diff --git a/.gitignore b/.gitignore index 992484a..1d8fe38 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,12 @@ venv *.pcm *.mp3 *.o + +# Generated demo output (e.g. examples/posix image_generation_demo) +generated-images/ + +# Local credentials for activated devices (never commit) +.envrc third_party/* !third_party/mbedtls !third_party/cJSON diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 0632b77..776f362 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -88,14 +88,15 @@ test:memory-leak: chmod +x "$VG" # Leak-check the pure (no-subprocess) binaries, grouped by module: # rtc-tcp-client : tai_unit_tests, tai_log_level_zero_tests, - # tai_log_module_zero_tests, tai_integration_tests + # tai_log_module_zero_tests, tai_integration_tests, + # image_generation_demo_test # iot-client : iot_cipher_test, iot_ai_ctrl_test # tuya-ble : tuya_ble_test # rtc-client is a prebuilt closed lib (headers + libstm.a) with no offline # test, so there is nothing to leak-check for it here. The mock-driven iot # tests are covered by the `test` job; under valgrind their Python # handshakes would time out. - for t in tai_unit_tests tai_log_level_zero_tests tai_log_module_zero_tests tai_integration_tests iot_cipher_test iot_ai_ctrl_test tuya_ble_test; do + for t in tai_unit_tests tai_log_level_zero_tests tai_log_module_zero_tests tai_integration_tests image_generation_demo_test iot_cipher_test iot_ai_ctrl_test tuya_ble_test; do echo "==================== valgrind: ${t} ====================" "$VG" "./${CMAKE_BUILD_DIR}/${t}" 2>&1 | tee "valgrind-${t}.log" rc=${PIPESTATUS[0]} diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b95db7..def324f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Examples — `unbind_demo` checks cloud binding status before client initialization/reconnect and after a removal notice (#44). - Examples — `activate_demo --release` prints cloud binding status before and after reset (#44). +### Fixed + +- iot-client — Match the request body `t` to the signed URL timestamp in `iot_atop_call()`. + ## [0.5.0] - 2026-09-24 ### Added diff --git a/CMakeLists.txt b/CMakeLists.txt index fc56e36..522d67a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -475,6 +475,10 @@ if(AGENTIC_KIT_BUILD_TESTS AND AGENTIC_KIT_ENABLE_PROJECT_TESTS) ) add_test(NAME iot_tai_control_test COMMAND iot_tai_control_test) + add_executable(image_generation_demo_test "${AI_TCP_TESTS_DIR}/image_generation_demo_test.c") + target_link_libraries(image_generation_demo_test PRIVATE tuya_ai_tcp tuya_iot_client agentic_kit_pal) + add_test(NAME image_generation_demo_test COMMAND image_generation_demo_test) + # The ble sources compile into the executable (not the tuya_ble # library) so they pick up the test sink's log remap from # tuya_iot_client_testlog's include path -- that is what lets diff --git a/docs-site/docs/reference/rtc-tcp-client.md b/docs-site/docs/reference/rtc-tcp-client.md index 581f119..5b52126 100644 --- a/docs-site/docs/reference/rtc-tcp-client.md +++ b/docs-site/docs/reference/rtc-tcp-client.md @@ -113,6 +113,7 @@ sidebar_position: 1 | `TAI_DATA_ID_TEXT_UP` | 3 | 上行文本 | | `TAI_DATA_ID_TEXT_DOWN` | 4 | 下行文本 | | `TAI_DATA_ID_IMAGE_UP` | 5 | 上行图像 | +| `TAI_DATA_ID_IMAGE_DOWN` | 6 | 下行图像(配置密钥的 Session 声明) | | `TAI_DATA_ID_AUDIO_AUX` | 7 | 辅助音频 | ### 2.10 返回码 {#210-返回码} @@ -133,7 +134,7 @@ sidebar_position: 1 ## 3. 配置(`tai_config_t`) {#3-配置tai_config_t} -调用 `tai_ctx_init` 前填充此结构体。所有指针字段在 context 生命周期内必须保持有效。 +调用 `tai_ctx_init` 前填充此结构体。除 `image_key` 外,所有指针字段在 context 生命周期内必须保持有效;该密钥在构造成功时被复制。 ### 3.1 服务器配置 {#31-服务器配置} @@ -171,7 +172,30 @@ sidebar_position: 1 | 字段 | 类型 | 说明 | |------|------|------| -| `sign_level` | `uint8_t` | 签名级别:`TAI_SIGN_HMAC_SHA256`(推荐)或 `TAI_SIGN_HMAC_SHA1`。注意 `TAI_SIGN_NONE`(0)会被视为"未设置"而强制回退到 `TAI_SIGN_HMAC_SHA256`,无法通过该字段关闭签名 | +| `sign_level` | `uint8_t` | 未配置 `image_key` 时:`TAI_SIGN_HMAC_SHA256`(推荐)或 `TAI_SIGN_HMAC_SHA1`;0 视为未设置并回退到 HMAC-SHA256。配置 `image_key` 时忽略此字段,使用签名级别 0,所有 Frame 均不附加签名。 | + +#### 图像密钥与加密媒体连接 {#image-decrypt-key} + +为兼容已有链接,保留历史锚点 `image-decrypt-key`;配置字段名为 `image_key`,同一密钥用于上行加密和下行解密。 + +| 字段 | 类型 | 说明 | +|------|------|------| +| `image_key` | `const uint8_t *` | 可选的固定 AES-128 图像密钥:恰好 `TAI_IMAGE_KEY_LEN`(16)个解码后的二进制字节。NULL 保持旧有行为。 | + +:::warning 配置密钥后必须使用 TLS +`image_key` 启用 TuyaOpen 加密媒体配置:Frame 版本 3、签名级别 0(忽略 `cfg.sign_level`),Attribute 仍使用 v2 布局。ClientHello 的 36 字节 suite 为 `00 || random[32] || 04 03 03`;后三字节分别表示 AES-128-GCM、完整视频 I 帧、完整图像的加密范围,**不是媒体能力位图**。本 SDK 不支持视频或文件处理。 + +此 unsigned Frame 安全决策已获用户明确批准。图像 GCM 不使用 AAD,只逐个认证完整 Image Packet 的加密正文;元数据、控制 Packet、URL 和空标记依赖 TLS 保护。配置非空 `ca_cert` 或 `cert_bundle_attach` 时验证对端;两者都未配置时,配置密钥的连接仍使用 TLS 但对端不验证(连接时告警一次)——与 IoT/MQTT 路径同一规则。配置密钥时若设置 `disable_tls`,`tai_connect()` 在网络操作前返回 `TAI_ERR_TLS`(不支持 raw TCP)。未验证的 TLS 存在中间人攻击风险,可泄露 token、图像密钥并危及元数据和控制流,因此不要在生产环境省略信任来源。实现已启用,但不代表已通过真实云端验证。 +::: + +配置密钥的 Session 仅声明上行音频/文本/图像 ID 1/3/5,下行音频/文本/图像 ID 2/4/6。TuyaOpen 按配置类型顺序分配偶数接收 ID;这支持本 SDK 的 Session 分配,并不意味着协议对所有媒体类型规定了通用固定 ID。`protocol_version` 保持 0 或 `TAI_VER_21`,不要为 Frame 版本 3 将该字段设为 3。 + +- 传入二进制密钥,不是 Base64 文本、C 字符串、`local_key`、设备凭据或派生的 Frame 密钥。指针必须指向至少 16 个可读字节;应用必须验证解码长度。全零密钥在结构上有效,同样被视为已配置。 +- `tai_ctx_init()` 仅在成功时复制密钥,随后调用方可以清零/释放临时字节。context 拥有不可变副本,断连和连接失败均不清除它;拥有者通过 `tai_disconnect()` 停止并 join worker 后,`tai_ctx_deinit()` 会清零该副本。 +- 密钥是固定的。获取和可选的保留由应用负责;IoT 初始化及所有 TAI 生命周期/接收路径均不自动获取、重试、刷新、轮换或持久化密钥。重新连接复用同一密钥,无需再次调用 ATOP。 +- SDK 和使用公开 `tai_config_t` / `tai_image_msg_t` 布局的调用方必须使用相同头文件重新编译。不透明 context 的内存大小通过 `tai_ctx_size()` 获取。IoT 公开布局(`iot_client_config_t`、`iot_on_boarding_config_t`、`iot_client_t`)不变。 + +[应用显式获取密钥](#application-image-key) 展示了仅构造、不连接的示例。 ### 3.6 保活配置 {#36-保活配置} @@ -185,14 +209,15 @@ sidebar_position: 1 | 字段 | 类型 | 说明 | |------|------|------| -| `disable_tls` | `uint8_t` | 非零时跳过 TLS,仅用于集成测试 | +| `disable_tls` | `uint8_t` | 非零时跳过 TLS,仅用于无图像密钥的集成测试;配置密钥时 `tai_connect()` 在网络操作前返回 `TAI_ERR_TLS` | ### 3.8 平台适配 {#38-平台适配} | 字段 | 类型 | 说明 | |------|------|------| | `pal` | `const pal_t *` | 平台适配层实现指针 | -| `cert_bundle_attach` | `tls_cert_bundle_attach_fn` | 平台证书包回调(ESP-IDF 上设为 `esp_crt_bundle_attach`,否则 TLS 不做证书校验);NULL 表示不使用。详见 [TLS 证书验证](../guides/tls-cert-verification.md) | +| `cert_bundle_attach` | `tls_cert_bundle_attach_fn` | 平台证书包回调(ESP-IDF 上设为 `(tls_cert_bundle_attach_fn)esp_crt_bundle_attach`);NULL 表示不使用证书包。可替代 `ca_cert` 提供对端验证。详见 [TLS 证书验证](../guides/tls-cert-verification.md) | +| `ca_cert` | `const char *` | 可选 CA 证书,完整 PEM 或裸 Base64 正文,**不是文件路径**。借用至 context 销毁;非空时要求验证对端。配置图像密钥时提供此字段或 `cert_bundle_attach` 才验证对端;两者均省略时仍使用 TLS 但对端不验证(连接时告警一次)。 | ### 3.9 回调函数 {#39-回调函数} @@ -263,20 +288,27 @@ int (*on_flow_control)(tai_ctx_t *ctx, void *user_data); | 字段 | 类型 | 说明 | |------|------|------| -| `data` | `const uint8_t *` | 编码图像字节(JPEG/PNG);回调生命周期内有效 | +| `data` | `const uint8_t *` | 借用的图像负载:配置密钥时为认证后的明文字节或 URL 透传;未配置时为旧有原始负载;仅回调期间有效 | +| `payload_type` | `uint8_t` | 配置密钥时:解密后为 `TAI_IMG_PAYLOAD_RAW`,URL 透传为 `TAI_IMG_PAYLOAD_URL`;旧有路径报告该 Packet 解析出的类型,缺省为 RAW | | `len` | `size_t` | 数据字节数 | | `format` | `uint8_t` | `TAI_IMG_JPEG` / `TAI_IMG_PNG` / 0=未知 | -| `width` | `uint16_t` | 宽(px),未知或非 START/ONE_SHOT 包时为 0 | -| `height` | `uint16_t` | 高(px),未知或非 START/ONE_SHOT 包时为 0 | +| `width` | `uint16_t` | 宽(px),未知或该 Packet 无 image-params 时为 0 | +| `height` | `uint16_t` | 高(px),未知或该 Packet 无 image-params 时为 0 | | `stream_flag` | `uint8_t` | `TAI_STREAM_*` | -| `data_id` | `uint16_t` | 数据 ID(取自媒体头) | +| `data_id` | `uint16_t` | 数据 ID(取自媒体头);配置密钥的接收路径要求 `TAI_DATA_ID_IMAGE_DOWN`(6) | | `event_id` | `const char *` | turn id(借用);无则为 `""` | | `timestamp_ms` | `uint64_t` | 流起始时间戳(媒体头) | :::note 流式接收 -接收到的图片以分片流形式到达:START(或 ONE_SHOT)携带首字节与 image-params,MIDDLE 继续,END 结束(len 可能为 0)。调用方按 `stream_flag` 累积分片,在流结束后(END 或 ONE_SHOT)解码整图。`format`/`width`/`height` 仅在 START/ONE_SHOT 从 image-params 解析得到,MIDDLE/END 时为 0。 +接收到的图片以分片流形式到达:START(或 ONE_SHOT)携带首字节与 image-params,MIDDLE 继续,END 结束(len 可能为 0)。调用方按 `stream_flag` 复制/累积分片;仅在应用确定累计字节构成受支持的图像文件时,才可在 END 或 ONE_SHOT 后进行图像编解码。`format`/`width`/`height` 来自该 Packet 的 image-params,省略该属性的 MIDDLE/END 为 0。 ::: +回调签名和 worker 线程交付不变,新增 `payload_type` 区分交付的负载。已实现的配置密钥路径在 transport Fragmentation 重组后逐个认证完整 Image Packet:24 字节头部含 16 字节 IV;RAW 的正文是密文加 16 字节 GCM tag,Base64 则先严格解码再认证;AES-128-GCM 不使用 AAD。仅成功认证后交付非空图像明文;tag-only 正文也必须认证。24 字节的 header-only START/ONE_SHOT/END 被当作零长度标记,header-only MIDDLE 被拒绝;标记不是认证后的图像数据。URL 只以 URL 类型透传,不进行 GCM 或下载。无密钥路径保持原始字节,不自动解码或解密;SDK 不将 JPEG/PNG 解码为像素。 + +配置密钥的解析严格校验 image-params 的四个无符号字段及范围、重复 image-params 和 Event ID;START/ONE_SHOT 必须携带 image-params,MIDDLE/END 必须匹配已锁定的 Event/Data ID,若重复携带参数则必须一致。缺失属性的 continuation 继承负载类型,`format`/`width`/`height` 为 0;携带一致属性时报告这些值。孤立或冲突 continuation、畸形 Base64、截断和认证失败均不交付失败 Packet。完成、Event 边界、SessionClose、断连及错误会清除相应流状态,不会触发密钥获取或明文回退。 + +明文来自临时 PAL 堆缓冲区,回调返回后清零并释放;需要保留时按 `len` 复制。URL/文本不保证 NUL 结尾。现有 `AGENTIC_KIT_TAI_FRAG_BUF_SIZE` 默认 32,000 字节限制单个重组 Packet,而非整张图像。RAW 需要独立明文 scratch,Base64 还需要解码 scratch;云端最大 Packet 和目标峰值内存**尚未实测**。配置密钥的连接已启用,须满足[TLS 信任要求及显式例外](#image-decrypt-key)。 + `tai_event_msg_t`(事件回调): | 字段 | 类型 | 说明 | @@ -303,7 +335,7 @@ int (*on_flow_control)(tai_ctx_t *ctx, void *user_data); | `session_id` | `char[64]` | 值拷贝;无则为 `""` | :::warning 生命周期 -`msg` 以及其内部所有指针(`data` / `text` / `event_id` 等)**仅在回调执行期间有效**——SDK 从不堆分配它们。如需在回调返回后保留,请自行拷贝。 +`msg` 以及其内部所有指针(`data` / `text` / `event_id` 等)**仅在回调执行期间有效**——图像明文可能指向临时堆缓冲区,回调返回后即清零并释放。如需在回调返回后保留,请自行拷贝。 ::: #### 断连原因(`reason`) {#断连原因reason} @@ -337,6 +369,10 @@ int (*on_flow_control)(tai_ctx_t *ctx, void *user_data); | `TAI_PROTO_ERR_MEDIA_HDR` | 8 | 媒体/文本头截断 | | `TAI_PROTO_ERR_UNEXPECTED` | 9 | 包合法但状态错误(行为错误) | | `TAI_PROTO_ERR_OVERSIZED` | 10 | 入站帧超出 rx_buf | +| `TAI_PROTO_ERR_IMAGE_AUTH` | 11 | 图像 GCM 认证失败 | +| `TAI_PROTO_ERR_IMAGE_PROCESSING` | 12 | 图像 scratch 分配或处理失败 | + +这些是正数 fatal detail,不是负数 `TAI_ERR_*` 返回码。图像错误通过一次终止性的 `on_disconnect(PROTOCOL, detail)` 报告;畸形头部/属性、流状态错误仍使用对应的既有 detail。失败 Packet 不触发 `on_image`,应用须丢弃此前累计但尚未完成的图像。 --- @@ -384,7 +420,7 @@ void tai_ctx_deinit(tai_ctx_t *ctx); int tai_connect(tai_ctx_t *ctx); ``` -执行 TLS 握手、发送 ClientHello、建立会话、启动后台接收线程。 +执行 TLS 握手、发送 ClientHello、确认会话应答并启动后台接收线程。构造时传入 `image_key` 会选择[加密媒体配置](#image-decrypt-key),而不是阻止连接;若启用 raw TCP,则在网络操作前返回 `TAI_ERR_TLS`。提供非空 `ca_cert` / `cert_bundle_attach` 时要求验证对端;两者均省略时仍使用 TLS 但对端不验证并告警一次。TLS 证书或握手失败也返回 `TAI_ERR_TLS`。无密钥时保留旧有行为。 **返回值:** `TAI_OK` 成功,`TAI_ERR_*` 失败。 @@ -501,6 +537,8 @@ int tai_send_image(tai_ctx_t *ctx, 发送单张图片。 +`image_key` 同时用于三个图像发送 API:`tai_send_image()`、`tai_send_image_with_text()` 和 `tai_send_image_audio_start()`。加密准备在 EventStart 之前完成,不修改调用方缓冲区;非空图片需要 `len + 32` 字节临时 scratch(16 字节 IV + `len` 字节密文 + 16 字节 tag),空图片需要 16 字节 scratch(仅 IV,无 tag)。scratch 在发送完成或失败后清零并释放;无密钥发送保持旧有行为。这些发送路径使用[默认验证 TLS 对端的加密媒体连接](#image-decrypt-key);真实云端兼容性仍待验证。 + **参数:** - `data` — 图片二进制数据 - `len` — 数据长度 @@ -615,6 +653,149 @@ free(mem); --- +### 应用显式获取图像密钥(仅构造) {#application-image-key} + +使用公开的通用 `iot_atop_call()`,指定 API `thing.km.secret.get.device`、版本 `1.0`、`data = "{}"`。空对象依据 TuyaOpen 参考实现未传业务参数的做法,目前仅由本地 mock 测试覆盖;**实际云端是否接受尚未验证(UNVERIFIED)**。通用 API 不补充业务字段,不要自行添加 token/agent 参数。`OPRT_OK` 本身不能证明 `result.secretValue` 可用。 + +以下应用本地示例返回构造好的 context 或 NULL,**不调用 `tai_connect()`**,只显式请求一次 ATOP,不重试。应用提供在 `iot_init()` 之后创建的已激活 IoT client、正确对齐且至少为 `tai_ctx_size()` 字节的自有 `mem`,以及其余字段完整的 `base` 配置。`ca_cert` 参数必须提供应用信任的真实 CA(完整 PEM 或裸 Base64 正文),或传 NULL 并使用 `base->cert_bundle_attach` 中已有的平台证书包。helper 将 CA 赋给 `cfg.ca_cert`,不内置示例证书;CA 和其他借用字段须在 context 生命周期内保持有效。示例仅使用 SDK/库的公开头文件,不增加具名 SDK 封装,不改变 cJSON 的分配器钩子。 + +```c +#include +#include +#include +#include "iot_atop.h" +#include "tuya_ai.h" +#include "cJSON.h" +#include "mbedtls/base64.h" +#include "mbedtls/platform_util.h" + +/* Only for owned trees returned by cJSON_ParseWithOpts. */ +static void app_wipe_json_strings(cJSON *node) +{ + for (; node; node = node->next) { + app_wipe_json_strings(node->child); + if (node->valuestring) + mbedtls_platform_zeroize(node->valuestring, strlen(node->valuestring)); + if (node->string) + mbedtls_platform_zeroize(node->string, strlen(node->string)); + } +} + +static int app_decode_image_key(const char *text, uint8_t *key) +{ + static const char alphabet[] = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + size_t decoded_len = 0; + + if (!text || strlen(text) != 24 || text[22] != '=' || text[23] != '=') + return -1; + for (size_t i = 0; i < 22; ++i) { + const char *p = strchr(alphabet, text[i]); + if (!p) return -1; + /* With '==', the last sextet's four unused bits must be zero. */ + if (i == 21 && ((size_t)(p - alphabet) & 15u) != 0) + return -1; + } + if (mbedtls_base64_decode(key, TAI_IMAGE_KEY_LEN, &decoded_len, + (const unsigned char *)text, 24) != 0) + return -1; + return decoded_len == TAI_IMAGE_KEY_LEN ? 0 : -1; +} + +static tai_ctx_t *app_construct_with_image_key(iot_client_t *client, + void *mem, + const tai_config_t *base, + const char *ca_cert) +{ + const iot_atop_request_t request = { + .api = "thing.km.secret.get.device", + .version = "1.0", + .data = "{}" + }; + iot_atop_response_t response = {0}; + cJSON *result = NULL; + cJSON *secret = NULL; + uint8_t key[TAI_IMAGE_KEY_LEN] = {0}; + tai_config_t cfg = {0}; + tai_ctx_t *ctx = NULL; + + if (!client || !mem || !base) goto cleanup; + if (base->disable_tls || + ((!ca_cert || !ca_cert[0]) && !base->cert_bundle_attach)) goto cleanup; + if (iot_atop_call(client, &request, &response) != OPRT_OK) + goto cleanup; + if (!response.result) goto cleanup; + /* response.result already IS the result, not the ATOP Envelope. */ + result = cJSON_ParseWithOpts(response.result, NULL, 1); + if (!cJSON_IsObject(result)) goto cleanup; + secret = cJSON_GetObjectItemCaseSensitive(result, "secretValue"); + if (!cJSON_IsString(secret) || !secret->valuestring) goto cleanup; + if (app_decode_image_key(secret->valuestring, key) != 0) goto cleanup; + + cfg = *base; + cfg.ca_cert = ca_cert; + cfg.image_key = key; + ctx = tai_ctx_init(mem, &cfg); + cfg.image_key = NULL; + +cleanup: + mbedtls_platform_zeroize(key, sizeof(key)); + app_wipe_json_strings(result); + cJSON_Delete(result); + if (response.result) + mbedtls_platform_zeroize(response.result, strlen(response.result)); + mbedtls_platform_zeroize(response.error_code, sizeof(response.error_code)); + mbedtls_platform_zeroize(response.error_msg, sizeof(response.error_msg)); + iot_atop_response_free(client, &response); + return ctx; +} +``` + +校验器仅接受规范的标准 Base64:长度为 24,前 22 个字符属于标准字母表,末尾恰好为 `==`,最后一个六位组的未用位为零,且解码结果恰好 16 字节。空白、URL-safe 变体、错误填充和错误长度都会在配置前被拒绝。传输/业务失败、result 缺失/null/非对象、密钥缺失/非字符串、非法 Base64 及构造失败均经同一清理路径返回 NULL。 + +构造成功后及所有失败路径均清零临时二进制密钥;删除解析树前,局部 helper 通过公开的 child/next 字段遍历所有节点,清零字符串值和对象成员名,包括被拒绝形状内的嵌套内容,而不只清理 secretValue。返回的明文 JSON 和 response.error_code/error_msg 也在响应释放前清零。这只是应用可见缓冲区的实际清理,不保证解析器/传输内部没有其他副本。不要记录 response、`secretValue` 或解码后的密钥。使用真实密钥前,确保 SDK 已包含该 secret API 的响应正文日志抑制;仅关闭 TAI 日志不会抑制 IoT ATOP 诊断。 + +成功后 context 由调用方持有:最终依次调用 `tai_disconnect(ctx)`、`tai_ctx_deinit(ctx)`,再释放 `mem`。返回 NULL 时没有构造成功的 context,`mem` 仍由调用方持有。示例不持久化固定密钥;应用若已保留经过验证的密钥,可直接传给 `tai_ctx_init()`,无需再次请求。两条路径均选择加密媒体配置;由拥有者在构造成功后调用 `tai_connect(ctx)` 并检查返回值,TLS 对端验证及会话确认成功后即可收发加密图像。helper 只检查信任来源是否配置,真正的证书验证发生在连接时;这不是已通过真实云端验证的声明。 前面的典型流程省略该字段(NULL),保持旧有行为。 + +--- + +### 生成图像并保存到本地 {#image-generation-demo} + +`examples/posix/ai/rtc-tcp-client/image_generation_demo.c` 通过 `tai_send_text()` 发送一次提示词,保存第一张完整的内联 PNG/JPEG。这是云端文生图,不是上传图像进行识别,也不是本地渲染。前提是设备已激活并绑定获授权的 Agent,且其云端工作流生成**内联 Image Packet**。请遵循产品的[工作流配置](../guides/create-workflow.md)及路由约定;发送提示词不会配置工作流,也不能假定 `clm_intent=ai_image` 是通用的生成意图。 + +运行前,通过安全的环境变量/秘密注入机制从外部提供 `TUYA_DEVICE_ID`、`TUYA_SECRET_KEY` 和 `TUYA_LOCAL_KEY`。不要提交凭据,也不要将秘密字面值输入 shell 命令或历史记录。默认必须提供 `--ca-file`:可信 CA bundle 必须同时覆盖 **bootstrap HTTPS 和 TAI 端点**。示例读取其内容(非空,最多 256 KiB),传给 IoT `cacert` 和 TAI `ca_cert`,并保留至客户端销毁;SDK 接收的是证书内容,不是文件名。布尔开关 `--allow-insecure-fallback`(不带参数值)显式允许省略 `--ca-file`:此时 IoT HTTPS 和 TAI 仍使用 TLS,但不验证对端,并警告中间人攻击可能窃取 token、图像密钥或篡改元数据和控制流。若提供 `--ca-file`,即使同时指定该开关也必须验证对端;无效或不存在的 CA 路径仍报错,证书或握手失败不会自动重试为未验证连接。示例不会通过未验证的连接下载信任锚点。 + +:::warning 使用凭据前,以 WARN 上限重新编译所有 SDK 库 +SDK 现有 DEBUG 日志可能暴露 Session token 响应,TAI 的 INFO Packet 日志也可能暴露 token。请按下方命令使用 `-DCMAKE_C_FLAGS="-DAGENTIC_KIT_LOG_LEVEL=2"`,使**所有 C 库**(而非仅示例或 TAI)编译为只输出 ERROR/WARN 日志。单独设置 CMake 变量 `-DAGENTIC_KIT_LOG_LEVEL=2` 并未接入编译器宏定义。没有运行时日志级别开关;调整上限必须重新编译。 +::: + +从仓库根目录执行,使用独立的 `build-examples` 目录(不要复用根 SDK 的构建目录): + +```sh +cmake -S examples/posix -B build-examples \ + -DPython3_EXECUTABLE="$(command -v python3)" \ + -DCMAKE_C_FLAGS="-DAGENTIC_KIT_LOG_LEVEL=2" +cmake --build build-examples --target image_generation_demo -j 4 + +./build-examples/image_generation_demo \ + --prompt "Generate a watercolor picture of a red panda reading under a tree" \ + --ca-file /path/to/trusted-ca-bundle.pem \ + --output-dir ./generated-images \ + --timeout 120 \ + --region AY +``` + +请替换 CA 路径。每个产品只绑定一个 Agent,产品下的设备统一使用该 Agent;示例不提供 Agent 选择参数。示例**仅使用 PROD**,区域默认为 **AY**,接受的精确区域拼写为 `AY`、`AZ`、`UEAZ`、`EU`、`WEAZ`、`IN`、`SG`。`--timeout` 默认 **120 秒**,从提示词发送后开始计时;不包含 bootstrap HTTPS 和 Connection 建立,因此不是整个进程的执行时限。 + +- **引导与密钥生命周期:** 关闭 MQTT 自动连接后,示例获取 Session token,并[显式获取固定图像密钥](#application-image-key)一次。校验规范 Base64 解码结果恰好为 16 字节,清零应用持有的密钥响应副本及构造后的临时密钥,并在 context 销毁时清零其密钥副本。不持久化密钥,不自动重连或重发提示词,以免重复生成或重复计费。 +- **有界接收:** 回调负载/标识符是借用数据,在返回前复制到一个受 mutex 保护的累积缓冲区,整张图像上限为 **16 MiB**(SDK 的单 Packet 上限仍独立生效)。完成必须是非空 ONE_SHOT 或完整的 START/MIDDLE/END 流,单独 EventEnd 不算完成。回调通过 `tai_request_disconnect()` 请求停止;仅主线程在 `tai_disconnect()` join worker 后写入文件。 +- **本地输出:** `--output-dir` 默认为相对进程工作目录的 `./generated-images`。示例在云端调用前验证目录,缺失时只创建最后一级,父目录必须已存在。PNG/JPEG 签名检查拒绝未知字节或与非零声明格式不符的内容,但不做解码器验证。独占临时文件写入并关闭后,以本地生成的唯一 `.png`/`.jpg` 文件名发布,不覆盖已有文件;重名时失败。提示词和 Event ID 不用作文件名。 +- **结果与不支持的交付:** 生成任务仅在文件发布成功后退出 0,并打印保存路径、字节数、格式及尺寸(0 表示未知)。文本/音频被丢弃。未实现 Image URL 交付下载,也不下载 NLG 文本中的图片链接;只有 URL 或只有文本的响应会失败,不会被保存为图片。超时、打断、图像不完整、引导/TLS/协议错误、大小/内存限制及文件错误均返回非零。 + +真实云端生成、所选工作流的交付模式、密钥服务是否接受请求及加密媒体互通均**尚未验证**;本用法示例不代表已完成端到端云端运行。 + +--- + ## 8. 线程安全 {#8-线程安全} - `tai_send_*` 函数是线程安全的,可以从任意线程调用 diff --git a/docs-site/i18n/en/docusaurus-plugin-content-docs/current/reference/rtc-tcp-client.md b/docs-site/i18n/en/docusaurus-plugin-content-docs/current/reference/rtc-tcp-client.md index 122962b..02f7b33 100644 --- a/docs-site/i18n/en/docusaurus-plugin-content-docs/current/reference/rtc-tcp-client.md +++ b/docs-site/i18n/en/docusaurus-plugin-content-docs/current/reference/rtc-tcp-client.md @@ -113,6 +113,7 @@ Header file: `tuya_ai.h` (single include) | `TAI_DATA_ID_TEXT_UP` | 3 | Uplink text | | `TAI_DATA_ID_TEXT_DOWN` | 4 | Downlink text | | `TAI_DATA_ID_IMAGE_UP` | 5 | Uplink image | +| `TAI_DATA_ID_IMAGE_DOWN` | 6 | Downlink image (advertised by keyed Sessions) | | `TAI_DATA_ID_AUDIO_AUX` | 7 | Auxiliary audio | ### 2.10 Return Codes {#210-返回码} @@ -133,7 +134,7 @@ Header file: `tuya_ai.h` (single include) ## 3. Configuration (`tai_config_t`) {#3-配置tai_config_t} -Populate this structure before calling `tai_ctx_init`. All pointer fields must remain valid for the lifetime of the context. +Populate this structure before calling `tai_ctx_init`. All pointer fields except `image_key` must remain valid for the lifetime of the context; that key is copied during successful construction. ### 3.1 Server Configuration {#31-服务器配置} @@ -171,7 +172,30 @@ Populate this structure before calling `tai_ctx_init`. All pointer fields must r | Field | Type | Description | |------|------|------| -| `sign_level` | `uint8_t` | Sign level: `TAI_SIGN_HMAC_SHA256` (recommended) or `TAI_SIGN_HMAC_SHA1`. Note that `TAI_SIGN_NONE` (0) is treated as "unset" and forcibly falls back to `TAI_SIGN_HMAC_SHA256`; signing cannot be disabled through this field | +| `sign_level` | `uint8_t` | Without `image_key`: `TAI_SIGN_HMAC_SHA256` (recommended) or `TAI_SIGN_HMAC_SHA1`; 0 means unset and falls back to HMAC-SHA256. With `image_key`, this field is ignored: sign level is 0 and no Frame carries a signature. | + +#### Image key and encrypted-media Connections {#image-decrypt-key} + +The legacy anchor `image-decrypt-key` is retained for existing links; the configuration field is `image_key`, and the same key encrypts uplink and decrypts downlink images. + +| Field | Type | Description | +|------|------|------| +| `image_key` | `const uint8_t *` | Optional fixed AES-128 image key: exactly `TAI_IMAGE_KEY_LEN` (16) decoded binary bytes. NULL preserves legacy behavior. | + +:::warning A supplied key requires TLS +`image_key` enables TuyaOpen's encrypted-media profile: Frame version 3, sign level 0 (ignoring `cfg.sign_level`), with the v2 Attribute layout unchanged. The 36-byte ClientHello suite is `00 || random[32] || 04 03 03`; the last three bytes select AES-128-GCM, whole video I-frames and whole images as encryption ranges, **not a media-capability bitmap**. This SDK does not support video or file handling. + +The user explicitly approved this unsigned-Frame security decision. Image GCM uses no AAD and authenticates the encrypted body of each complete Image Packet; metadata, control Packets, URLs and empty markers rely on TLS protection. A nonempty `cfg.ca_cert` or `cert_bundle_attach` verifies the peer; with neither, a keyed Connection still uses TLS but with the peer unverified (one warning at connect) -- the same CA-driven rule as the IoT/MQTT path. With a key, `disable_tls` makes `tai_connect()` return `TAI_ERR_TLS` before networking (raw TCP is unsupported). Unverified TLS risks man-in-the-middle attacks exposing tokens and the image key and compromising metadata and control traffic, so omit a trust source only outside production. The implementation is enabled, but this is not a live-cloud verification claim. +::: + +Keyed Sessions advertise only audio/text/image: send IDs 1/3/5 and receive IDs 2/4/6. TuyaOpen allocates even receive IDs in configured type order; that supports this SDK's Session allocation, not a universal fixed ID for each media type. Keep `protocol_version` at 0 or `TAI_VER_21`; do not set it to 3 to select Frame version 3. + +- Pass binary key material, not Base64 text, a C string, `local_key`, device credentials or a derived Frame key. The pointer must reference at least 16 readable bytes; the application must validate the decoded length. An all-zero key is structurally valid and still counts as supplied. +- `tai_ctx_init()` copies the key only on success. The caller may then wipe/release its temporary bytes. The context owns an immutable copy, retained across disconnect and failed Connection attempts, and `tai_ctx_deinit()` wipes it after the owner has stopped/joined any worker with `tai_disconnect()`. +- The key is fixed. Acquisition and optional retention belong to the application; there is no automatic acquisition, retry, refresh, rotation or persistence in IoT initialization or any TAI lifecycle/receive path. Reconnect reuses the same key without another ATOP call. +- Rebuild the SDK and consumers of the public `tai_config_t` / `tai_image_msg_t` layouts against the same headers. Allocate opaque context storage using `tai_ctx_size()`. IoT public layouts (`iot_client_config_t`, `iot_on_boarding_config_t`, `iot_client_t`) are unchanged. + +See [explicit application acquisition](#application-image-key) for a construction-only example. ### 3.6 Keepalive Configuration {#36-保活配置} @@ -185,14 +209,15 @@ Populate this structure before calling `tai_ctx_init`. All pointer fields must r | Field | Type | Description | |------|------|------| -| `disable_tls` | `uint8_t` | If nonzero, skips TLS; for integration testing only | +| `disable_tls` | `uint8_t` | If nonzero, skips TLS for no-key integration tests only; with an image key, `tai_connect()` returns `TAI_ERR_TLS` before networking | ### 3.8 Platform Adaptation {#38-平台适配} | Field | Type | Description | |------|------|------| | `pal` | `const pal_t *` | Pointer to the platform adaptation layer implementation | -| `cert_bundle_attach` | `tls_cert_bundle_attach_fn` | Platform certificate bundle callback (set to `esp_crt_bundle_attach` on ESP-IDF; otherwise TLS does not verify certificates); NULL means unused. See [TLS Certificate Verification](../guides/tls-cert-verification.md) | +| `cert_bundle_attach` | `tls_cert_bundle_attach_fn` | Platform certificate bundle callback (set to `(tls_cert_bundle_attach_fn)esp_crt_bundle_attach` on ESP-IDF); NULL means no bundle. Provides peer verification as an alternative to `ca_cert`. See [TLS Certificate Verification](../guides/tls-cert-verification.md) | +| `ca_cert` | `const char *` | Optional CA certificate: full PEM or bare Base64 body, **not a file path**. Borrowed until context destruction; nonempty requires peer verification. A keyed Connection verifies the peer only when this field or `cert_bundle_attach` is set; omitting both still uses TLS but with the peer unverified (one warning at connect). | ### 3.9 Callbacks {#39-回调函数} @@ -263,20 +288,27 @@ Backpressure is rechecked every `AGENTIC_KIT_TAI_FLOW_CONTROL_POLL_MS` (50 ms by | Field | Type | Description | |------|------|------| -| `data` | `const uint8_t *` | Encoded image bytes (JPEG/PNG); valid for the callback lifetime | +| `data` | `const uint8_t *` | Borrowed image payload: authenticated plaintext or URL pass-through with a key, legacy raw payload without one; valid only during the callback | +| `payload_type` | `uint8_t` | With a key: `TAI_IMG_PAYLOAD_RAW` after decryption, `TAI_IMG_PAYLOAD_URL` for URL pass-through; the legacy path reports the type parsed for that Packet, defaulting to RAW | | `len` | `size_t` | Data length in bytes | | `format` | `uint8_t` | `TAI_IMG_JPEG` / `TAI_IMG_PNG` / 0=unknown | -| `width` | `uint16_t` | Width (px), 0 if unknown or if this is not a START/ONE_SHOT Packet | -| `height` | `uint16_t` | Height (px), 0 if unknown or if this is not a START/ONE_SHOT Packet | +| `width` | `uint16_t` | Width (px), 0 if unknown or this Packet omits image-params | +| `height` | `uint16_t` | Height (px), 0 if unknown or this Packet omits image-params | | `stream_flag` | `uint8_t` | `TAI_STREAM_*` | -| `data_id` | `uint16_t` | Data ID (from the media header) | +| `data_id` | `uint16_t` | Data ID (from the media header); keyed receive requires `TAI_DATA_ID_IMAGE_DOWN` (6) | | `event_id` | `const char *` | Turn ID (borrowed); `""` if absent | | `timestamp_ms` | `uint64_t` | Stream start timestamp (media header) | :::note Streaming reception -Received images arrive as a stream of chunks: START (or ONE_SHOT) carries the first bytes and image-params, MIDDLE continues the stream, and END finishes it (len may be 0). The caller accumulates the chunks according to the `stream_flag` and decodes the complete image after the stream ends (END or ONE_SHOT). `format`/`width`/`height` are parsed from image-params only for START/ONE_SHOT and are 0 for MIDDLE/END. +Received images arrive as a stream of chunks: START (or ONE_SHOT) carries the first bytes and image-params, MIDDLE continues the stream, and END finishes it (len may be 0). The caller copies/accumulates chunks according to `stream_flag`; image-codec decoding after END or ONE_SHOT is appropriate only if the application knows the accumulated bytes form a supported image file. `format`/`width`/`height` come from that Packet's image-params and are 0 for MIDDLE/END that omit the Attribute. ::: +The callback signature and worker-thread delivery are unchanged; the added `payload_type` identifies the delivered payload. The implemented keyed path authenticates each complete Image Packet after transport Fragmentation reassembly: the 24-byte header includes a 16-byte IV; RAW bodies contain ciphertext plus a 16-byte GCM tag, while Base64 bodies are strictly decoded before authentication; AES-128-GCM uses no AAD. Nonempty image plaintext is delivered only after authentication succeeds; tag-only bodies must also authenticate. Header-only 24-byte START/ONE_SHOT/END Packets are zero-length markers; header-only MIDDLE is rejected. Markers are not authenticated image data. URLs are typed pass-through only, with no GCM or download. Without a key, original bytes remain unchanged, with no automatic decoding or decryption. The SDK does not decode JPEG/PNG into pixels. + +Keyed parsing strictly validates the four unsigned image-params fields and their ranges, duplicate image-params and Event IDs. START/ONE_SHOT requires image-params; MIDDLE/END must match the latched Event/Data ID and any repeated parameters must agree. Continuations without Attributes inherit payload type but report 0 for `format`/`width`/`height`; matching repeated Attributes supply those values. Orphan/conflicting continuations, malformed Base64, truncation and authentication failures do not deliver the failed Packet. Completion, Event boundaries, SessionClose, disconnect and errors clear the relevant stream state, without key acquisition or plaintext fallback. + +Plaintext uses temporary PAL heap storage, wiped and freed after callback return; copy `len` bytes to retain it. URL/text bytes need not be NUL-terminated. The existing `AGENTIC_KIT_TAI_FRAG_BUF_SIZE` default of 32,000 bytes limits one reassembled Packet, not an entire image. RAW needs separate plaintext scratch; Base64 also needs decode scratch. Maximum cloud Packet sizes and target peak memory are **not measured**. Keyed Connections are enabled subject to the [TLS trust requirements and explicit exception](#image-decrypt-key). + `tai_event_msg_t` (Event callback): | Field | Type | Description | @@ -303,7 +335,7 @@ The server time for `TAI_EVT_CHAT_BREAK` is not in the event payload but in attr | `session_id` | `char[64]` | Copied value; `""` if absent | :::warning Lifetime -`msg` and all pointers within it (`data` / `text` / `event_id`, and so on) are valid **only for the duration of the callback** - the SDK never heap-allocates them. Copy them if they must be retained after the callback returns. +`msg` and all pointers within it (`data` / `text` / `event_id`, and so on) are valid **only for the duration of the callback** - image plaintext may point to temporary heap storage that is wiped and freed after callback return. Copy them if they must be retained after the callback returns. ::: #### Disconnect Reasons (`reason`) {#断连原因reason} @@ -337,6 +369,10 @@ The server time for `TAI_EVT_CHAT_BREAK` is not in the event payload but in attr | `TAI_PROTO_ERR_MEDIA_HDR` | 8 | Truncated media/text header | | `TAI_PROTO_ERR_UNEXPECTED` | 9 | Valid Packet in an invalid state (behavior error) | | `TAI_PROTO_ERR_OVERSIZED` | 10 | Inbound Frame exceeds rx_buf | +| `TAI_PROTO_ERR_IMAGE_AUTH` | 11 | Image GCM authentication failed | +| `TAI_PROTO_ERR_IMAGE_PROCESSING` | 12 | Image scratch allocation or processing failed | + +These are positive fatal details, not negative `TAI_ERR_*` return codes. Image errors produce one terminal `on_disconnect(PROTOCOL, detail)`; malformed headers/Attributes and invalid stream state still use the corresponding existing details. The failed Packet produces no `on_image`; the application must discard any previously accumulated incomplete image. --- @@ -384,7 +420,7 @@ Deinitializes the context and releases internal resources. `mem` can be freed af int tai_connect(tai_ctx_t *ctx); ``` -Performs the TLS handshake, sends ClientHello, establishes a Session, and starts the background receive thread. +Performs the TLS handshake, sends ClientHello, confirms the Session acknowledgement, and starts the background receive thread. Supplying `image_key` at construction selects the [encrypted-media profile](#image-decrypt-key), rather than blocking the Connection. Raw TCP returns `TAI_ERR_TLS` before networking. A nonempty `ca_cert` or `cert_bundle_attach` requires peer verification; with neither, the Connection still uses TLS but with the peer unverified and one warning logged. Certificate or TLS handshake failure also returns `TAI_ERR_TLS`. No-key behavior remains unchanged. **Return value:** `TAI_OK` on success, or `TAI_ERR_*` on failure. @@ -501,6 +537,8 @@ int tai_send_image(tai_ctx_t *ctx, Sends a single image. +`image_key` is shared by all three image send APIs: `tai_send_image()`, `tai_send_image_with_text()` and `tai_send_image_audio_start()`. Encryption preparation completes before EventStart without modifying the caller's buffer. A nonempty image needs `len + 32` bytes of temporary scratch (16-byte IV + `len` ciphertext bytes + 16-byte tag); an empty image needs 16 scratch bytes (IV only, no tag). Scratch is wiped and freed after send success or failure; no-key sends retain legacy behavior. These send paths use the [verified-TLS encrypted-media Connection](#image-decrypt-key); live-cloud compatibility remains unverified. + **Parameters:** - `data` - Image binary data - `len` - Data length @@ -615,6 +653,149 @@ free(mem); --- +### Explicit application image-key acquisition (construction only) {#application-image-key} + +Use the public generic `iot_atop_call()` with API `thing.km.secret.get.device`, version `1.0`, and `data = "{}"`. The empty object follows the TuyaOpen reference's lack of business parameters and is covered by the local mock tests only; **actual cloud acceptance is UNVERIFIED**. The generic API does not add business fields; do not invent token/agent parameters. `OPRT_OK` alone does not establish that `result.secretValue` is usable. + +This application-local example returns a constructed context or NULL, **never calls `tai_connect()`**, and makes one explicit ATOP request with no retries. Supply an activated IoT client after `iot_init()`, suitably aligned caller-owned `mem` of at least `tai_ctx_size()` bytes, and an otherwise complete `base` configuration. The `ca_cert` argument must supply a real application-trusted CA (full PEM or bare Base64 body), or be NULL when using an existing platform bundle in `base->cert_bundle_attach`. The helper assigns the CA to `cfg.ca_cert` rather than embedding an example certificate; keep the CA and all other borrowed fields alive for the context lifetime. The example uses only public SDK/library headers; it adds no named SDK wrapper and does not change cJSON's allocator hooks. + +```c +#include +#include +#include +#include "iot_atop.h" +#include "tuya_ai.h" +#include "cJSON.h" +#include "mbedtls/base64.h" +#include "mbedtls/platform_util.h" + +/* Only for owned trees returned by cJSON_ParseWithOpts. */ +static void app_wipe_json_strings(cJSON *node) +{ + for (; node; node = node->next) { + app_wipe_json_strings(node->child); + if (node->valuestring) + mbedtls_platform_zeroize(node->valuestring, strlen(node->valuestring)); + if (node->string) + mbedtls_platform_zeroize(node->string, strlen(node->string)); + } +} + +static int app_decode_image_key(const char *text, uint8_t *key) +{ + static const char alphabet[] = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + size_t decoded_len = 0; + + if (!text || strlen(text) != 24 || text[22] != '=' || text[23] != '=') + return -1; + for (size_t i = 0; i < 22; ++i) { + const char *p = strchr(alphabet, text[i]); + if (!p) return -1; + /* With '==', the last sextet's four unused bits must be zero. */ + if (i == 21 && ((size_t)(p - alphabet) & 15u) != 0) + return -1; + } + if (mbedtls_base64_decode(key, TAI_IMAGE_KEY_LEN, &decoded_len, + (const unsigned char *)text, 24) != 0) + return -1; + return decoded_len == TAI_IMAGE_KEY_LEN ? 0 : -1; +} + +static tai_ctx_t *app_construct_with_image_key(iot_client_t *client, + void *mem, + const tai_config_t *base, + const char *ca_cert) +{ + const iot_atop_request_t request = { + .api = "thing.km.secret.get.device", + .version = "1.0", + .data = "{}" + }; + iot_atop_response_t response = {0}; + cJSON *result = NULL; + cJSON *secret = NULL; + uint8_t key[TAI_IMAGE_KEY_LEN] = {0}; + tai_config_t cfg = {0}; + tai_ctx_t *ctx = NULL; + + if (!client || !mem || !base) goto cleanup; + if (base->disable_tls || + ((!ca_cert || !ca_cert[0]) && !base->cert_bundle_attach)) goto cleanup; + if (iot_atop_call(client, &request, &response) != OPRT_OK) + goto cleanup; + if (!response.result) goto cleanup; + /* response.result already IS the result, not the ATOP Envelope. */ + result = cJSON_ParseWithOpts(response.result, NULL, 1); + if (!cJSON_IsObject(result)) goto cleanup; + secret = cJSON_GetObjectItemCaseSensitive(result, "secretValue"); + if (!cJSON_IsString(secret) || !secret->valuestring) goto cleanup; + if (app_decode_image_key(secret->valuestring, key) != 0) goto cleanup; + + cfg = *base; + cfg.ca_cert = ca_cert; + cfg.image_key = key; + ctx = tai_ctx_init(mem, &cfg); + cfg.image_key = NULL; + +cleanup: + mbedtls_platform_zeroize(key, sizeof(key)); + app_wipe_json_strings(result); + cJSON_Delete(result); + if (response.result) + mbedtls_platform_zeroize(response.result, strlen(response.result)); + mbedtls_platform_zeroize(response.error_code, sizeof(response.error_code)); + mbedtls_platform_zeroize(response.error_msg, sizeof(response.error_msg)); + iot_atop_response_free(client, &response); + return ctx; +} +``` + +The validator accepts only canonical standard Base64: 24 characters, exactly 22 alphabet characters followed by `==`, zero unused bits in the last sextet, and exactly 16 decoded bytes. It rejects whitespace, URL-safe variants, bad padding and wrong lengths before configuration. Transport/business failures, absent/null/non-object results, missing/non-string secrets, invalid Base64 and constructor failure all return NULL through the same cleanup path. + +The temporary binary key is zeroized after construction on success and on every failure. Before deleting the parsed tree, the local helper traverses every node through public child/next fields and wipes string values and object member names, including nested content in rejected shapes, not just secretValue. Returned plaintext JSON and response.error_code/error_msg are also wiped before response cleanup. This is practical cleanup of application-visible buffers, not a guarantee that parser/transport internals leave no other copies. Do not log the response, `secretValue` or decoded key. Ensure the SDK's secret-API response-body log suppression is present before using real keys; disabling only TAI logs does not suppress IoT ATOP diagnostics. + +On success the caller owns the context: eventually call `tai_disconnect(ctx)`, then `tai_ctx_deinit(ctx)`, then release `mem`. On NULL, no constructed context is returned and the caller still owns `mem`. This example does not persist the fixed key. An application that already retains a validated key can pass it directly to `tai_ctx_init()` without another request. Both paths select the encrypted-media profile. After successful construction, the owner calls `tai_connect(ctx)` and checks the result; encrypted images can be sent and received after TLS peer verification and Session confirmation succeed. The helper only checks that trust is configured; actual certificate verification happens at connect time. This is not a live-cloud verification claim. The earlier typical flow omits the field (NULL) and remains legacy behavior. + +--- + +### Generate an image and save it locally {#image-generation-demo} + +`examples/posix/ai/rtc-tcp-client/image_generation_demo.c` sends one prompt with `tai_send_text()` and saves the first complete inline PNG/JPEG. This is cloud text-to-image generation, not an image upload for recognition or local rendering. It requires an activated device bound to an authorized Agent whose cloud workflow generates **inline Image Packets**. Follow the product's [workflow configuration](../guides/create-workflow.md) and routing contract; sending a prompt does not configure a workflow, and `clm_intent=ai_image` is not assumed to be a universal generation intent. + +Supply `TUYA_DEVICE_ID`, `TUYA_SECRET_KEY`, and `TUYA_LOCAL_KEY` externally through a secure environment/secret-injection mechanism before running. Do not commit credentials or type literal secrets into shell commands/history. By default, `--ca-file` is required: provide a trusted CA bundle covering **both bootstrap HTTPS and TAI endpoints**. The demo reads its contents (nonempty, at most 256 KiB) into IoT `cacert` and TAI `ca_cert`, retaining them through client teardown; the SDK receives certificate contents, not the filename. `--allow-insecure-fallback` explicitly permits omitting the CA file, leaving TLS peer verification disabled with a MITM warning. A supplied CA is always verified, even with that flag; certificate failures never trigger an unverified retry. The demo does not download trust anchors over an unverified connection. + +:::warning Rebuild all SDK libraries with a WARN ceiling before using credentials +Existing SDK DEBUG logs can expose the Session-token response, and TAI Packet logs at INFO may expose tokens. Use `-DCMAKE_C_FLAGS="-DAGENTIC_KIT_LOG_LEVEL=2"` as below so **all C libraries**, not just the demo or TAI, compile with only ERROR/WARN logs. A bare CMake `-DAGENTIC_KIT_LOG_LEVEL=2` variable is not wired to compiler definitions. There is no runtime log-level switch; changing the ceiling requires rebuilding. +::: + +From the repository root, use the separate `build-examples` directory (do not reuse the root SDK build directory): + +```sh +cmake -S examples/posix -B build-examples \ + -DPython3_EXECUTABLE="$(command -v python3)" \ + -DCMAKE_C_FLAGS="-DAGENTIC_KIT_LOG_LEVEL=2" +cmake --build build-examples --target image_generation_demo -j 4 + +./build-examples/image_generation_demo \ + --prompt "Generate a watercolor picture of a red panda reading under a tree" \ + --ca-file /path/to/trusted-ca-bundle.pem \ + --output-dir ./generated-images \ + --timeout 120 \ + --region AY +``` + +Replace the CA path with your configuration. Each product binds one Agent, and all devices belonging to that product use that Agent; the demo does not offer Agent selection. The demo uses **PROD only**, defaults to **AY**, and accepts the exact region spellings `AY`, `AZ`, `UEAZ`, `EU`, `WEAZ`, `IN`, `SG`. `--timeout` defaults to **120 seconds**, measured after sending the prompt; it excludes bootstrap HTTPS and Connection setup, so it is not a total execution deadline. + +- **Bootstrap and key lifetime:** With MQTT auto-connect disabled, the demo obtains a Session token and [explicitly acquires the fixed Image key](#application-image-key) once. It validates canonical Base64 decoding to 16 bytes, wipes application-owned key-response copies and the temporary key after construction, and wipes the context's key at teardown. It neither persists the key nor reconnects/resubmits the prompt; replay could duplicate generation or charges. +- **Bounded reception:** Callback payloads/identifiers are borrowed and copied before return into one mutex-protected accumulator, capped at **16 MiB** for the entire image (SDK per-Packet limits still apply). Completion requires a nonempty ONE_SHOT or a completed START/MIDDLE/END stream, not merely EventEnd. Callbacks request shutdown with `tai_request_disconnect()`; only the main thread writes files, after `tai_disconnect()` joins the worker. +- **Local output:** `--output-dir` defaults to `./generated-images` relative to the process working directory. The demo validates it before cloud calls and creates only the final directory if missing; its parent must already exist. PNG/JPEG signature checks reject unknown bytes or a mismatch with a nonzero declared format; this is not decoder validation. An exclusive temporary file is written and closed, then published with a unique locally generated `.png`/`.jpg` name without overwriting existing files; collisions fail. Prompts and Event IDs are not filenames. +- **Result and unsupported delivery:** A generation run exits 0 only after successful publication and prints the saved path, byte count, format and dimensions (0 means unknown). Text/audio are discarded. Image URL delivery and downloading image links from NLG text are not implemented; URL-only or text-only responses fail rather than being saved as images. Timeout, interruption, incomplete images, bootstrap/TLS/protocol errors, size/allocation failures and file errors return nonzero. + +Live-cloud generation, the selected workflow's delivery mode, key-service acceptance and encrypted-media interoperability remain **unverified**; this usage example is not evidence of an end-to-end cloud run. + +--- + ## 8. Thread Safety {#8-线程安全} - The `tai_send_*` functions are thread-safe and can be called from any thread diff --git a/examples/posix/CMakeLists.txt b/examples/posix/CMakeLists.txt index 22f057c..1f0cc2f 100644 --- a/examples/posix/CMakeLists.txt +++ b/examples/posix/CMakeLists.txt @@ -139,9 +139,22 @@ else() endif() # -- ai / rtc-tcp-client ---------------------------------------------------- +# A keyed Connection reassembles each whole-image Packet before GCM decryption +# (see modules/rtc-tcp-client/CONTEXT.md), so the reassembly ceiling must cover +# one inline image plus its Packet metadata, not just a few 4096-byte transport +# fragments. The module default (32000) fails image reception with +# TAI_PROTO_ERR_FRAG: the cloud delivered an image as one ~400 KB Packet. Size +# the SDK library built for these demos to the demo's 16 MiB image limit and +# leave the module default small for consumers that never receive keyed images. +target_compile_definitions(tuya_ai_tcp PRIVATE + "AGENTIC_KIT_TAI_FRAG_BUF_SIZE=(16*1024*1024+4096)") + add_executable(text_chat_demo "${CMAKE_CURRENT_SOURCE_DIR}/ai/rtc-tcp-client/text_chat_demo.c") target_link_libraries(text_chat_demo PRIVATE tuya_ai_tcp tuya_iot_client agentic_kit_pal) +add_executable(image_generation_demo "${CMAKE_CURRENT_SOURCE_DIR}/ai/rtc-tcp-client/image_generation_demo.c") +target_link_libraries(image_generation_demo PRIVATE tuya_ai_tcp tuya_iot_client agentic_kit_pal) + add_executable(edu_camera_demo "${CMAKE_CURRENT_SOURCE_DIR}/ai/rtc-tcp-client/edu_camera_demo.c") target_link_libraries(edu_camera_demo PRIVATE tuya_ai_tcp tuya_iot_client agentic_kit_pal) diff --git a/examples/posix/ai/rtc-tcp-client/image_generation_demo.c b/examples/posix/ai/rtc-tcp-client/image_generation_demo.c new file mode 100644 index 0000000..c8c4ac9 --- /dev/null +++ b/examples/posix/ai/rtc-tcp-client/image_generation_demo.c @@ -0,0 +1,578 @@ +/* + * Configure: cmake -S examples/posix -B build-examples -DCMAKE_C_FLAGS="-DAGENTIC_KIT_LOG_LEVEL=2" + * Build: cmake --build build-examples --target image_generation_demo + * Rebuild all SDK C libraries with this WARN ceiling before using credentials: + * DEBUG response logs and INFO Packet logs may expose the Session token. + * Run: image_generation_demo --prompt "Generate a red panda" --ca-file trust.pem + * or, accepting unverified TLS (MITM risk): ... --allow-insecure-fallback + * Credentials: TUYA_DEVICE_ID, TUYA_SECRET_KEY, TUYA_LOCAL_KEY (activated device). + * Requires an Agent workflow configured for inline text-to-image generation. + * PROD only; first complete JPEG/PNG, at most 16 MiB; no download or replay. + */ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "iot_client.h" +#include "iot_atop.h" +#include "tuya_ai.h" +#include "cJSON.h" +#include "mbedtls/platform_util.h" +#include "demo_json.h" +#include "demo_mcp.h" + +#define IMAGE_LIMIT (16u * 1024u * 1024u) +#define CA_LIMIT (256u * 1024u) + +typedef struct { + const char *prompt, *ca_file, *output_dir; + unsigned timeout; + iot_region_t region; + int allow_insecure; /* explicit opt-in: use TLS without a CA */ +} options_t; + +typedef struct { + pthread_mutex_t mutex; + int outcome; /* 0 waiting, 1 complete, -1 failed; terminal states are immutable */ + const char *error; + unsigned char *bytes; + size_t len, capacity; + int active; + uint8_t format; + uint16_t width, height, data_id; + char event_id[256]; +} image_state_t; + +static volatile sig_atomic_t cancelled; + +static void on_signal(int sig) +{ + (void)sig; + cancelled = 1; +} + +static void usage(void) +{ + puts("Usage: image_generation_demo --prompt TEXT [--ca-file FILE | --allow-insecure-fallback] [options]\n" + " --ca-file FILE Trusted CA bundle for bootstrap HTTPS and TAI\n" + " --allow-insecure-fallback\n" + " Permit TLS without a CA; peer unverified, MITM risk\n" + " --output-dir DIR Default ./generated-images; parent must exist\n" + " --timeout SECONDS Response wait after sending (default 120)\n" + " --region REGION AY (default), AZ, UEAZ, EU, WEAZ, IN, SG; PROD\n" + " --help Show this help\n" + "Required environment: TUYA_DEVICE_ID, TUYA_SECRET_KEY, TUYA_LOCAL_KEY.\n" + "The CA must trust bootstrap HTTPS and TAI. Without --ca-file, TLS is\n" + "unverified and requires the explicit --allow-insecure-fallback opt-in.\n" + "A supplied --ca-file is always verified, even with the fallback flag.\n" + "Before using credentials, rebuild all SDK C libraries at WARN ceiling:\n" + " -DCMAKE_C_FLAGS=\"-DAGENTIC_KIT_LOG_LEVEL=2\"\n" + "DEBUG response logs and INFO Packet logs may expose the Session token.\n" + "Saves the first complete inline JPEG/PNG, max 16 MiB. URL-only output\n" + "is unsupported. No reconnect/resubmit. Exit 0 only after publication.\n" + "Requires an activated device and a configured text-to-image workflow;\n" + "sending a prompt alone does not configure that workflow.\n" + "All devices use their product's single bound Agent; no Agent override."); +} + +static int parse_options(int argc, char **argv, options_t *o) +{ + *o = (options_t){.output_dir = "./generated-images", .timeout = 120, .region = AY}; + for (int i = 1; i < argc; i++) { + const char *arg = argv[i]; + if (!strcmp(arg, "--help")) return 1; + if (!strcmp(arg, "--allow-insecure-fallback")) { o->allow_insecure = 1; continue; } + if (++i == argc || !argv[i][0]) return -1; + const char *value = argv[i]; + if (!strcmp(arg, "--prompt")) o->prompt = value; + else if (!strcmp(arg, "--ca-file")) o->ca_file = value; + else if (!strcmp(arg, "--output-dir")) o->output_dir = value; + else if (!strcmp(arg, "--timeout")) { + char *end; + errno = 0; + unsigned long n = strtoul(value, &end, 10); + if (*value < '0' || *value > '9' || *end || errno || !n || n > UINT_MAX) + return -1; + o->timeout = (unsigned)n; + } else if (!strcmp(arg, "--region")) { + const char *names[] = {"AY", "AZ", "UEAZ", "EU", "WEAZ", "IN", "SG"}; + size_t n; + for (n = 0; n < sizeof(names) / sizeof(names[0]); n++) + if (!strcmp(value, names[n])) break; + if (n == sizeof(names) / sizeof(names[0])) return -1; + o->region = (iot_region_t)n; + } else return -1; + } + return o->prompt && (o->ca_file || o->allow_insecure) ? 0 : -1; +} + +static int credentials(iot_client_config_t *cfg) +{ + const char *names[] = {"TUYA_DEVICE_ID", "TUYA_SECRET_KEY", "TUYA_LOCAL_KEY"}; + char *fields[] = {cfg->devid, cfg->secret_key, cfg->local_key}; + size_t sizes[] = {sizeof(cfg->devid), sizeof(cfg->secret_key), sizeof(cfg->local_key)}; + for (size_t i = 0; i < 3; i++) { + const char *value = getenv(names[i]); + if (!value || !*value) { + fprintf(stderr, "Required environment variable missing: %s\n", names[i]); + return -1; + } + if (demo_copy_field(fields[i], sizes[i], value, names[i])) return -1; + } + return 0; +} + +static int prepare_directory(const char *path) +{ + struct stat st; + if (mkdir(path, 0700) && errno != EEXIST) return -1; + if (stat(path, &st)) return -1; + if (!S_ISDIR(st.st_mode)) { errno = ENOTDIR; return -1; } + return access(path, W_OK | X_OK); +} + +static char *read_ca(const char *path) +{ + FILE *f = fopen(path, "rb"); + if (!f) return NULL; + char *ca = malloc(CA_LIMIT + 1); + size_t n = ca ? fread(ca, 1, CA_LIMIT + 1, f) : 0; + int bad = !ca || !n || n > CA_LIMIT || ferror(f) || (ca && memchr(ca, 0, n)); + if (fclose(f)) bad = 1; + if (bad) { free(ca); errno = EINVAL; return NULL; } + ca[n] = 0; + return ca; +} + +/* Reject decoded NULs in every value and field name before cJSON allocates + * copies that strlen-based validation and cleanup could truncate. */ +static int validate_json_strings(const char *json) +{ + if (!json) return -1; + while (*json) { + if (*json != '"') { json++; continue; } + const char *body = json + 1; + const char *end = json_str_end(body); + char probe[1]; + if (!end || json_unescape_ex(body, (size_t)(end - body), probe, sizeof(probe), 1) < 0) + return -1; + json = end + 1; + } + return 0; /* cJSON enforces the surrounding syntax. */ +} + +/* cJSON owns decoded copies, including strings unrelated to the expected field. */ +static void wipe_json(cJSON *node) +{ + for (; node; node = node->next) { + wipe_json(node->child); + if (node->valuestring) mbedtls_platform_zeroize(node->valuestring, strlen(node->valuestring)); + if (node->string) mbedtls_platform_zeroize(node->string, strlen(node->string)); + } +} + +static int copy_json_string(cJSON *node, char *out, size_t cap) +{ + if (!cJSON_IsString(node) || !node->valuestring || !node->valuestring[0] || + strlen(node->valuestring) >= cap) return -1; + strcpy(out, node->valuestring); + return 0; +} + +/* Unlike parse_token(), reject missing/invalid ports instead of defaulting to 443. */ +static int parse_connection(const char *token, tai_conn_params_t *p) +{ + size_t decoded_len = 0; + char *decoded = b64_decode(token, &decoded_len); + const char *json = decoded ? decoded : token; + cJSON *root = NULL; + int rc = -1; + memset(p, 0, sizeof(*p)); + if (decoded && memchr(decoded, 0, decoded_len)) goto done; + if (validate_json_strings(json)) goto done; + root = cJSON_ParseWithOpts(json, NULL, 1); + cJSON *conn = cJSON_GetObjectItemCaseSensitive(root, "connect_conf"); + cJSON *sess = cJSON_GetObjectItemCaseSensitive(root, "session_conf"); + cJSON *hosts = cJSON_GetObjectItemCaseSensitive(conn, "hosts"); + cJSON *domains = cJSON_GetObjectItemCaseSensitive(conn, "domains"); + cJSON *port = cJSON_GetObjectItemCaseSensitive(conn, "ecc_tls_port"); + if (!port) port = cJSON_GetObjectItemCaseSensitive(conn, "tcpport"); + if (!cJSON_IsObject(root) || !cJSON_IsObject(conn) || !cJSON_IsObject(sess) || + !cJSON_IsArray(hosts) || + copy_json_string(cJSON_GetArrayItem(hosts, 0), p->host, sizeof(p->host)) || + copy_json_string(cJSON_GetObjectItemCaseSensitive(conn, "derived_client_id"), + p->derived_client_id, sizeof(p->derived_client_id)) || + copy_json_string(cJSON_GetObjectItemCaseSensitive(sess, "agentToken"), + p->agent_token, sizeof(p->agent_token)) || + !cJSON_IsNumber(port) || port->valuedouble < 1 || port->valuedouble > 65535 || + port->valuedouble != port->valueint) goto done; + p->port = (uint16_t)port->valueint; + if (domains) { + if (!cJSON_IsArray(domains) || + copy_json_string(cJSON_GetArrayItem(domains, 0), p->tls_sni, sizeof(p->tls_sni))) goto done; + } else strcpy(p->tls_sni, p->host); + cJSON *biz = cJSON_GetObjectItemCaseSensitive(sess, "bizConfig"); + cJSON *code = cJSON_GetObjectItemCaseSensitive(biz, "bizCode"); + cJSON *tag = cJSON_GetObjectItemCaseSensitive(biz, "bizTag"); + if (cJSON_IsNumber(code) && code->valuedouble >= 0 && code->valuedouble <= INT_MAX) + p->biz_code = code->valueint; + if (cJSON_IsNumber(tag) && tag->valuedouble >= 0 && tag->valuedouble < (double)LONG_MAX) + p->biz_tag = (long)tag->valuedouble; + rc = 0; + done: + wipe_json(root); + cJSON_Delete(root); + if (decoded) { mbedtls_platform_zeroize(decoded, decoded_len); free(decoded); } + if (rc) mbedtls_platform_zeroize(p, sizeof(*p)); + return rc; +} + +static int decode_image_key(const char *text, uint8_t key[TAI_IMAGE_KEY_LEN]) +{ + static const char alphabet[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + size_t len = 0; + if (!text || strlen(text) != 24 || text[22] != '=' || text[23] != '=') return -1; + for (size_t i = 0; i < 22; i++) if (!strchr(alphabet, text[i])) return -1; + if ((strchr(alphabet, text[21]) - alphabet) & 15) return -1; + return mbedtls_base64_decode(key, TAI_IMAGE_KEY_LEN, &len, + (const unsigned char *)text, 24) == 0 && + len == TAI_IMAGE_KEY_LEN ? 0 : -1; +} + +static int fetch_image_key(iot_client_t *client, uint8_t key[TAI_IMAGE_KEY_LEN]) +{ + iot_atop_request_t req = {.api = "thing.km.secret.get.device", .version = "1.0", .data = "{}"}; + iot_atop_response_t resp = {0}; + cJSON *root = NULL; + int rc = iot_atop_call(client, &req, &resp); + if (rc == OPRT_OK) { + if (!validate_json_strings(resp.result)) + root = cJSON_ParseWithOpts(resp.result, NULL, 1); + cJSON *secret = cJSON_GetObjectItemCaseSensitive(root, "secretValue"); + char encoded[25] = {0}; + if (!cJSON_IsObject(root) || !cJSON_IsString(secret) || + json_object_get_string(resp.result, "secretValue", encoded, sizeof(encoded)) || + decode_image_key(encoded, key)) rc = OPRT_INVALID_RESULT; + mbedtls_platform_zeroize(encoded, sizeof(encoded)); + } + wipe_json(root); + cJSON_Delete(root); + if (resp.result) mbedtls_platform_zeroize(resp.result, strlen(resp.result)); + mbedtls_platform_zeroize(resp.error_code, sizeof(resp.error_code)); + mbedtls_platform_zeroize(resp.error_msg, sizeof(resp.error_msg)); + iot_atop_response_free(client, &resp); + if (rc) { + mbedtls_platform_zeroize(key, TAI_IMAGE_KEY_LEN); + fprintf(stderr, "Image-key acquisition failed: %d\n", rc); + } + return rc; +} + +static void discard_image(image_state_t *s) +{ + free(s->bytes); + s->bytes = NULL; + s->len = s->capacity = 0; + s->active = 0; +} + +static void fail_image(image_state_t *s, const char *error) +{ + if (s->outcome) return; + discard_image(s); + s->error = error; + s->outcome = -1; +} + +static void on_image(tai_ctx_t *ctx, const tai_image_msg_t *msg, void *user) +{ + image_state_t *s = user; + const char *id = msg->event_id ? msg->event_id : ""; + pthread_mutex_lock(&s->mutex); + if (s->outcome) goto done; + if (msg->payload_type != TAI_IMG_PAYLOAD_RAW) { + fail_image(s, msg->payload_type == TAI_IMG_PAYLOAD_URL ? + "URL image delivery is unsupported (no download)" : "Expected plaintext RAW image bytes"); + goto done; + } + if (msg->stream_flag == TAI_STREAM_START || msg->stream_flag == TAI_STREAM_ONE_SHOT) { + discard_image(s); + if (strlen(id) >= sizeof(s->event_id)) { + fail_image(s, "Image Event ID too long"); goto done; + } + strcpy(s->event_id, id); + s->data_id = msg->data_id; + s->format = msg->format; + s->width = msg->width; + s->height = msg->height; + s->active = 1; + } else if ((msg->stream_flag != TAI_STREAM_MIDDLE && msg->stream_flag != TAI_STREAM_END) || + !s->active || strcmp(id, s->event_id) || msg->data_id != s->data_id || + (msg->format && msg->format != s->format) || + (msg->width && msg->width != s->width) || + (msg->height && msg->height != s->height)) { + fail_image(s, "Orphan or conflicting Image Stream continuation"); goto done; + } + if (msg->len > IMAGE_LIMIT - s->len) { + fail_image(s, "Image exceeds 16 MiB limit"); goto done; + } + if (msg->len && !msg->data) { fail_image(s, "Missing image bytes"); goto done; } + size_t needed = s->len + msg->len; + if (needed > s->capacity) { + size_t cap = s->capacity ? s->capacity : 4096; + while (cap < needed) cap *= 2; + unsigned char *bytes = realloc(s->bytes, cap); + if (!bytes) { fail_image(s, "Image allocation failed"); goto done; } + s->bytes = bytes; + s->capacity = cap; + } + if (msg->len) memcpy(s->bytes + s->len, msg->data, msg->len); + s->len = needed; + if (msg->stream_flag == TAI_STREAM_END || msg->stream_flag == TAI_STREAM_ONE_SHOT) { + if (!s->len) fail_image(s, "Empty image"); + else { s->outcome = 1; s->active = 0; } + } + done:; + int terminal = s->outcome; + pthread_mutex_unlock(&s->mutex); + if (terminal) tai_request_disconnect(ctx); +} + +static void on_event(tai_ctx_t *ctx, const tai_event_msg_t *msg, void *user) +{ + image_state_t *s = user; + pthread_mutex_lock(&s->mutex); + if (!s->outcome) { + /* The cloud ends the request Event as soon as it accepts it -- long + * before a slow (10-30 s) text-to-image result arrives in the response + * Event. Only an EventEnd that cuts off an image already streaming is a + * failure; otherwise keep waiting for the image or the timeout. */ + if (msg->event_type == TAI_EVT_END) { + if (s->active) fail_image(s, "Event ended without a complete inline image"); + } + else if (msg->event_type == TAI_EVT_CHAT_BREAK) + fail_image(s, "Image generation interrupted by ChatBreak"); + else if (msg->event_type == TAI_EVT_SERVER_TIMEOVER) + fail_image(s, "Server timeover before image completion"); + else if (msg->event_type == TAI_EVT_START && s->active && + strcmp(s->event_id, msg->event_id ? msg->event_id : "")) + discard_image(s); + } + int terminal = s->outcome; + pthread_mutex_unlock(&s->mutex); + if (terminal) tai_request_disconnect(ctx); + else if (msg->event_type == TAI_EVT_MCP_CMD) demo_mcp_reply_no_tools(ctx, msg); +} + +static void on_disconnect(tai_ctx_t *ctx, const tai_disconnect_msg_t *msg, void *user) +{ + image_state_t *s = user; + /* Static because fail_image() stores the pointer; the mutex serializes it. + * reason==PROTOCOL means our decoder rejected the server's bytes, so the + * TAI_PROTO_ERR_* detail is the whole diagnosis. */ + static char text[96]; + pthread_mutex_lock(&s->mutex); + if (msg->reason == TAI_DISCONNECT_PROTOCOL) { + snprintf(text, sizeof(text), + "Protocol/authentication failure before image completion (TAI_PROTO_ERR_%u)", + (unsigned)msg->detail); + fail_image(s, text); + } else { + fail_image(s, "Disconnected without a complete inline image"); + } + pthread_mutex_unlock(&s->mutex); + tai_request_disconnect(ctx); +} + +/* Text and audio are intentionally discarded; neither can complete an image. */ +static void on_text(tai_ctx_t *ctx, const tai_text_msg_t *msg, void *user) +{ + (void)ctx; (void)msg; (void)user; +} + +static int64_t monotonic_ms(void) +{ + struct timespec ts; + if (clock_gettime(CLOCK_MONOTONIC, &ts)) return -1; + return (int64_t)ts.tv_sec * 1000 + ts.tv_nsec / 1000000; +} + +static int wait_image(image_state_t *s, unsigned timeout) +{ + int64_t now = monotonic_ms(); + int64_t deadline = now + (int64_t)timeout * 1000; + for (;;) { + pthread_mutex_lock(&s->mutex); + if (cancelled) fail_image(s, "Interrupted by signal"); + else if (now < 0) fail_image(s, "Monotonic clock failed"); + else if (now >= deadline) fail_image(s, "Timed out waiting for a complete inline image"); + int outcome = s->outcome; + pthread_mutex_unlock(&s->mutex); + if (outcome) return outcome; + struct timespec pause = {.tv_nsec = 20000000}; + nanosleep(&pause, NULL); + now = monotonic_ms(); + } +} + +static int save_image(const image_state_t *s, const char *directory, char *path, size_t path_cap) +{ + static const unsigned char png[] = {137, 80, 78, 71, 13, 10, 26, 10}; + int format = 0; + if (s->outcome != 1 || !s->bytes) { errno = EINVAL; return -1; } + if (s->len >= sizeof(png) && !memcmp(s->bytes, png, sizeof(png))) format = TAI_IMG_PNG; + else if (s->len >= 3 && s->bytes[0] == 255 && s->bytes[1] == 216 && s->bytes[2] == 255) + format = TAI_IMG_JPEG; + if (!format || (s->format && s->format != format)) { + fprintf(stderr, "Unsupported or mismatched JPEG/PNG signature\n"); + errno = EINVAL; return -1; + } + char temporary[PATH_MAX]; + int n = snprintf(temporary, sizeof(temporary), "%s/.image-XXXXXX", directory); + if (n < 0 || (size_t)n >= sizeof(temporary)) { errno = ENAMETOOLONG; return -1; } + int fd = mkstemp(temporary); + if (fd < 0) return -1; + int rc = -1, saved_errno; + n = snprintf(path, path_cap, "%s/image-%s.%s", directory, + strrchr(temporary, '-') + 1, format == TAI_IMG_PNG ? "png" : "jpg"); + if (n < 0 || (size_t)n >= path_cap) { errno = ENAMETOOLONG; goto done; } + size_t offset = 0; + while (offset < s->len) { + ssize_t written = write(fd, s->bytes + offset, s->len - offset); + if (written < 0 && errno == EINTR) continue; + if (written <= 0) { if (!written) errno = EIO; goto done; } + offset += (size_t)written; + } + /* A failed close must not be retried: POSIX may already have released fd. */ + int close_rc = close(fd); + fd = -1; + if (close_rc) goto done; + if (link(temporary, path)) goto done; + if (unlink(temporary)) { + saved_errno = errno; + unlink(path); + errno = saved_errno; + goto done; + } + rc = 0; + done: + saved_errno = errno; + if (fd >= 0) close(fd); + if (rc) { unlink(temporary); if (path_cap) path[0] = 0; } + errno = saved_errno; + return rc; +} + +int main(int argc, char **argv) +{ + options_t opt; + int parsed = parse_options(argc, argv, &opt); + if (parsed) { usage(); return parsed > 0 ? 0 : 2; } + int rc = 1, status, disconnected = 0; + iot_client_config_t iot_cfg = {0}; + iot_client_t *iot = NULL; + tai_conn_params_t params = {0}; + char token[8192] = {0}, path[PATH_MAX]; + uint8_t key[TAI_IMAGE_KEY_LEN] = {0}; + char *ca = NULL; + void *mem = NULL; + tai_ctx_t *ctx = NULL; + image_state_t state = {.mutex = PTHREAD_MUTEX_INITIALIZER}; + struct sigaction action = {0}, old_int, old_term; + int signals = 0; + if (credentials(&iot_cfg)) goto done; + if (prepare_directory(opt.output_dir)) { perror("Output directory"); goto done; } + if (opt.ca_file) { + ca = read_ca(opt.ca_file); + if (!ca) { perror("CA file (nonempty, at most 256 KiB)"); goto done; } + } else { + fprintf(stderr, "WARNING: no CA file; TLS peer verification is disabled (MITM risk)\n"); + } + cancelled = 0; + action.sa_handler = on_signal; + sigemptyset(&action.sa_mask); + if (sigaction(SIGINT, &action, &old_int)) goto done; + if (sigaction(SIGTERM, &action, &old_term)) { sigaction(SIGINT, &old_int, NULL); goto done; } + signals = 1; + status = iot_init_default(); + if (status) { fprintf(stderr, "SDK initialization failed: %d\n", status); goto done; } + + iot_cfg.region = opt.region; + iot_cfg.env = PROD; + iot_cfg.cacert = ca; + iot_cfg.mqtt_disable_auto_connect = true; + iot = iot_client_init(&iot_cfg); + if (!iot) { fprintf(stderr, "IoT initialization failed\n"); goto done; } + if (cancelled) goto done; + /* This SDK implementation propagates TLS errors without refreshing trust, + * despite the historical refresh claim in iot_client.h. Never retry here. */ + status = iot_client_get_session_token_ex(iot, NULL, token, sizeof(token), NULL); + if (status) { fprintf(stderr, "Session token request failed: %d\n", status); goto done; } + if (parse_connection(token, ¶ms)) { fprintf(stderr, "Invalid Session token fields\n"); goto done; } + mbedtls_platform_zeroize(token, sizeof(token)); + if (cancelled || fetch_image_key(iot, key)) goto done; + tai_config_t cfg = { + .host = params.host, .port = params.port, .tls_sni = params.tls_sni, + .device_id = params.derived_client_id, .local_key = iot_cfg.local_key, + .client_type = TAI_CLIENT_DEVICE, .protocol_version = TAI_VER_21, + .agent_token = params.agent_token, .biz_code = (uint32_t)params.biz_code, + .biz_tag = (uint64_t)params.biz_tag, .pal = iot->pal, + .image_key = key, .ca_cert = ca, + .on_image = on_image, .on_event = on_event, + .on_disconnect = on_disconnect, .on_text = on_text, .user_data = &state + }; + mem = malloc(tai_ctx_size()); + if (mem) ctx = tai_ctx_init(mem, &cfg); + mbedtls_platform_zeroize(key, sizeof(key)); + iot_client_deinit(iot); + iot = NULL; + if (!ctx) { fprintf(stderr, "TAI construction failed\n"); goto done; } + if (cancelled) goto done; + status = tai_connect(ctx); + if (status) { fprintf(stderr, "TAI Connection failed: %d\n", status); goto done; } + if (cancelled) goto done; + status = tai_send_text(ctx, opt.prompt, strlen(opt.prompt)); + if (status) { + pthread_mutex_lock(&state.mutex); + fail_image(&state, "Prompt send failed (not retried)"); + pthread_mutex_unlock(&state.mutex); + } else wait_image(&state, opt.timeout); + tai_disconnect(ctx); + disconnected = 1; + if (state.outcome != 1) { + fprintf(stderr, "%s\n", state.error ? state.error : "No complete inline image"); + goto done; + } + if (save_image(&state, opt.output_dir, path, sizeof(path))) { perror("Save image"); goto done; } + printf("Saved %s (%zu bytes, %s, dimensions %u x %u; 0 = unknown)\n", path, state.len, + !strcmp(path + strlen(path) - 4, ".png") ? "PNG" : "JPEG", state.width, state.height); + rc = 0; + done: + if (ctx) { + if (!disconnected) tai_disconnect(ctx); + tai_ctx_deinit(ctx); + } + if (iot) iot_client_deinit(iot); + free(mem); + discard_image(&state); + pthread_mutex_destroy(&state.mutex); + free(ca); + mbedtls_platform_zeroize(key, sizeof(key)); + mbedtls_platform_zeroize(token, sizeof(token)); + mbedtls_platform_zeroize(¶ms, sizeof(params)); + mbedtls_platform_zeroize(&iot_cfg, sizeof(iot_cfg)); + if (signals) { + sigaction(SIGINT, &old_int, NULL); + sigaction(SIGTERM, &old_term, NULL); + } + cancelled = 0; + return rc; +} diff --git a/modules/iot-client/include/iot_atop.h b/modules/iot-client/include/iot_atop.h index 52b66ec..8b635d1 100644 --- a/modules/iot-client/include/iot_atop.h +++ b/modules/iot-client/include/iot_atop.h @@ -82,7 +82,8 @@ extern "C" { typedef struct { const char *api; /**< e.g. "tuya.device.upgrade.get" (required) */ const char *version; /**< e.g. "4.4" (required) */ - const char *data; /**< request body, a JSON object string; NULL/"" = "{}" */ + const char *data; /**< request body, a JSON object string; NULL/"" = "{}". + The SDK injects a "t" timestamp matching the URL t. */ } iot_atop_request_t; /** diff --git a/modules/iot-client/src/atop_base.c b/modules/iot-client/src/atop_base.c index 5a6e90f..271fb01 100644 --- a/modules/iot-client/src/atop_base.c +++ b/modules/iot-client/src/atop_base.c @@ -293,7 +293,8 @@ return OPRT_OK; } - static int atop_response_data_decode(const pal_t *pal, const char *key, const uint8_t *input, size_t ilen, uint8_t *output, size_t *olen) + static int atop_response_data_decode(const pal_t *pal, const char *key, const uint8_t *input, size_t ilen, uint8_t *output, size_t *olen, + bool sensitive_response) { int rt = OPRT_OK; (void)ilen; // Parameter kept for API consistency @@ -301,7 +302,9 @@ char *value; size_t value_length; - IOT_LOGD("atop_response_data_decode: %s", (char *)input); + if (!sensitive_response) { + IOT_LOGD("atop_response_data_decode: %s", (char *)input); + } cJSON *root = cJSON_Parse((char *)input); if (NULL == root) { @@ -323,7 +326,9 @@ value = item->valuestring; value_length = strlen(value); - IOT_LOGD("base64 encode result:\r\n%.*s", (int)value_length, value); + if (!sensitive_response) { + IOT_LOGD("base64 encode result:\r\n%.*s", (int)value_length, value); + } // base64 decode buffer size_t b64buffer_len = value_length * 3 / 4; @@ -357,12 +362,15 @@ IOT_LOGE("atop_data_decrpyt error: %d", rt); return rt; } - IOT_LOGD("result:\r\n%.*s", (int)*olen, output); + if (!sensitive_response) { + IOT_LOGD("result:\r\n%.*s", (int)*olen, output); + } return rt; } -static int atop_response_result_parse_cjson(const uint8_t *input, size_t ilen, atop_base_response_t *response) +static int atop_response_result_parse_cjson(const uint8_t *input, size_t ilen, atop_base_response_t *response, + bool sensitive_response) { int rt = OPRT_OK; (void)ilen; @@ -416,19 +424,25 @@ static int atop_response_result_parse_cjson(const uint8_t *input, size_t ilen, a } if (!cJSON_IsString(error_code_item) || error_code_item->valuestring == NULL) { - /* Keep the server's explanation in the log even on this malformed - * path -- it is often the only clue (e.g. a signature-time-skew - * message from a gateway that omits errorCode). */ - IOT_LOGE("atop rejected without errorCode (errorMsg=%s)", - response->error_msg); + /* Sensitive Envelopes may echo secrets in the server's explanation. */ + if (sensitive_response) { + IOT_LOGE("atop rejected without errorCode (sensitive response)"); + } else { + IOT_LOGE("atop rejected without errorCode (errorMsg=%s)", + response->error_msg); + } cJSON_Delete(root); return OPRT_COMMUNICATION_ERROR; } snprintf(response->error_code, sizeof(response->error_code), "%s", error_code_item->valuestring); - IOT_LOGE("atop rejected: errorCode=%s errorMsg=%s", - response->error_code, response->error_msg); + if (sensitive_response) { + IOT_LOGE("atop rejected (sensitive response)"); + } else { + IOT_LOGE("atop rejected: errorCode=%s errorMsg=%s", + response->error_code, response->error_msg); + } /* Every rejection is the same verdict: the cloud answered and said no. * No special-casing of individual errorCodes here (GATEWAY_NOT_EXISTS @@ -616,15 +630,16 @@ static int atop_response_result_parse_cjson(const uint8_t *input, size_t ilen, a } memset(result_buffer, 0, http_response.body_length + 1); - /* Decoded response data */ + /* Suppress body diagnostics before decoding, including plaintext Envelopes. */ + const bool sensitive_response = strcmp(request->api, "thing.km.secret.get.device") == 0; rt = atop_response_data_decode(pal, request->key, http_response.body, http_response.body_length, result_buffer, - &result_buffer_length); + &result_buffer_length, sensitive_response); if (OPRT_OK == rt) { - rt = atop_response_result_parse_cjson(result_buffer, result_buffer_length, response); + rt = atop_response_result_parse_cjson(result_buffer, result_buffer_length, response, sensitive_response); } else { IOT_LOGD("atop_response_decode error:%d, try parse the plaintext data.", rt); - rt = atop_response_result_parse_cjson(http_response.body, http_response.body_length, response); + rt = atop_response_result_parse_cjson(http_response.body, http_response.body_length, response, sensitive_response); } http_client_free(pal, &http_response); diff --git a/modules/iot-client/src/iot_atop.c b/modules/iot-client/src/iot_atop.c index cde8d72..8b468ec 100644 --- a/modules/iot-client/src/iot_atop.c +++ b/modules/iot-client/src/iot_atop.c @@ -32,22 +32,32 @@ _Static_assert(IOT_ATOP_ERROR_CODE_LEN >= ATOP_ERROR_CODE_LEN, _Static_assert(IOT_ATOP_ERROR_MSG_LEN >= ATOP_ERROR_MSG_LEN, "public error_msg buffer must not truncate the envelope's"); -/* A generic call forwards the caller's body untouched, so the only thing worth - * checking locally is that it is a JSON object at all. Catching a typo here - * saves an HTTPS round trip, and the cloud's rejection reason for malformed - * input is far less specific than "your JSON does not parse". */ -static int atop_body_is_json_object(const char *data) +/* A generic call forwards the caller's body, but every ATOP request body must + * carry the request timestamp: each typed wrapper in atop.c ends its body with + * "t", and the cloud binds the URL `t` to the body `t` -- a body without one is + * rejected on the sign path. So the caller's object is parsed, its `t` forced to + * the SDK's own timestamp (the same one that signs the URL), and re-serialized. + * A malformed non-object body is still caught here, before spending an HTTPS + * round trip, and the cloud's rejection reason for it is far less specific than + * "your JSON does not parse". */ +static int atop_body_prepare(const char *data, uint32_t timestamp, char **out) { + *out = NULL; /* require_null_terminated: plain cJSON_Parse() stops at the first complete * value and would wave through "{}garbage" -- exactly the snprintf-slip * class this check exists to catch locally. */ cJSON *root = cJSON_ParseWithOpts(data, NULL, 1); - if (root == NULL) { + if (root == NULL || !cJSON_IsObject(root)) { + cJSON_Delete(root); return OPRT_INVALID_PARAMETER; } - bool is_object = cJSON_IsObject(root); + /* Overwrite any caller-supplied t: the URL signs the SDK's timestamp, so a + * stale caller value would sign one t and send another. */ + cJSON_DeleteItemFromObject(root, "t"); + cJSON_AddNumberToObject(root, "t", (double)timestamp); + *out = cJSON_PrintUnformatted(root); cJSON_Delete(root); - return is_object ? OPRT_OK : OPRT_INVALID_PARAMETER; + return (*out != NULL) ? OPRT_OK : OPRT_MALLOC_FAILED; } int iot_atop_call(iot_client_t *client, @@ -79,12 +89,17 @@ int iot_atop_call(iot_client_t *client, return OPRT_UNINITIALIZED; } - const char *body = (request->data != NULL && request->data[0] != '\0') + const char *data = (request->data != NULL && request->data[0] != '\0') ? request->data : "{}"; - int rt = atop_body_is_json_object(body); + uint32_t timestamp = (uint32_t)time(NULL); + + char *body = NULL; + int rt = atop_body_prepare(data, timestamp, &body); if (rt != OPRT_OK) { - IOT_LOGE("iot_atop_call: data is not a JSON object"); + IOT_LOGE("iot_atop_call: %s", + rt == OPRT_INVALID_PARAMETER ? "data is not a JSON object" + : "failed to prepare request body"); return rt; } @@ -98,8 +113,8 @@ int iot_atop_call(iot_client_t *client, .api = request->api, .version = request->version, .devid = client->devid, - .timestamp = (uint32_t)time(NULL), - .data = (void *)body, + .timestamp = timestamp, + .data = body, .datalen = strlen(body), .host = host[0] ? host : NULL, .port = port, @@ -109,6 +124,9 @@ int iot_atop_call(iot_client_t *client, atop_base_response_t atop_response = {0}; rt = atop_base_request(client->pal, &atop_request, &atop_response); + /* The body is encrypted into atop_base's own buffer inside the call, so it + * is ours to release as soon as the request returns. */ + client->pal->free(body); /* Carry the cloud's verdict back on every path. For a generic call this is * the whole point: the caller knows what its interface can reject on, and diff --git a/modules/iot-client/test/iot_atop_call_test.c b/modules/iot-client/test/iot_atop_call_test.c index 887c77b..f12b7a0 100644 --- a/modules/iot-client/test/iot_atop_call_test.c +++ b/modules/iot-client/test/iot_atop_call_test.c @@ -475,6 +475,39 @@ static int test_unknown_api_reaches_the_cloud(void) return 0; } +/* The cloud binds the URL t to the body t: a generic caller that passes "{}", + * or its own stale t, must still go out with the SDK's timestamp in the body + * matching the URL. The mock answers success only when the two agree, so + * OPRT_OK here is the assertion. */ +static int test_call_binds_body_timestamp_to_url(void) +{ + const struct { + const char *data; + const char *what; + } cases[] = { + {NULL, "default {}"}, + {"{}", "empty object"}, + {"{\"t\":1}", "stale caller t"}, + {"{\"k\":\"v\"}", "caller field preserved"}, + }; + + for (size_t i = 0; i < sizeof(cases) / sizeof(cases[0]); i++) { + iot_atop_request_t req = { .api = "tuya.test.body.t.matches.url.t", + .version = "1.0", + .data = cases[i].data }; + iot_atop_response_t resp = {0}; + int rt = iot_atop_call(&g_client, &req, &resp); + if (rt != OPRT_OK || resp.result == NULL || strcmp(resp.result, "true") != 0) { + printf(" %s: returned %d, result=%s\n", cases[i].what, rt, + resp.result ? resp.result : "(null)"); + iot_atop_response_free(&g_client, &resp); + return -1; + } + iot_atop_response_free(&g_client, &resp); + } + return 0; +} + /* A response larger than AGENTIC_KIT_RESPONSE_BUFFER_SIZE must fail loudly. * * This is the failure from CHANGELOG: a product whose schema came back at @@ -494,6 +527,177 @@ static int test_unknown_api_reaches_the_cloud(void) * discipline included -- and keeps stderr readable. */ static char http_log_capture[8192]; +#define TEST_IMAGE_SECRET "AAECAwQFBgcICQoLDA0ODw==" +#define TEST_IMAGE_SECRET_RESULT "{\"secretValue\":\"" TEST_IMAGE_SECRET "\"}" + +static int check_secret_call(const char *devid, const char *expected, int plaintext) +{ + iot_client_t client = g_client; + snprintf(client.devid, sizeof(client.devid), "%s", devid); + iot_atop_request_t req = { .api = "thing.km.secret.get.device", + .version = "1.0", .data = "{}" }; + iot_atop_response_t resp = {0}; + + test_log_capture_begin(http_log_capture, sizeof http_log_capture); + int rt = iot_atop_call(&client, &req, &resp); + test_log_capture_end(); + + int result = 0; + if (rt != OPRT_OK || resp.error_code[0] != '\0' || resp.error_msg[0] != '\0' || + resp.server_time <= 0 || + (expected ? (!resp.result || strcmp(resp.result, expected) != 0) : resp.result != NULL)) { + printf(" secret call %s: status %d or result bytes differ\n", devid, rt); + result = -1; + } + if (strstr(http_log_capture, TEST_IMAGE_SECRET) || + strstr(http_log_capture, "secretValue") || + strstr(http_log_capture, "atop_response_data_decode:") || + strstr(http_log_capture, "base64 encode result:") || + strstr(http_log_capture, "result:\r\n")) { + printf(" secret call emitted response-body diagnostics\n"); + result = -1; + } +#if AGENTIC_KIT_IOT_LOG_LEVEL >= LOG_DEBUG + /* Positive controls prevent disabled DEBUG or a broken capture from passing. */ + if (!strstr(http_log_capture, "a=thing.km.secret.get.device&") || + !strstr(http_log_capture, "out post data len:") || + !strstr(http_log_capture, "http request send!") || + (plaintext && !strstr(http_log_capture, "try parse the plaintext data.")) || + (!plaintext && strstr(http_log_capture, "try parse the plaintext data."))) { + printf(" expected request/fallback DEBUG diagnostics missing or incorrect\n"); + result = -1; + } +#else + (void)plaintext; +#endif + iot_atop_response_free(&client, &resp); + return result; +} + +static int test_secret_api_returns_exact_result_without_logging(void) +{ + return check_secret_call(TEST_DEVID, TEST_IMAGE_SECRET_RESULT, 0); +} + +static int test_secret_api_plaintext_fallback_without_logging(void) +{ + return check_secret_call("ci_secret_valid_plaintext", TEST_IMAGE_SECRET_RESULT, 1); +} + +static int test_secret_api_malformed_business_results_stay_generic_success(void) +{ + const struct { + const char *mode; + const char *expected; + } cases[] = { + {"number", "{\"secretValue\":7}"}, + {"missing", "{}"}, + {"null", NULL}, + {"array", "[\"" TEST_IMAGE_SECRET "\"]"}, + {"string", "\"" TEST_IMAGE_SECRET "\""}, + }; + for (size_t i = 0; i < sizeof(cases) / sizeof(cases[0]); i++) { + for (int plaintext = 0; plaintext <= 1; plaintext++) { + char devid[64]; + snprintf(devid, sizeof(devid), "ci_secret_%s%s", cases[i].mode, + plaintext ? "_plaintext" : ""); + if (check_secret_call(devid, cases[i].expected, plaintext) != 0) return -1; + } + } + return 0; +} + +static int test_secret_api_rejections_without_logging(void) +{ + const struct { + const char *devid; + int status; + const char *error_code; + } cases[] = { + {"ci_secret_reject", OPRT_ATOP_BUSINESS_ERROR, "SECRET_" TEST_IMAGE_SECRET}, + {"ci_secret_reject_plaintext", OPRT_ATOP_BUSINESS_ERROR, "SECRET_" TEST_IMAGE_SECRET}, + {"ci_secret_nocode", OPRT_COMMUNICATION_ERROR, ""}, + {"ci_secret_nocode_plaintext", OPRT_COMMUNICATION_ERROR, ""}, + }; + for (size_t i = 0; i < sizeof(cases) / sizeof(cases[0]); i++) { + iot_client_t client = g_client; + snprintf(client.devid, sizeof(client.devid), "%s", cases[i].devid); + iot_atop_request_t req = { .api = "thing.km.secret.get.device", + .version = "1.0", .data = "{}" }; + iot_atop_response_t resp = {0}; + test_log_capture_begin(http_log_capture, sizeof http_log_capture); + int rt = iot_atop_call(&client, &req, &resp); + test_log_capture_end(); + + int result = 0; + if (rt != cases[i].status || resp.result != NULL || resp.server_time <= 0 || + strcmp(resp.error_code, cases[i].error_code) != 0 || + strcmp(resp.error_msg, "mock rejection: " TEST_IMAGE_SECRET) != 0) { + printf(" secret rejection %s: status %d or error fields differ\n", cases[i].devid, rt); + result = -1; + } + if (strstr(http_log_capture, TEST_IMAGE_SECRET) || + strstr(http_log_capture, "mock rejection:") || + strstr(http_log_capture, "SECRET_") || + strstr(http_log_capture, "atop_response_data_decode:") || + strstr(http_log_capture, "base64 encode result:") || + strstr(http_log_capture, "result:\r\n")) { + printf(" secret rejection emitted response-body diagnostics\n"); + result = -1; + } +#if AGENTIC_KIT_IOT_LOG_LEVEL >= LOG_ERROR + const char *diagnostic = cases[i].error_code[0] ? + "atop rejected (sensitive response)" : + "atop rejected without errorCode (sensitive response)"; + if (!strstr(http_log_capture, diagnostic)) { + printf(" sanitized ERROR diagnostic missing\n"); + result = -1; + } +#endif +#if AGENTIC_KIT_IOT_LOG_LEVEL >= LOG_DEBUG + if ((strstr(cases[i].devid, "_plaintext") != NULL) != + (strstr(http_log_capture, "try parse the plaintext data.") != NULL)) { + printf(" unexpected Envelope parsing path\n"); + result = -1; + } +#endif + iot_atop_response_free(&client, &resp); + if (result != 0) return result; + } + return 0; +} + +static int test_secret_log_suppression_is_exact_and_request_local(void) +{ + /* A similar but distinct API must retain diagnostics after secret calls. */ + if (check_secret_call(TEST_DEVID, TEST_IMAGE_SECRET_RESULT, 0) != 0) return -1; + iot_atop_request_t req = { .api = "thing.km.secret.get.device.extra", + .version = "1.0", .data = "{}" }; + iot_atop_response_t resp = {0}; + test_log_capture_begin(http_log_capture, sizeof http_log_capture); + int rt = iot_atop_call(&g_client, &req, &resp); + test_log_capture_end(); + + int result = rt == OPRT_ATOP_BUSINESS_ERROR && + strcmp(resp.error_code, "UNKNOWN_API") == 0 ? 0 : -1; +#if AGENTIC_KIT_IOT_LOG_LEVEL >= LOG_DEBUG + if (!strstr(http_log_capture, "atop_response_data_decode:") || + !strstr(http_log_capture, "base64 encode result:") || + !strstr(http_log_capture, "result:\r\n")) { + printf(" nonsecret API lost its body diagnostics\n"); + result = -1; + } +#endif +#if AGENTIC_KIT_IOT_LOG_LEVEL >= LOG_ERROR + if (!strstr(http_log_capture, "atop rejected: errorCode=UNKNOWN_API errorMsg=Unknown API: thing.km.secret.get.device.extra")) { + printf(" nonsecret API lost its rejection diagnostics\n"); + result = -1; + } +#endif + iot_atop_response_free(&g_client, &resp); + return result; +} + static int test_oversized_response_is_explained(void) { char body[64]; @@ -745,9 +949,15 @@ int main(void) RUN_TEST(test_null_body_defaults_to_empty_object); RUN_TEST(test_business_error_carries_error_code); RUN_TEST(test_unknown_api_reaches_the_cloud); + RUN_TEST(test_call_binds_body_timestamp_to_url); RUN_TEST(test_null_result_stays_null); RUN_TEST(test_gateway_not_exists_is_business_error); RUN_TEST(test_response_zeroed_on_bad_request); + RUN_TEST(test_secret_api_returns_exact_result_without_logging); + RUN_TEST(test_secret_api_plaintext_fallback_without_logging); + RUN_TEST(test_secret_api_malformed_business_results_stay_generic_success); + RUN_TEST(test_secret_api_rejections_without_logging); + RUN_TEST(test_secret_log_suppression_is_exact_and_request_local); RUN_TEST(test_oversized_response_is_explained); RUN_TEST(test_response_free_is_repeatable); RUN_TEST(test_sync_check_guards); diff --git a/modules/iot-client/test/mock/atop_mock.py b/modules/iot-client/test/mock/atop_mock.py index 23fe5b1..a6b98b8 100755 --- a/modules/iot-client/test/mock/atop_mock.py +++ b/modules/iot-client/test/mock/atop_mock.py @@ -30,6 +30,7 @@ Supported APIs: - thing.device.opensdk.active: Device activation - thing.ai.agent.token.get: AI token retrieval + - thing.km.secret.get.device: Synthetic fixed image secret retrieval - tuya.device.qrcode.info.get: QR code info retrieval - tuya.device.meta.save: Device metadata save - tuya.device.info.sync: Device binding status query @@ -344,6 +345,43 @@ def handle_ai_token_request(request_data, config): return json.dumps(response, separators=(',', ':')) +def handle_image_secret_request(request_data, url_params): + """Synthetic secret; test-only devId selects malformed business results.""" + # The real cloud binds the URL t to the body t: a body without a t (or one + # that disagrees) is rejected. Reproduce that here so the SDK is exercised + # on the injection it must perform, not on the empty-object shortcut. + try: + body = json.loads(request_data) + except (ValueError, TypeError): + body = None + if (url_params.get('v') != '1.0' or not isinstance(body, dict) or + 't' not in body or str(body['t']) != url_params.get('t')): + return json.dumps({"success": False, "t": int(time.time()), + "errorCode": "ILLEGAL_PARAM", + "errorMsg": "expected v1.0 and a body t matching the url t"}) + + secret = 'AAECAwQFBgcICQoLDA0ODw==' # bytes 0x00 through 0x0f, never a real key + mode = url_params.get('devId', '').replace('ci_secret_', '', 1) + if mode.endswith('_plaintext'): + mode = mode[:-len('_plaintext')] + if mode in ('reject', 'nocode'): + response = {"success": False, "t": int(time.time()), + "errorMsg": "mock rejection: " + secret} + if mode == 'reject': + response['errorCode'] = 'SECRET_' + secret + return json.dumps(response, separators=(',', ':')) + results = { + 'number': {"secretValue": 7}, + 'missing': {}, + 'null': None, + 'array': [secret], + 'string': secret, + } + result = results.get(mode, {"secretValue": secret}) + return json.dumps({"success": True, "t": int(time.time()), "result": result}, + separators=(',', ':')) + + def handle_device_meta_save_request(request_data, config): """Handle device meta save request (tuya.device.meta.save)""" try: @@ -741,6 +779,8 @@ def do_POST(self): response_json = handle_activate_request(decrypted_data, self.config) elif api == 'thing.ai.agent.token.get': response_json = handle_ai_token_request(decrypted_data, self.config) + elif api == 'thing.km.secret.get.device': + response_json = handle_image_secret_request(decrypted_data, url_params) elif api == 'tuya.device.qrcode.info.get': response_json = handle_qrcode_info_request(decrypted_data, self.config, url_params) elif api == 'tuya.device.meta.save': @@ -786,6 +826,27 @@ def do_POST(self): "errorCode": "GATEWAY_NOT_EXISTS", "errorMsg": "device removed from the cloud" }, separators=(',', ':')) + elif api == 'tuya.test.body.t.matches.url.t': + # Test-only: success only when the SDK put the same t in the + # decrypted body and in the signed URL -- the cloud's binding. + try: + echo = json.loads(decrypted_data) + except (ValueError, TypeError): + echo = None + body_t = echo.get('t') if isinstance(echo, dict) else None + if body_t is not None and str(body_t) == url_params.get('t'): + response_json = json.dumps({ + "success": True, + "t": int(time.time()), + "result": True + }, separators=(',', ':')) + else: + response_json = json.dumps({ + "success": False, + "t": int(time.time()), + "errorCode": "ILLEGAL_PARAM", + "errorMsg": "body t does not match url t" + }, separators=(',', ':')) else: response_json = json.dumps({ "success": False, @@ -814,6 +875,10 @@ def do_POST(self): print(f"📤 Encrypted response (base64): {encrypted_response_b64[:64]}...") + # Test-only plaintext Envelope fallback, selected without business parameters. + if api == 'thing.km.secret.get.device' and devid.endswith('_plaintext'): + response_body = response_json + # Send response self.send_response(200) self.send_header('Content-Type', 'application/json;charset=UTF-8') diff --git a/modules/rtc-tcp-client/CONTEXT.md b/modules/rtc-tcp-client/CONTEXT.md index 25c0efd..448f855 100644 --- a/modules/rtc-tcp-client/CONTEXT.md +++ b/modules/rtc-tcp-client/CONTEXT.md @@ -1,6 +1,6 @@ # RTC TCP Client (Device ↔ Tuya AI Foundation) -The device-side context that opens a real-time audio/video/text conversation with the +The device-side context that opens a real-time audio/image/text conversation with the Tuya AI Foundation server over a TCP+TLS link, then streams media and events both ways in a custom binary framing. This is transport, not business state — it carries turns of a conversation, it does not own device DPs (that is the [IoT Client](../iot-client/CONTEXT.md)). @@ -56,8 +56,11 @@ _Avoid_: event type (a different axis — see ambiguities), fragment flag (that **Data ID**: A `uint16` tag multiplexing channels within an Event — `AUDIO_UP`/`AUDIO_DOWN`, -`TEXT_UP`/`TEXT_DOWN`, `IMAGE_UP` (`TAI_DATA_ID_*`). `_UP` is device→server, `_DOWN` is -server→device. +`TEXT_UP`/`TEXT_DOWN`, `IMAGE_UP`/`IMAGE_DOWN` (`TAI_DATA_ID_*`). `_UP` is +device→server, `_DOWN` is server→device. Keyed Sessions advertise only audio/text/image: +send IDs 1/3/5, receive IDs 2/4/6. TuyaOpen allocates successive even receive IDs in +configured type order, not by a universal media-type-to-ID mapping. No video/file +handling is implemented; Packet type constants do not establish support. _Avoid_: data type, channel id (informal), stream id. **Event type**: @@ -69,9 +72,35 @@ _Avoid_: packet type (the wire kind), stream flag (chunk position), command. How each Frame is authenticated: `NONE`, `HMAC_SHA1` (20-byte sig), or `HMAC_SHA256` (32-byte sig) (`TAI_SIGN_*`). The names are wire labels, not algorithm choices — there is no SHA-1 primitive in `tai_crypto.c`; both signed levels compute HMAC-SHA256 into a 32-byte buffer -and `HMAC_SHA1` merely ships its first 20 bytes. ClientHello is the one Frame sent unsigned. +and `HMAC_SHA1` merely ships its first 20 bytes. Without an Image key, ClientHello is +unsigned and the configured/default signing applies afterward. With an Image key, +all Frames are unsigned (level 0, no trailer), ignoring `cfg.sign_level`; this is the +user-approved TuyaOpen alignment, not a negotiated fallback. _Avoid_: encryption (signing ≠ confidentiality), auth mode, "the SHA-1 signature". +**Image key**: +A fixed, application-acquired `TAI_IMAGE_KEY_LEN` (16)-byte binary AES-128 key, +supplied through `tai_config_t.image_key` and copied into private context storage. +The same immutable key encrypts uplink and decrypts downlink images. It is +independent of `local_key` and derived Frame keys; an all-zero key is valid. The +copy survives disconnect and failed Connection attempts and is wiped at context +destruction. No TAI lifecycle path requests, refreshes or persists it. +Encryption in all three image send APIs and the authenticated RAW/Base64 receive +pipeline with typed URL pass-through are enabled. Key presence selects Frame +version 3 (reserved bits 2), v2 Attribute layout and the 36-byte ClientHello suite +`00 || random[32] || 04 03 03`. The suffix selects AES-128-GCM, whole video I-frames +and whole images as encryption ranges, not media capabilities. Keep public +`protocol_version` at 0 or `TAI_VER_21`; it is not the Frame version nibble. +The user explicitly approved unsigned Frames for this profile. Since image GCM +uses no AAD, verified TLS protects metadata/control traffic, URLs and empty +markers; each encrypted Image Packet body is authenticated by its own GCM tag. +Supply a nonempty borrowed `cfg.ca_cert` (full PEM or bare Base64 body, not a path) +or `cert_bundle_attach`. Raw TCP or missing trust returns `TAI_ERR_TLS` before +networking. NULL key preserves legacy framing/signing/Session/image behavior. +Live-cloud compatibility remains unverified. See +[the security decision and evidence](docs/adr/0002-image-receive-decryption.md). +_Avoid_: decryption-only key, session key, Frame key, cached credential, rotating secret. + **Keepalive (Ping / Pong)**: The liveness exchange the background thread runs: it sends a Ping every `ping_interval_ms` (default 60 s) and treats the Connection as dead if no inbound traffic — a Pong *or any* @@ -144,8 +173,8 @@ _Avoid_: tool call, function call, RPC (too generic). ### Layers and threads Top to bottom: the send API (`tai_send_*`) → Packet builders (`tai_proto_build_*`) → the -Frame layer — encoding, the HMAC signature, and Fragmentation (`tai_transport.c`) → an I/O -abstraction (`ctx_io_send` / `ctx_io_recv`, routing to TLS or, in test mode, raw TCP) → the +Frame layer — encoding, legacy HMAC signatures, and Fragmentation (`tai_transport.c`) → an I/O +abstraction (`ctx_io_send` / `ctx_io_recv`, routing to TLS or, in no-key test mode, raw TCP) → the PAL. Two threads run at once: **caller threads** drive every `tai_send_*`; one **background worker** (started by `tai_connect`, joined by `tai_disconnect`) drives receiving and Keepalive. @@ -166,14 +195,16 @@ Every packet — control and media — streams **scatter-gather** through `send_ no large contiguous frame buffer: - **Media packets** (audio, image, large text, MCP JSON): a `*_hdr` builder writes only the small - application header into `tx_hdr_buf+5`, and the large payload stays in the caller's buffer. + application header into `tx_hdr_buf+5`, and the large payload stays in the caller's buffer + except keyed images, which use temporary encrypted scratch. - **Control packets** (SessionNew/Close, Event start/payloads-end/end, ChatBreak, Ping): the whole application packet is assembled in `tx_ctrl_buf` (its attribute block can carry the user session/event JSON) and handed to `send_app` → `send_app_sg` with no application header — the packet itself is the zero-copy payload. -`send_one_frame_sg` signs the logical `[frame header || app header || payload]` via -`tai_frame_hmac_sg` (byte-identical to the contiguous HMAC — the receiver is unchanged), then +For legacy signed Connections, `send_one_frame_sg` signs the logical +`[frame header || app header || payload]` via `tai_frame_hmac_sg` (byte-identical to +the contiguous HMAC). Keyed Connections skip signing and the trailer. The sender then emits the frame in one of two shapes, split by `AGENTIC_KIT_TAI_FRAME_COALESCE_LIMIT` (default 512 B, counting the whole frame: header, app header, payload, signature): @@ -185,8 +216,8 @@ counting the whole frame: header, app header, payload, signature): `[frame header || app header]`, the **payload from the caller's buffer**, and the signature. A logical packet over 32 KB is fragmented across the concat, with the -app header only in the first Frame. (ClientHello is the one exception: it is sent *unsigned* and -one-shot, so `tai_connect` frames it inline on the stack rather than through the signing sender.) +app header only in the first Frame. ClientHello bypasses this scatter-gather path: +`tai_connect` frames it inline on the stack, unsigned and one-shot in both profiles. Sequence numbers come from `tai_next_seq` (monotonic, wrapping 65535→1). A failure *before* any byte hits the wire (a build/encode error) rolls the counter back and returns that error. A @@ -208,8 +239,16 @@ signal. Higher-level senders compose Packets into an Event: Image (ONE_SHOT) → EventPayloadsEnd → EventEnd. - **Standalone**: `tai_ping`, `tai_chat_break`, and `tai_send_mcp_response` are each one Packet. -ClientHello is the one Packet sent at Sign level NONE (unsigned); everything after SessionNew -is signed. +All three image-bearing APIs (`tai_send_image`, `tai_send_image_with_text`, +`tai_send_image_audio_start`) implement encryption with the copied Image key before +EventStart. A nonempty input needs `len + 32` scratch bytes (16-byte IV, ciphertext, +16-byte tag); empty input needs 16 bytes (IV only). Scratch is wiped/freed on every +exit, and input remains untouched. No-key sends retain legacy behavior. + +ClientHello is always unsigned. In the legacy profile, SessionNew and subsequent +Frames use the configured/default Sign level; in the keyed profile every Frame +is unsigned and the Connection uses TLS, verifying the peer when a nonempty CA or +cert bundle is supplied and running unverified (one warning) when neither is. ### Receiving @@ -225,14 +264,20 @@ the worker fire `on_disconnect`. `tai_process_rx` peels complete Frames off the front of that buffer: -1. Confirm the leading byte looks like a v2.1 Frame; if not, fail-fast (on a reliable ordered - TLS stream a desync cannot be recovered by dropping bytes). +1. Confirm the leading byte matches the selected Frame profile: legacy version 2, or + keyed version 2 or 3. The cloud answers its own control/acknowledgement Frames with + version 2 and only media with version 3, sharing the v2.1 header layout, so a keyed + Connection accepts either; the profile is pinned by the signature length (0), not + this nibble, so accepting version 2 is not a downgrade to the legacy signed read. A + genuinely unsupported version is fail-fast; an ordered TLS stream cannot resync by + dropping bytes. 2. Read the length field for the full Frame size; wait if the whole Frame has not arrived. A Frame that declares more bytes than the receive buffer can hold (`rx_buf`, one max Fragment) can never be assembled, so it is fail-fast (`TAI_PROTO_ERR_OVERSIZED`) rather than stalling until the liveness timeout — this only trips if the server ignores the advertised MAX_FRAGMENT_LEN. -3. Verify the HMAC; a mismatch is fail-fast (tear down and reconnect). +3. Verify the HMAC on legacy signed Frames; a mismatch is fail-fast. Keyed Frames + have no HMAC and rely on verified TLS plus per-Image-Packet GCM authentication. 4. Decode the header, then reassemble Fragmentation: `FRAG_NONE` is complete as-is; FIRST/MIDDLE/LAST accumulate into the reassembly buffer (`frag_buf`) until LAST, then the whole Packet is decoded once. A truncated/oversized fragment, an orphan MIDDLE/LAST, an @@ -259,11 +304,28 @@ session) — it fires from the dispatch path but does **not** latch the guard, s death that later tears the same Connection down is still delivered as a second, distinct `on_disconnect`. +Keyed **Image** processing validates the extended header, exact bounded image-params +and Event identity after complete-Packet reassembly. RAW and strictly decoded Base64 +bodies use AES-128-GCM with a 16-byte IV/tag and no AAD; tag-only bodies authenticate, +while header-only START/ONE_SHOT/END are zero-length markers (MIDDLE is rejected). +URL bytes are passed through, never downloaded or decrypted. `payload_type` reports +RAW for decrypted bytes and URL for URLs; legacy payload bytes remain unchanged. +Continuations must match the latched Event/Data ID and any repeated parameters; +completion, Event boundaries, SessionClose, disconnect and errors clear stream state. +Plaintext is borrowed temporary PAL heap storage, wiped/freed after callback return. +A failed Packet produces no image callback; the positive fatal details +`TAI_PROTO_ERR_IMAGE_AUTH` (11) and `TAI_PROTO_ERR_IMAGE_PROCESSING` (12), or existing +malformed/state details, reach one terminal `on_disconnect(PROTOCOL, detail)`. +Applications discard incomplete images; no error triggers key acquisition or fallback. + ### Connection lifecycle -`tai_connect`: generate the security-suite random → derive the encrypt and sign keys → open the -Connection (TLS handshake) → send the unsigned ClientHello → send SessionNew → mark the -Connection connected and the Session open → start the worker. +`tai_connect`: for a keyed context first reject raw TCP or missing trust with +`TAI_ERR_TLS` before networking → generate the security-suite random → derive +Frame keys only for the legacy profile → open the Connection (required peer +verification for keyed TLS) → send unsigned ClientHello → send SessionNew → +confirm the server acknowledgement → mark the Connection connected and the +Session open → start the worker. The copied Image key is never replaced by HKDF. `tai_disconnect`: stop and join the worker first → then, **all under the send lock**, send SessionClose, close the transport, and reset the receive/reassembly buffers and the connected / @@ -285,25 +347,34 @@ must not block — captured in the callback-contract block in `tuya_ai.h`. - The receive buffer must hold the largest single Frame; because the server fragments anything large, a single Frame stays well within it. - Reassembly trusts FIRST/MIDDLE/LAST ordering — safe over the reliable, in-order Connection. -- ClientHello is the only unsigned Frame. -- Only sign levels 1 and 2 map to a signature length; any other value falls through to - `sig_len = 0`, which disables signing in *both* directions — every Frame ships unsigned and - `tai_frame_verify` returns OK without looking — while ClientHello's security suite still - advertises that level byte. A configured `sign_level` of 0 is not that case: zero reads as - "unset" and becomes HMAC_SHA256. Nothing local catches a wrong choice here — the loopback - server derives keys and signs through the very same functions as the client, so even a wrong - algorithm round-trips. +- Key presence selects an immutable profile, not merely a receive transform: Frame + version 3, unsigned Frames, v2 Attributes and the exact `00 || random[32] || 04 03 03` + suite. A suite Attribute, if received, must match its length/sign/range settings. +- Keyed Connections require TLS (raw TCP is rejected) and verify the peer whenever a + nonempty `ca_cert` or `cert_bundle_attach` is supplied. With neither, the Connection + still uses TLS but unverified, behind one warning -- the same CA-driven rule the IoT + path follows. There is no module knob to relax verification: supplying no trust source + is the only way to it, and it is not for production. GCM has no AAD and does not + authenticate metadata or control Packets, so an unverified keyed Connection exposes + them to a man-in-the-middle. No video/file support follows from the suite range bytes. +- No-key signing is unchanged: zero `cfg.sign_level` means default HMAC-SHA256; + levels 1/2 map to 20/32 bytes. Other legacy values fall through to zero signature + length and are not a supported way to select the encrypted-media profile. Keyed + contexts deliberately ignore the field and use level 0. Shared encoder/loopback + round trips alone are not independent evidence of correct wire bytes. ## Example dialogue > **Dev:** I called `tai_connect` and it returned OK — am I in a Session yet? > **Expert:** Yes. `tai_connect` opens the Connection (TLS), sends an unsigned ClientHello, -> then a signed SessionNew, and starts the receive thread. After that you have one Session +> then SessionNew (signed for legacy, unsigned for keyed), confirms the acknowledgement, +> and starts the receive thread. After that you have one Session > (`vcd-session-…`) ready for Events. > **Dev:** So `tai_send_text("hi")` is one packet? > **Expert:** No — it's a whole Event: EventStart, a Text packet on data-id `TEXT_UP` with > stream flag ONE_SHOT, then EventPayloadsEnd and EventEnd. Each is an application Packet, -> wrapped in a Frame with a sequence number and an HMAC signature at your sign level. +> wrapped in a Frame with a sequence number and, for legacy signed Connections, an HMAC. +> With an Image key, Frames are unsigned over required verified TLS. > **Dev:** The reply comes back on `on_text`? > **Expert:** Right — server text arrives on `on_text` with a stream flag (START…END for a > streamed answer), audio on `on_audio`, and everything else on `on_event`: the inbound diff --git a/modules/rtc-tcp-client/docs/adr/0002-image-receive-decryption.md b/modules/rtc-tcp-client/docs/adr/0002-image-receive-decryption.md new file mode 100644 index 0000000..06522a2 --- /dev/null +++ b/modules/rtc-tcp-client/docs/adr/0002-image-receive-decryption.md @@ -0,0 +1,201 @@ +# Fixed image key and TuyaOpen encrypted-media Connections + +Status: accepted by explicit user authorization; implemented in production code. +Keyed Connections, all three image-upload encryption paths and the RAW/Base64/URL +receive pipeline are enabled and verified against a local TLS peer. +Live-cloud compatibility remains unverified. + +## Decision + +The application explicitly acquires the fixed image key using `iot_atop_call()` +with `thing.km.secret.get.device` version `1.0`, or reuses a key it already owns. +It validates `result.secretValue` as Base64 decoding to exactly 16 bytes and +supplies the binary bytes through `tai_config_t.image_key`. + +The constructor copies the key after fallible initialization. Presence is determined +by the pointer, so an all-zero key is valid. The owned immutable bytes are separate +from Frame encryption/signing keys, survive disconnect and failed Connection +attempts, and are wiped with `mbedtls_platform_zeroize()` on context destruction. +The owner must stop/join the worker before destruction. There is no SDK acquisition, +cache, refresh, persistence, setter, or IoT initialization/onboarding change. + +The same `TAI_IMAGE_KEY_LEN` (16)-byte image key is used for uplink encryption and +downlink decryption. This supersedes the original receive-only scope and the +former decryption-only naming; no separate upload key or runtime setter is added. + +The user explicitly authorized alignment with TuyaOpen's encrypted-media settings, +superseding the previous signing/image-only-suite blockers. A supplied key selects +Frame version 3 (reserved bits 2), no Frame signature (sign level 0, ignoring +`cfg.sign_level`), v2 Attribute layout and the exact 36-byte ClientHello suite +`00 || random[32] || 04 03 03`. The suffix means AES-128-GCM, whole video I-frames +and whole images as encryption ranges, not media capabilities. We do not invent +an image-only suite or advertise video/file Data IDs; video/file handling is not +implemented. Public `protocol_version` stays 0 or `TAI_VER_21`, not 3. + +Keyed SessionNew advertises send IDs 1 (audio), 3 (text), 5 (image), and receive +IDs 2 (audio), 4 (text), 6 (image). TuyaOpen's sequential even receive-ID allocator +supports this allocation by configured type order; it is not evidence of a universal +fixed mapping from media type to ID. Keyed image receive requires ID 6. + +**Security decision:** image AES-128-GCM uses no AAD. Its per-Packet tag authenticates +the encrypted image body, not the media metadata, Attributes or control traffic. +Unsigned Frames therefore need verified TLS to protect that metadata/control +traffic, URL pass-through and empty markers. Configure a nonempty `cfg.ca_cert` +(full PEM or bare Base64 body, not a file path; borrowed for context lifetime) or +`cert_bundle_attach`. When either is supplied, `tai_connect()` verifies the peer; a +trust pointer is not proof of a valid certificate or successful handshake. With +neither, the Connection still uses TLS but with the peer unverified and one warning +logged -- the same CA-driven rule as the IoT path, and not for production. `disable_tls` +(raw TCP) is always rejected for a keyed context with `TAI_ERR_TLS` before networking; +there is no legacy-profile fallback for a keyed context. + +A NULL key keeps legacy Frame version 2, signing defaults, 33-byte suite, Session +payload and image bytes unchanged. The optional CA field can also enable required +peer verification without a key; omitting it retains the legacy TLS configuration. +Rebuild SDK and consumers for the public config and typed image-message layouts. +Implemented source alignment is not evidence of live-cloud interoperability. + +## Implemented processing + +- `tai_send_image()`, `tai_send_image_with_text()` and + `tai_send_image_audio_start()` prepare encrypted image data before EventStart. + Nonempty input uses `len + 32` bytes of scratch: a 16-byte IV, ciphertext and a + 16-byte tag. Empty input uses 16 bytes (IV only). The caller's input is not + modified; temporary scratch is wiped and freed on success and failure. +- Receive processing runs after complete-Packet transport reassembly. The extended + header is eight media bytes plus a 16-byte IV. RAW contains ciphertext and a + trailing 16-byte tag; canonical Base64 is decoded first. AES-128-GCM uses no AAD, + separate bounded plaintext storage and authentication before delivery. Tag-only + bodies authenticate too; header-only START/ONE_SHOT/END are zero-length markers, + not authenticated image data, and header-only MIDDLE is rejected. +- URL bytes are passed through as `TAI_IMG_PAYLOAD_URL`, never fetched or decrypted. + Decrypted bytes are typed `TAI_IMG_PAYLOAD_RAW` in `tai_image_msg_t.payload_type`. + Without a key, legacy bytes remain unchanged and the parsed Packet type is + reported (RAW when absent). No JPEG/PNG-to-pixels decoding is provided. +- Keyed parsing validates exact bounded image-params fields and ranges, duplicate + image-params and unambiguous Event IDs. START/ONE_SHOT supplies parameters; + MIDDLE/END must match the active Event/Data ID and any repeated parameters. + Missing continuation Attributes inherit payload type, not format/dimensions. + Completion, Event boundaries, SessionClose, disconnect and failures reset stream + state. No failed Packet is delivered and no key request or plaintext fallback + follows an error. +- Dispatch returns positive fatal details: `TAI_PROTO_ERR_IMAGE_AUTH = 11` for + GCM authentication failure and `TAI_PROTO_ERR_IMAGE_PROCESSING = 12` for scratch + allocation/processing failure. Existing malformed-header/Packet and unexpected + state details remain in use. The worker reports one terminal + `on_disconnect(PROTOCOL, detail)`; the application discards any incomplete image. +- Callback delivery remains on the worker. All pointers are borrowed until callback + return; plaintext can occupy temporary PAL heap storage and is wiped/freed then. + Retaining image bytes requires an application copy, not retaining the pointer. + URL bytes remain length-delimited, not necessarily NUL-terminated. + +## TuyaOpen source evidence + +Reference: `/Users/wangsn/work/device-dev/TuyaOpen/image-decryption.md`, checked +against the source paths below in that checkout. These observations describe +`AI_VERSION == 2` / `AI_SUB_VERSION == 2`, not a captured target-cloud exchange. + +| Concern | Exact source evidence | Implemented decision | +| --- | --- | --- | +| Suite bytes | `src/tuya_ai_service/svc_ai_basic/src/tuya_ai_protocol.c:1368` builds 36 bytes; line 1369 writes `AI_SIGN_LEVEL_0`, line 1371 copies 32 random bytes, and lines 1373-1383 append the nonzero data/video/image settings. `src/tuya_ai_service/svc_ai_agent/src/tuya_ai_agent.c:1252` sets `AI_DATA_SL4`, `AI_VIDEO_ENCRYPT_I_FR_ALL`, `AI_IMAGE_ENCRYPT_ALL`. | Send exactly `00 \|\| random[32] \|\| 04 03 03`, not a guessed image-only variant. | +| Suite meaning | `src/tuya_ai_service/svc_ai_basic/include/tuya_ai_protocol.h:276` defines `AI_DATA_SL4` as AES128-GCM; line 282 defines whole video I-frame encryption as 3; line 288 defines whole-image encryption as 3. | Encryption settings are not a media-support bitmap. No video/file support is claimed. | +| Frame/signing | `src/tuya_ai_service/svc_ai_basic/src/tuya_ai_protocol.c:950` selects version 3 for sub-version 2; line 955 fixes reserved bits to 2. Lines 981-1001 append signatures only in the version-1 branch, not version 2. | User-approved unsigned version-3 Frames; ignore `cfg.sign_level`, do not use an invalid sign level as a workaround. | +| Attribute layout | `src/tuya_ai_service/svc_ai_basic/src/tuya_ai_protocol.c:697` writes the 32-bit Attribute block length; lines 701-718 pack 16-bit type/length in the non-v1 branch, and lines 835-865 unpack that layout independently of the Frame sub-version. | Retain v2 layout (32-bit block length, 16-bit type/length). | +| Data IDs | `src/tuya_ai_service/svc_ai_agent/src/tuya_ai_agent.c:985` assigns receive IDs in configured type order. `src/tuya_ai_service/svc_ai_basic/src/tuya_ai_biz.c:1467` allocates odd send IDs and line 1478 allocates even receive IDs starting at 2. Lines 1374-1400 serialize SessionNew lists of IDs. | SDK audio/text/image order uses send 1/3/5 and receive 2/4/6; these are Session allocations, not universal type numbers. | +| Image body | `src/tuya_ai_service/svc_ai_basic/include/tuya_ai_protocol.h:532` defines the image header with a 16-byte IV for sub-version 2. `src/tuya_ai_service/svc_ai_agent/src/tuya_ai_agent.c:251` decodes Base64 per callback; line 355 decrypts with the final 16-byte tag and no AAD. | Authenticate each complete Image Packet after transport reassembly. Do not copy the reference's `eff_len > 16` authentication bypass; tag-only bodies authenticate and shorter nonempty bodies fail. | +| Uplink/empty markers | `src/tuya_ai_service/svc_ai_basic/src/tuya_ai_biz.c:274` allocates tag space only for a nonempty payload; line 313 uses image IV, fixed key and no AAD, then line 323 appends the tag. | All three send APIs encrypt nonempty images; empty images retain the extended header/IV without a tag. | + +Local implementation mapping: `include/tuya_ai.h` defines key/CA ownership and the +public contract; `src/tai_client.c` selects the immutable profile, enforces TLS, +emits version-3 Frames in both send paths and checks the receive version; +`src/tai_protocol.c` emits the exact suite and Session lists, validates any returned +suite settings and processes images on ID 6. Paths are relative to this module. + +The reference secret fetch passes no business parameters. Documentation uses `{}` +and the local ATOP mock verifies that shape, but cloud acceptance is not yet +verified. Generic ATOP remains untyped: malformed business results can return +`OPRT_OK` and must be rejected by the application. Only this exact secret API's +response-body diagnostics are suppressed, request-locally, including plaintext +Envelope fallback; unrelated API diagnostics remain intact. + +## Remaining validation + +The source mapping and user-approved security decision resolve the former +Connection blockers; the production path is enabled. Real verified-TLS tests use +public construction/connect with an in-process mbedTLS peer and existing synthetic +certificate fixtures. They cover CA/bundle trust, wrong CA/hostname rejection, +pre-network raw/missing-trust rejection, exact ClientHello/Session bytes, +wrong-profile rejection, worker image callbacks, all upload APIs, ChatBreak with +same/omitted Event ID, and reconnect with the same immutable key. + +Fresh validation on 2026-10-14: +- Full serial host CTest: 20/20 passed. +- Full serial ASan/UBSan CTest: 20/20 passed with `detect_leaks=0` and + `halt_on_error=1`; not a leak-detection result. +- Docs typecheck, locale parity, link tests and both locale builds passed. +- ESP32-S3 `idf -B build-image-decryption build` compiled and linked; existing + certificate-bundle callback cast warnings remain. No flashing was performed. + +An authorized live-cloud exchange remains pending for acquisition with `{}`, +ClientHello/acknowledgement/Session acceptance, image boundaries and empty markers +in both directions, and same-key reconnect. Source alignment and offline fixtures +must not be reported as successful cloud verification. + +The 32,000-byte reassembled-Packet default is too small for keyed image receive. +Observed on 2026-10-14 (POSIX `image_generation_demo`, peer verification disabled): +the cloud delivered a complete inline image as a single Packet of ~400 KB, which +overflowed the reassembly buffer and failed the Connection with `TAI_PROTO_ERR_FRAG` +before any image arrived. A single Packet must therefore be bounded by the whole +image plus its Packet metadata, not by a few 4096-byte transport fragments; the +16 MiB image ceiling makes that a per-consumer decision, not a safe universal +default. The module default stays 32,000 for consumers that never receive keyed +images, and a keyed-image consumer overrides `AGENTIC_KIT_TAI_FRAG_BUF_SIZE` to its +image ceiling -- `examples/posix` sizes the SDK library it builds to 16 MiB + 4096, +after which the same exchange saved a 400 KB PNG. RAW receive needs bounded separate +plaintext scratch, while Base64 also needs a decode buffer; upload scratch is +described above. Target peak heap/stack measurement is still not recorded. No new +runtime dependencies are required. + +## Historical gated-pipeline validation (2026-10-14) + +These recorded results predate enabled keyed Connections and required verified TLS. +They are historical only, not validation of the current production profile or the +present documentation edits. Fresh results are recorded above. + +- Serial host CTest in `build-feature`: 20/20 passed. +- Serial ASan/UBSan CTest in `build-asan-feature`: 20/20 passed with + `detect_leaks=0` and `halt_on_error=1`. Scratch allocation/free balance is checked + by test PALs; this is not a general leak-detection result. +- Worker parser tests cover fragmented RAW/Base64 delivery, authentication failure, + allocation failure, strict wire Attribute validation, exact Packet ceilings and + lifecycle reset. They install the key in an admission-paused legacy loopback + context; they do not prove encrypted-profile handshake compatibility. +- Docs typecheck, locale parity, link tests and both production locale builds passed. +- ESP32-S3 consumer `examples/esp-idf/ai/rtc-tcp-client` compiled and linked with + `idf -B build-image-decryption build`. Existing cert-bundle function-pointer cast + warnings remain. No flashing, cloud exchange or runtime memory measurement. + +## Historical preparatory validation + +The following results describe the earlier key-ownership/crypto-primitive revision, +not the current upload and receive pipeline. They must not be treated as validation +of the current changes or as evidence of cloud compatibility. + +- Serial host CTest: 20/20 passed, including both log-disabled TAI variants, + packet-log compile matrix, legacy receive integration and secret-API logging. +- Serial ASan/UBSan CTest: 20/20 passed with `detect_leaks=0` and + `halt_on_error=1`. This is not a leak-detection result. UBSan found an + intermediate out-of-bounds pointer in the new truncation test; parenthesizing + the offset fixed it before the successful full rerun. +- `npm run build` in `docs-site`: typecheck, locale parity, link checks and both + locale production builds passed. +- ESP-IDF `examples/esp-idf/ai/rtc-tcp-client`: full ESP32-S3 build passed in + `build-image-decryption`, using an isolated sdkconfig generated from the + consumer's existing defaults. GCM/key-wiping translation units compiled and + firmware linked; no flashing or runtime memory measurements were performed. +- At that revision, existing CI invoked the modified suites without new CMake + registration or CI source-list changes; each suite's main called the added cases. + +Release notes are deferred until a real PR number exists; do not fabricate one. +Document the enabled implementation separately from pending validation; do not +claim live-cloud verification. diff --git a/modules/rtc-tcp-client/include/tuya_ai.h b/modules/rtc-tcp-client/include/tuya_ai.h index 199e27a..81bddf1 100644 --- a/modules/rtc-tcp-client/include/tuya_ai.h +++ b/modules/rtc-tcp-client/include/tuya_ai.h @@ -94,6 +94,8 @@ extern "C" { #define TAI_IMG_JPEG 1 #define TAI_IMG_PNG 2 +#define TAI_IMAGE_KEY_LEN 16 + /* ========================================================================= * Constants -- Image payload types (Section 9.3) * ========================================================================= */ @@ -121,6 +123,7 @@ extern "C" { #define TAI_DATA_ID_TEXT_UP 3 #define TAI_DATA_ID_TEXT_DOWN 4 #define TAI_DATA_ID_IMAGE_UP 5 +#define TAI_DATA_ID_IMAGE_DOWN 6 /* advertised by keyed Sessions */ #define TAI_DATA_ID_AUDIO_AUX 7 /* ========================================================================= @@ -155,7 +158,8 @@ typedef struct tai_ctx tai_ctx_t; * * Every receive callback takes ONE const message pointer + user_data. The * struct AND every pointer inside it (data/text/event_id) are valid ONLY for - * the callback's duration — copy to retain. The SDK never heap-allocates these. + * the callback's duration — copy to retain. Message structs are stack-allocated; + * authenticated image plaintext uses temporary PAL storage freed after the call. * * ABI: the SDK zero-fills the struct before populating; new fields are appended * before _reserved. Forward-safe only when the SDK is at least as new as the @@ -193,9 +197,14 @@ typedef struct tai_text_msg { * chunks: START (or ONE_SHOT) carries the first bytes and the image-params, * MIDDLE continues, END terminates (len may be 0). The caller accumulates the * chunks by stream_flag and decodes once the stream ends. format/width/height - * are populated from image-params on START/ONE_SHOT and 0 on MIDDLE/END. */ + * are populated when image-params is present, otherwise 0 on continuations. + * Keyed inline RAW/Base64 bodies are authenticated per complete Packet, then + * delivered as RAW plaintext (image-file bytes, not decoded pixels). URL bytes + * are passed through unchanged, never downloaded or decrypted. No-key payloads + * remain unchanged, with their original type when supplied (0 when absent). + * On a protocol failure discard any accumulated incomplete image. */ typedef struct tai_image_msg { - const uint8_t *data; /* encoded image bytes (JPEG/PNG); callback-lifetime */ + const uint8_t *data; /* image payload or URL; borrowed until callback returns */ size_t len; uint8_t format; /* TAI_IMG_JPEG / TAI_IMG_PNG / 0=unknown */ uint16_t width; /* px, 0 if unknown / not on this chunk */ @@ -204,6 +213,7 @@ typedef struct tai_image_msg { uint16_t data_id; /* Data ID */ const char *event_id; /* turn id, borrowed; "" if none */ uint64_t timestamp_ms; /* stream-start ts (media header) */ + uint8_t payload_type; /* TAI_IMG_PAYLOAD_*; decrypted bytes are RAW */ uint8_t _reserved[8]; } tai_image_msg_t; @@ -239,6 +249,8 @@ typedef struct tai_event_msg { #define TAI_PROTO_ERR_MEDIA_HDR 8 /* media/text header truncated */ #define TAI_PROTO_ERR_UNEXPECTED 9 /* valid packet, wrong state (behaviour) */ #define TAI_PROTO_ERR_OVERSIZED 10 /* inbound frame exceeds rx_buf (see note) */ +#define TAI_PROTO_ERR_IMAGE_AUTH 11 /* Image GCM authentication failed */ +#define TAI_PROTO_ERR_IMAGE_PROCESSING 12 /* Image scratch allocation/processing failed */ typedef struct tai_disconnect_msg { uint8_t reason; /* TAI_DISCONNECT_* */ @@ -253,7 +265,8 @@ typedef struct tai_disconnect_msg { * Configuration * ========================================================================= * Fill this struct before calling tai_ctx_init(). - * All pointer fields must remain valid for the lifetime of the context. + * All pointer fields except image_key must remain valid for the + * lifetime of the context. image_key is copied during construction. */ typedef struct tai_config { /* --- Server (from MQTT protocol-9000 or gateway API) --- */ @@ -282,7 +295,7 @@ typedef struct tai_config { uint64_t biz_tag; /* --- Cryptography --- */ - uint8_t sign_level; + uint8_t sign_level; /* ignored with image_key: keyed Frames are unsigned */ /* --- Keepalive (0 = default) --- */ uint32_t ping_interval_ms; /* 0 = default 60000 */ @@ -350,6 +363,30 @@ typedef struct tai_config { * SDK receive state. NULL means receive continuously. */ int (*on_flow_control)(tai_ctx_t *ctx, void *user_data); + /* Optional fixed AES-128 image key: exactly TAI_IMAGE_KEY_LEN + * decoded binary bytes, not Base64, a C string, local_key or an HKDF output. + * Non-NULL must reference at least 16 readable bytes; the application must + * validate the decoded length (this pointer API cannot check allocation size). + * Copied only on successful tai_ctx_init; caller storage is then releasable. + * An all-zero key is valid. The owned copy survives disconnect/failure and + * is wiped by tai_ctx_deinit. NULL preserves legacy behavior. + * Selects TuyaOpen's encrypted-media profile: Frame version 3, unsigned + * Frames, v2 Attribute layout, suite 00 || random[32] || 04 03 03. + * The suffix means AES-128-GCM, whole video I-frames, whole images; it is + * not a media-support bitmap. Only audio/text/image Data IDs are advertised; + * video/file handling is not implemented. No automatic key acquisition. + * Requires TLS (disable_tls is rejected by tai_connect before networking). + * A nonempty ca_cert or cert_bundle_attach verifies the peer; with neither, + * TLS still runs but the peer is unverified (as on the IoT path). + * protocol_version must be 0 or TAI_VER_21. */ + const uint8_t *image_key; + + /* Optional CA certificate (full PEM or bare Base64 body, not a file path). + * Borrowed for context lifetime. Nonempty (or cert_bundle_attach) enables + * required peer verification; with neither, the Connection still uses TLS + * but with the peer unverified (one warning logged at connect time). */ + const char *ca_cert; + } tai_config_t; /* ========================================================================= @@ -358,10 +395,15 @@ typedef struct tai_config { size_t tai_ctx_size(void); tai_ctx_t *tai_ctx_init(void *mem, const tai_config_t *cfg); +/* After tai_disconnect has stopped/joined any worker, release context resources + * and wipe the owned image key. Does not free the caller-owned context memory. */ void tai_ctx_deinit(tai_ctx_t *ctx); /* Connect: TLS handshake + ClientHello + SessionNew + start background thread. * The background thread handles receive polling and keepalive pings. + * With image_key, returns TAI_ERR_TLS before networking if disable_tls is set. + * A nonempty ca_cert or cert_bundle_attach requires peer verification; with + * neither, the Connection uses unverified TLS and logs a warning at connect. * Returns TAI_OK or TAI_ERR_*. */ int tai_connect(tai_ctx_t *ctx); diff --git a/modules/rtc-tcp-client/src/tai_client.c b/modules/rtc-tcp-client/src/tai_client.c index 4d9e216..22be87e 100644 --- a/modules/rtc-tcp-client/src/tai_client.c +++ b/modules/rtc-tcp-client/src/tai_client.c @@ -15,6 +15,7 @@ #include "tai_internal.h" #include "rng.h" +#include "mbedtls/platform_util.h" #include #include @@ -166,7 +167,7 @@ static int send_one_frame_sg(tai_ctx_t *ctx, uint8_t frag_flag, uint16_t seq, if (plen + ctx->sig_len > 0xFFFFU) return TAI_ERR_ARGS; uint8_t *head = ctx->tx_hdr_buf; /* [frame hdr 5][app hdr] */ - head[0] = (uint8_t)(((frag_flag & 0x03) << 6) | (0x02 << 4) | 0x02); + head[0] = (uint8_t)(((frag_flag & 0x03) << 6) | (0x02 << 4) | ctx->frame_ver); tai_w16(head + 1, seq); tai_w16(head + 3, (uint16_t)(plen + ctx->sig_len)); size_t head_len = TAI_FRAME_HDR_LEN + hdr_len; /* app hdr already in place */ @@ -321,10 +322,12 @@ tai_ctx_t *tai_ctx_init(void *mem, const tai_config_t *cfg) ctx->client_id = cfg->device_id; ctx->proto_ver = cfg->protocol_version ? cfg->protocol_version : TAI_VER_21; + ctx->frame_ver = cfg->image_key ? 3 : 2; ctx->client_type = cfg->client_type ? cfg->client_type : TAI_CLIENT_DEVICE; ctx->biz_code = cfg->biz_code ? cfg->biz_code : 65537U; ctx->biz_tag = cfg->biz_tag ? cfg->biz_tag : 119U; - ctx->sign_level = cfg->sign_level ? cfg->sign_level : TAI_SIGN_HMAC_SHA256; + ctx->sign_level = cfg->image_key ? TAI_SIGN_NONE + : cfg->sign_level ? cfg->sign_level : TAI_SIGN_HMAC_SHA256; switch (ctx->sign_level) { case TAI_SIGN_HMAC_SHA1: ctx->sig_len = 20; break; @@ -349,6 +352,7 @@ tai_ctx_t *tai_ctx_init(void *mem, const tai_config_t *cfg) ctx->connect_timeout_ms = cfg->connect_timeout_ms ? cfg->connect_timeout_ms : 5000U; ctx->disable_tls = cfg->disable_tls; ctx->cert_bundle_attach = cfg->cert_bundle_attach; + ctx->ca_cert = cfg->ca_cert; /* Initialise mutex */ ctx->mutex = cfg->pal->mutex_create(); @@ -365,6 +369,12 @@ tai_ctx_t *tai_ctx_init(void *mem, const tai_config_t *cfg) return NULL; } + if (cfg->image_key) { + memcpy(ctx->image_key, cfg->image_key, + TAI_IMAGE_KEY_LEN); + ctx->has_image_key = 1; + } + TAI_LOGI(ctx->pal, TAG, "ctx_init: proto=v%u client_type=%s sign_level=%s sig_len=%u", ctx->proto_ver, tai_client_type_name(ctx->client_type), @@ -388,6 +398,9 @@ void tai_ctx_deinit(tai_ctx_t *ctx) if (!ctx) return; ctx_io_close(ctx); if (ctx->mutex) { ctx->pal->mutex_destroy(ctx->mutex); ctx->mutex = NULL; } + mbedtls_platform_zeroize(ctx->image_key, sizeof(ctx->image_key)); + ctx->has_image_key = 0; + tai_proto_reset_image(ctx); } /* ========================================================================= @@ -409,9 +422,27 @@ static int tai_process_rx(tai_ctx_t *ctx, int *paused); int tai_connect(tai_ctx_t *ctx) { if (!ctx || !ctx->pal) return TAI_ERR_ARGS; + int has_trust = (ctx->ca_cert && ctx->ca_cert[0]) || ctx->cert_bundle_attach; + if (ctx->has_image_key) { + if (ctx->disable_tls) { + TAI_LOGE(ctx->pal, TAG, "keyed Connection requires TLS"); + return TAI_ERR_TLS; + } + /* TLS is still required, but with no CA or bundle the peer cannot be + * verified -- the same rule the IoT/MQTT path follows. Warn once and + * proceed: transport encryption holds, a man-in-the-middle does not. */ + if (!has_trust) + TAI_LOGW(ctx->pal, TAG, + "keyed Connection has no CA or cert bundle; peer verification disabled (MITM risk)"); + if (ctx->proto_ver != TAI_VER_21 || ctx->frame_ver != 3 || + ctx->sign_level != TAI_SIGN_NONE || ctx->sig_len != 0) + return TAI_ERR_PROTO; + } if (!ctx->host || ctx->port == 0) return TAI_ERR_ARGS; int rc; + tai_proto_reset_image(ctx); + ctx->rx_event_id[0] = '\0'; ctx->disconnect_emitted = 0; /* re-arm single-point on_disconnect for this connection */ TAI_LOGI(ctx->pal, TAG, "connecting to %s:%u", ctx->host, ctx->port); @@ -423,20 +454,22 @@ int tai_connect(tai_ctx_t *ctx) return TAI_ERR_CRYPTO; } - /* 2. Derive keys */ - const char *ikm = ctx->local_key ? ctx->local_key : ""; - size_t ikm_len = ctx->local_key ? strlen(ctx->local_key) : 0; + /* Keyed media uses the owned Image key and unsigned Frames, not HKDF keys. */ + if (!ctx->has_image_key) { + const char *ikm = ctx->local_key ? ctx->local_key : ""; + size_t ikm_len = ctx->local_key ? strlen(ctx->local_key) : 0; - rc = tai_crypto_derive_keys(ctx->proto_ver, - (const uint8_t *)ikm, ikm_len, - ctx->encrypt_random, 32, - ctx->encrypt_key, ctx->sign_key, - ctx->pal); - if (rc != TAI_OK) { - TAI_LOGE(ctx->pal, TAG, "key derivation failed: %d", rc); - return rc; + rc = tai_crypto_derive_keys(ctx->proto_ver, + (const uint8_t *)ikm, ikm_len, + ctx->encrypt_random, 32, + ctx->encrypt_key, ctx->sign_key, + ctx->pal); + if (rc != TAI_OK) { + TAI_LOGE(ctx->pal, TAG, "key derivation failed: %d", rc); + return rc; + } + TAI_LOGD(ctx->pal, TAG, "keys derived (ikm_len=%zu)", ikm_len); } - TAI_LOGD(ctx->pal, TAG, "keys derived (ikm_len=%zu)", ikm_len); /* 3. TLS connect (or raw TCP in test mode) */ if (ctx->disable_tls) { @@ -451,7 +484,12 @@ int tai_connect(tai_ctx_t *ctx) .host = ctx->host, .port = ctx->port, .sni = ctx->tls_sni, - .verify = TLS_VERIFY_OPTIONAL, /* fallback when no cert bundle */ + .cacert = ctx->ca_cert, + /* Keyed Connections verify the peer when a trust source exists, and + * run unverified (one WARN above) when none is configured. */ + .verify = ctx->has_image_key + ? (has_trust ? TLS_VERIFY_REQUIRED : TLS_VERIFY_NONE) + : TLS_VERIFY_OPTIONAL, .cert_bundle_attach = ctx->cert_bundle_attach, .connect_timeout_ms = ctx->connect_timeout_ms, /* bounds TCP connect + TLS handshake */ .pal = ctx->pal, @@ -464,9 +502,8 @@ int tai_connect(tai_ctx_t *ctx) TAI_LOGI(ctx->pal, TAG, "TLS connected"); } - /* 4. ClientHello — sent UNENCRYPTED (sig_len = 0). It is the one unsigned - * frame, so it can't go through send_app (which signs); it is small (no - * JSON) and one-shot, so frame it inline on the stack. */ + /* 4. ClientHello is always unsigned, including on signed legacy Connections. + * It is small (no JSON) and one-shot, so frame it inline on the stack. */ int app_len = tai_proto_build_client_hello(ctx, ctx->tx_ctrl_buf, sizeof(ctx->tx_ctrl_buf)); @@ -488,6 +525,8 @@ int tai_connect(tai_ctx_t *ctx) ctx->pal, ch_frame, sizeof(ch_frame)); if (frame_len < 0) { tai_disconnect(ctx); return frame_len; } + /* ClientHello has no signature, so selecting its wire nibble is safe here. */ + ch_frame[0] = (uint8_t)((0x02 << 4) | ctx->frame_ver); rc = ctx_io_send(ctx, ch_frame, (size_t)frame_len); if (rc != TAI_OK) { @@ -646,6 +685,7 @@ void tai_disconnect(tai_ctx_t *ctx) ctx->connecting = 0; /* clear in case a connect aborted mid-handshake */ ctx->disconnect_emitted = 0; /* re-arm the single-point on_disconnect */ ctx->rx_event_id[0] = '\0'; /* clear latched turn id so a reconnect starts clean */ + tai_proto_reset_image(ctx); ctx_unlock(ctx); } @@ -662,6 +702,27 @@ static int process_app_packet(tai_ctx_t *ctx, const uint8_t *payload; size_t payload_len; + if (ctx->has_image_key) { + if (app_len > sizeof(ctx->frag_buf)) return TAI_PROTO_ERR_FRAG; + /* The legacy decoder tolerates truncated TLVs and excess Attributes. + * Keyed identity/parameter checks must see the entire block. */ + if (app_len && (app_bytes[0] & 1)) { + if (app_len < 5) return TAI_PROTO_ERR_PKT_DECODE; + size_t block_len = tai_r32(app_bytes + 1); + if (block_len > app_len - 5) return TAI_PROTO_ERR_PKT_DECODE; + size_t pos = 5, end = 5 + block_len; + int count = 0; + while (pos < end) { + if (end - pos < 4 || count == AGENTIC_KIT_TAI_MAX_ATTRS) + return TAI_PROTO_ERR_PKT_DECODE; + size_t len = tai_r16(app_bytes + pos + 2); + pos += 4; + if (len > end - pos) return TAI_PROTO_ERR_PKT_DECODE; + pos += len; + count++; + } + } + } int rc = tai_packet_decode(ctx->proto_ver, app_bytes, app_len, &pkt_type, @@ -767,8 +828,20 @@ static int tai_process_rx(tai_ctx_t *ctx, int *paused) continue; } - /* Detect version from first byte */ - int ver = tai_frame_detect_version(ctx->rx_buf[0]); + /* Detect version from first byte. Version 3 keeps the v2.1 Attribute and + * Frame layout, and the cloud answers with version 2 for its own + * control/acknowledgement Frames -- only media is version 3. TuyaOpen + * never validates this nibble on receive, so a keyed Connection accepts + * either v2.1 version rather than rejecting the handshake. The layout is + * identical and the profile is pinned by sig_len = 0, not this byte, so + * accepting version 2 never selects the legacy signed read. */ + int ver; + if (ctx->has_image_key) { + uint8_t nibble = (uint8_t)(ctx->rx_buf[0] & 0x0F); + ver = (nibble == 0x02 || nibble == 0x03) ? TAI_VER_21 : TAI_ERR_PROTO; + } else { + ver = tai_frame_detect_version(ctx->rx_buf[0]); + } if (ver < 0) { TAI_LOGW(ctx->pal, TAG, "unknown frame byte 0x%02x", ctx->rx_buf[0]); return TAI_PROTO_ERR_BAD_VERSION; @@ -1061,6 +1134,44 @@ int tai_send_audio_end(tai_ctx_t *ctx) /* ========================================================================= * tai_send_image * ========================================================================= */ +typedef struct { + const uint8_t *data; + size_t len; + uint8_t *scratch; +} prepared_image_t; + +/* Reserve the maximum header span before any allocation or EventStart. + * Outbound images are not limited by the receive reassembly buffer. */ +static int prepare_image(tai_ctx_t *ctx, const uint8_t *data, size_t len, + prepared_image_t *image) +{ + size_t extra = ctx->has_image_key ? (len ? 32 : 16) : 0; + size_t overhead = sizeof(ctx->tx_hdr_buf) + extra; + if (len > SIZE_MAX - overhead || len > PTRDIFF_MAX - overhead) + return TAI_ERR_ARGS; + image->data = data; + image->len = len; + if (!ctx->has_image_key) return TAI_OK; + + image->len += extra; + image->scratch = ctx->pal->malloc(image->len); + if (!image->scratch) return TAI_ERR_MEM; + image->data = image->scratch; + if (tai_random_bytes(ctx->pal, image->scratch, 16) != 0) + return TAI_ERR_CRYPTO; + if (!len) return TAI_OK; /* Reference empty marker: IV only, no tag. */ + return tai_image_gcm_encrypt(ctx->image_key, image->scratch, + data, len, image->scratch + 16, len + 16); +} + +static void release_image(tai_ctx_t *ctx, prepared_image_t *image) +{ + if (image->scratch) { + mbedtls_platform_zeroize(image->scratch, image->len); + ctx->pal->free(image->scratch); + } +} + static int send_image_locked(tai_ctx_t *ctx, const uint8_t *data, size_t len, uint8_t format, uint16_t width, uint16_t height) @@ -1109,7 +1220,11 @@ int tai_send_image(tai_ctx_t *ctx, TAI_LOGI(ctx->pal, TAG, "send_image: %zu bytes fmt=%u %ux%u", len, format, width, height); ctx_lock(ctx); - int rc = send_image_locked(ctx, data, len, format, width, height); + prepared_image_t image = {0}; + int rc = prepare_image(ctx, data, len, &image); + if (rc == TAI_OK) + rc = send_image_locked(ctx, image.data, image.len, format, width, height); + release_image(ctx, &image); ctx_unlock(ctx); return rc; } @@ -1178,11 +1293,17 @@ int tai_send_image_with_text(tai_ctx_t *ctx, uint16_t width, uint16_t height) { if (!ctx || !ctx->connected || !text || !img_data) return TAI_ERR_ARGS; + if (text_len > SIZE_MAX - sizeof(ctx->tx_hdr_buf) || + text_len > PTRDIFF_MAX - sizeof(ctx->tx_hdr_buf)) return TAI_ERR_ARGS; TAI_LOGI(ctx->pal, TAG, "send_image_with_text: text=%zu img=%zu bytes", text_len, img_len); ctx_lock(ctx); - int rc = send_image_with_text_locked(ctx, text, text_len, - img_data, img_len, format, width, height); + prepared_image_t image = {0}; + int rc = prepare_image(ctx, img_data, img_len, &image); + if (rc == TAI_OK) + rc = send_image_with_text_locked(ctx, text, text_len, + image.data, image.len, format, width, height); + release_image(ctx, &image); ctx_unlock(ctx); return rc; } @@ -1203,24 +1324,27 @@ int tai_send_image_audio_start(tai_ctx_t *ctx, img_len, width, height, codec); ctx_lock(ctx); - int app_len, hdr_len, rc; + prepared_image_t image = {0}; + int app_len, hdr_len; + int rc = prepare_image(ctx, img_data, img_len, &image); + if (rc != TAI_OK) goto done; /* EventStart (control) */ app_len = tai_proto_build_event_start(ctx, ctx->tx_ctrl_buf, sizeof(ctx->tx_ctrl_buf)); - if (app_len < 0) { ctx_unlock(ctx); return app_len; } + if (app_len < 0) { rc = app_len; goto done; } rc = send_app(ctx, ctx->tx_ctrl_buf, (size_t)app_len); - if (rc != TAI_OK) { ctx_unlock(ctx); return rc; } + if (rc != TAI_OK) goto done; ctx->event_open = 1; - /* Image OneShot (scatter-gather: small header + zero-copy image payload) */ + /* Image OneShot (scatter-gather: header + prepared image payload) */ hdr_len = tai_proto_build_image_hdr(ctx, TAI_DATA_ID_IMAGE_UP, TAI_STREAM_ONE_SHOT, img_format, width, height, ctx->tx_hdr_buf + TAI_FRAME_HDR_LEN, sizeof(ctx->tx_hdr_buf) - TAI_FRAME_HDR_LEN); - if (hdr_len < 0) { ctx_unlock(ctx); return hdr_len; } - rc = send_app_sg(ctx, (size_t)hdr_len, img_data, img_len); - if (rc != TAI_OK) { ctx_unlock(ctx); return rc; } + if (hdr_len < 0) { rc = hdr_len; goto done; } + rc = send_app_sg(ctx, (size_t)hdr_len, image.data, image.len); + if (rc != TAI_OK) goto done; /* Audio params stored here; the first tai_send_audio_chunk emits START. * The event stays open until tai_send_image_audio_end (audio END + @@ -1231,8 +1355,10 @@ int tai_send_image_audio_start(tai_ctx_t *ctx, ctx->audio_sample_rate = sample_rate; ctx->audio_started = 0; +done: + release_image(ctx, &image); ctx_unlock(ctx); - return TAI_OK; + return rc; } int tai_send_image_audio_chunk(tai_ctx_t *ctx, const uint8_t *pcm, size_t len) @@ -1446,6 +1572,7 @@ static void *worker_thread(void *arg) * decided a fatal cause — a transport fault above, or a fail-fast / * CONNECTION_CLOSE returned up from dispatch. A clean tai_disconnect / * tai_request_disconnect leaves f_reason==0xFF and fires nothing. */ + tai_proto_reset_image(ctx); if (f_reason != 0xFF) { ctx->connected = 0; ctx->running = 0; diff --git a/modules/rtc-tcp-client/src/tai_crypto.c b/modules/rtc-tcp-client/src/tai_crypto.c index 767dc7e..02ff413 100644 --- a/modules/rtc-tcp-client/src/tai_crypto.c +++ b/modules/rtc-tcp-client/src/tai_crypto.c @@ -10,6 +10,8 @@ #include "mbedtls/md.h" #include "mbedtls/hkdf.h" +#include "mbedtls/gcm.h" +#include "mbedtls/platform_util.h" #include "rng.h" @@ -42,6 +44,72 @@ int tai_hkdf_sha256(const uint8_t *ikm, size_t ikm_len, out, out_len) == 0 ? 0 : -1; } +static int buffers_overlap(const void *a, size_t a_len, + const void *b, size_t b_len) +{ + uintptr_t x = (uintptr_t)a, y = (uintptr_t)b; + if (!a_len || !b_len) return 0; + return x <= y ? y - x < a_len : x - y < b_len; +} + +int tai_image_gcm_encrypt(const uint8_t key[TAI_IMAGE_KEY_LEN], + const uint8_t iv[16], + const uint8_t *data, size_t len, + uint8_t *out, size_t out_size) +{ + if (!key || !iv || !data || !out || + len > SIZE_MAX - 16 || len > PTRDIFF_MAX - 16) + return TAI_ERR_ARGS; + size_t body_len = len + 16; + if (out_size < body_len) return TAI_ERR_MEM; + if (buffers_overlap(out, body_len, data, len) || + buffers_overlap(out, body_len, key, TAI_IMAGE_KEY_LEN) || + buffers_overlap(out, body_len, iv, 16)) + return TAI_ERR_ARGS; + + mbedtls_gcm_context gcm; + mbedtls_gcm_init(&gcm); + int rc = mbedtls_gcm_setkey(&gcm, MBEDTLS_CIPHER_ID_AES, key, 128); + if (rc == 0) + rc = mbedtls_gcm_crypt_and_tag(&gcm, MBEDTLS_GCM_ENCRYPT, len, + iv, 16, NULL, 0, data, out, + 16, out + len); + mbedtls_gcm_free(&gcm); + if (rc != 0) { + mbedtls_platform_zeroize(out, body_len); + return TAI_ERR_CRYPTO; + } + return TAI_OK; +} + +int tai_image_gcm_decrypt(const uint8_t key[TAI_IMAGE_KEY_LEN], + const uint8_t iv[16], + const uint8_t *body, size_t body_len, + uint8_t *out, size_t out_size) +{ + if (!key || !iv || !body || !out || body_len < 16) + return TAI_ERR_ARGS; + size_t len = body_len - 16; + if (out_size < len) return TAI_ERR_MEM; + if (buffers_overlap(out, len, body, body_len) || + buffers_overlap(out, len, key, TAI_IMAGE_KEY_LEN) || + buffers_overlap(out, len, iv, 16)) + return TAI_ERR_ARGS; + + mbedtls_gcm_context gcm; + mbedtls_gcm_init(&gcm); + int rc = mbedtls_gcm_setkey(&gcm, MBEDTLS_CIPHER_ID_AES, key, 128); + if (rc == 0) + rc = mbedtls_gcm_auth_decrypt(&gcm, len, iv, 16, NULL, 0, + body + len, 16, body, out); + mbedtls_gcm_free(&gcm); + if (rc != 0) { + mbedtls_platform_zeroize(out, len); + return TAI_ERR_CRYPTO; + } + return TAI_OK; +} + /* ========================================================================= * tai_random_bytes * ========================================================================= */ diff --git a/modules/rtc-tcp-client/src/tai_internal.h b/modules/rtc-tcp-client/src/tai_internal.h index 062a90d..b31b24c 100644 --- a/modules/rtc-tcp-client/src/tai_internal.h +++ b/modules/rtc-tcp-client/src/tai_internal.h @@ -177,7 +177,8 @@ struct tai_ctx { const char *agent_token; /* Protocol configuration */ - uint8_t proto_ver; /* TAI_VER_21 (reserved for future) */ + uint8_t proto_ver; /* Application/Attribute layout: TAI_VER_21 */ + uint8_t frame_ver; /* Wire nibble: 2 legacy, 3 keyed media */ uint8_t client_type; /* TAI_CLIENT_DEVICE / TAI_CLIENT_APP */ uint32_t biz_code; uint64_t biz_tag; @@ -185,6 +186,7 @@ struct tai_ctx { uint8_t sig_len; /* 0, 20, or 32 */ uint8_t disable_tls; /* 1 = raw TCP (testing only) */ tls_cert_bundle_attach_fn cert_bundle_attach; /* platform cert-bundle callback */ + const char *ca_cert; /* borrowed CA certificate */ /* Session state */ uint8_t connected; @@ -219,11 +221,21 @@ struct tai_ctx { /* Received turn id (attr 61) backing callback msg->event_id; "" if none */ char rx_event_id[64]; + /* Worker-owned keyed Image Stream identity and validated params. */ + uint8_t rx_image_active; + uint16_t rx_image_data_id; + uint16_t rx_image_params[4]; /* payload type, format, width, height */ + char rx_image_event_id[64]; + /* Crypto material */ uint8_t encrypt_random[32]; uint8_t encrypt_key[32]; uint8_t sign_key[32]; + /* Fixed application-supplied image key, independent of Frame keys. */ + uint8_t image_key[TAI_IMAGE_KEY_LEN]; + uint8_t has_image_key; + /* TLS handle (owned by the shared common/tls module) */ tls_t *tls; @@ -450,6 +462,27 @@ int tai_hmac_sha256(const uint8_t *key, size_t key_len, int tai_hkdf_sha256(const uint8_t *ikm, size_t ikm_len, const uint8_t *salt, size_t salt_len, uint8_t *out, size_t out_len); +/* Encrypt into ciphertext || tag with AES-128-GCM, a 16-byte IV/tag and no + * AAD. All pointers are required; out needs len + 16 bytes and must not + * overlap data, key or IV. Argument/overlap/overflow errors return + * TAI_ERR_ARGS, insufficient capacity TAI_ERR_MEM, without writing. + * TAI_ERR_CRYPTO wipes len + 16 bytes. Empty input produces a tag here; + * the send path handles the reference's IV-only empty image separately. */ +int tai_image_gcm_encrypt(const uint8_t key[TAI_IMAGE_KEY_LEN], + const uint8_t iv[16], + const uint8_t *data, size_t len, + uint8_t *out, size_t out_size); +/* Authenticate ciphertext || tag with AES-128-GCM, a 16-byte IV/tag and no + * AAD. All pointers are required, even for a tag-only body (empty plaintext). + * out must have body_len - 16 bytes and must not overlap body, key or IV. + * TAI_OK writes exactly body_len - 16 bytes; TAI_ERR_CRYPTO wipes that span. + * Invalid arguments/overlap/short body return TAI_ERR_ARGS without writing; + * insufficient output capacity returns TAI_ERR_MEM without writing. + * This primitive does not establish image wire-format or empty-marker rules. */ +int tai_image_gcm_decrypt(const uint8_t key[TAI_IMAGE_KEY_LEN], + const uint8_t iv[16], + const uint8_t *body, size_t body_len, + uint8_t *out, size_t out_size); int tai_random_bytes(const pal_t *pal, uint8_t *buf, size_t len); int tai_crypto_derive_keys(uint8_t proto_ver, const uint8_t *ikm, size_t ikm_len, @@ -517,6 +550,9 @@ int tai_proto_dispatch (tai_ctx_t *ctx, /* Worker-only: returns 1 while delivery remains paused, 0 once finished. * The Packet's bytes stay pinned in rx_buf / frag_buf until it drains. */ int tai_proto_drain_pending_audio(tai_ctx_t *ctx); +/* Clear receive Image Stream state, not the fixed key or shared Event ID. + * Call when resetting receive state on disconnect / failed Connection. */ +void tai_proto_reset_image(tai_ctx_t *ctx); /* Admission query — the single judgment behind receive backpressure, shared * by the four checkpoints (worker pass top, before each drain read, between diff --git a/modules/rtc-tcp-client/src/tai_protocol.c b/modules/rtc-tcp-client/src/tai_protocol.c index 960929c..e4abb6a 100644 --- a/modules/rtc-tcp-client/src/tai_protocol.c +++ b/modules/rtc-tcp-client/src/tai_protocol.c @@ -14,6 +14,8 @@ #include /* snprintf */ #include "tai_internal.h" +#include "mbedtls/base64.h" +#include "mbedtls/platform_util.h" #define TAG "proto" @@ -37,6 +39,12 @@ static const char TAI_DEFAULT_EVENT_USER_DATA[] = static const uint8_t TAI_SESSION_PAYLOAD[] = { 0,8, 0,1, 0,3, 0,5, 0,7, 0,4, 0,2, 0,4 }; +/* TuyaOpen allocates odd send IDs and even receive IDs; these lists contain + * IDs, not packet types. Keyed Sessions expose only audio, text and image. */ +static const uint8_t TAI_KEYED_SESSION_PAYLOAD[] = { + 0,6, 0,TAI_DATA_ID_AUDIO_UP, 0,TAI_DATA_ID_TEXT_UP, 0,TAI_DATA_ID_IMAGE_UP, + 0,6, 0,TAI_DATA_ID_AUDIO_DOWN, 0,TAI_DATA_ID_TEXT_DOWN, 0,TAI_DATA_ID_IMAGE_DOWN +}; /* ========================================================================= * Build a UserData (attr 111) JSON wrapper. @@ -111,10 +119,18 @@ static int gen_id(tai_ctx_t *ctx, const char *prefix, int tai_proto_build_client_hello(tai_ctx_t *ctx, uint8_t *buf, size_t buf_size) { - /* Build security-suit: 1 byte sign_level + 32 bytes encrypt_random */ - uint8_t security_suit[33]; + uint8_t security_suit[36]; + uint16_t suit_len = ctx->has_image_key ? 36 : 33; security_suit[0] = ctx->sign_level; memcpy(security_suit + 1, ctx->encrypt_random, 32); + if (ctx->has_image_key) { + /* TuyaOpen AI_SUB_VERSION=2: AES-128-GCM, all video I-frame bytes, + * all image bytes. These are encryption ranges, not support flags. + * No video/file Data IDs are advertised by our keyed Session. */ + security_suit[33] = 4; + security_suit[34] = 3; + security_suit[35] = 3; + } uint8_t s_ctype[1], s_ping[4], s_maxfrag[4]; tai_attr_t attrs[5]; @@ -123,7 +139,7 @@ int tai_proto_build_client_hello(tai_ctx_t *ctx, attrs[na++] = tai_attr_u8v(TAI_ATTR_CLIENT_TYPE, s_ctype, ctx->client_type); if (ctx->client_id && ctx->client_id[0]) attrs[na++] = tai_attr_strv(TAI_ATTR_CLIENT_ID, ctx->client_id); - attrs[na++] = tai_attr_bytesv(TAI_ATTR_SECURITY_SUIT, security_suit, 33); + attrs[na++] = tai_attr_bytesv(TAI_ATTR_SECURITY_SUIT, security_suit, suit_len); attrs[na++] = tai_attr_u32v(TAI_ATTR_MAX_FRAGMENT_LEN, s_maxfrag, AGENTIC_KIT_TAI_MAX_FRAGMENT_PAYLOAD); attrs[na++] = tai_attr_u32v(TAI_ATTR_PING_INTERVAL, s_ping, @@ -171,7 +187,8 @@ int tai_proto_build_session_new(tai_ctx_t *ctx, int rc = tai_packet_encode(ctx->proto_ver, TAI_PKT_SESSION_NEW, attrs, na, - TAI_SESSION_PAYLOAD, sizeof(TAI_SESSION_PAYLOAD), + ctx->has_image_key ? TAI_KEYED_SESSION_PAYLOAD : TAI_SESSION_PAYLOAD, + ctx->has_image_key ? sizeof(TAI_KEYED_SESSION_PAYLOAD) : sizeof(TAI_SESSION_PAYLOAD), buf, buf_size); ctx->pal->free(ud_json); return rc; @@ -471,6 +488,14 @@ int tai_proto_build_mcp_hdr(tai_ctx_t *ctx, uint8_t *buf, size_t buf_size) * Decode the payload of a received application packet and call the * appropriate callback on the context. * ========================================================================= */ +void tai_proto_reset_image(tai_ctx_t *ctx) +{ + ctx->rx_image_active = 0; + ctx->rx_image_data_id = 0; + memset(ctx->rx_image_params, 0, sizeof(ctx->rx_image_params)); + memset(ctx->rx_image_event_id, 0, sizeof(ctx->rx_image_event_id)); +} + /* Latch the turn id (attr 61) into ctx->rx_event_id to back msg->event_id. * A packet that carries attr 61 updates it; a packet WITHOUT one INHERITS the * current value (it is NOT reset to ""). The EVENT case clears it right after @@ -483,6 +508,10 @@ static void latch_event_id(tai_ctx_t *ctx, if (e && e->len > 0) { size_t n = e->len < sizeof(ctx->rx_event_id) - 1 ? e->len : sizeof(ctx->rx_event_id) - 1; + if (ctx->has_image_key && ctx->rx_image_active && + (strlen(ctx->rx_image_event_id) != n || + memcmp(ctx->rx_image_event_id, e->value, n) != 0)) + tai_proto_reset_image(ctx); memcpy(ctx->rx_event_id, e->value, n); ctx->rx_event_id[n] = '\0'; } @@ -539,13 +568,14 @@ static void emit_event(tai_ctx_t *ctx, uint16_t event_type, } static void emit_image(tai_ctx_t *ctx, const uint8_t *data, size_t len, - uint8_t format, uint16_t width, uint16_t height, + uint8_t payload_type, uint8_t format, uint16_t width, uint16_t height, uint8_t stream_flag, uint16_t data_id, uint64_t ts_ms) { if (!ctx->on_image) return; tai_image_msg_t m = {0}; m.data = data; m.len = len; + m.payload_type = payload_type; m.format = format; m.width = width; m.height = height; @@ -700,6 +730,171 @@ static int media_text(tai_ctx_t *ctx, return TAI_OK; } +/* Exactly four unsigned decimal fields, bounded by their destination types. */ +static int parse_image_params(const tai_attr_t *a, uint16_t fields[4]) +{ + const uint32_t limits[4] = {TAI_IMG_PAYLOAD_URL, UINT8_MAX, UINT16_MAX, UINT16_MAX}; + size_t pos = 0; + for (int i = 0; i < 4; i++) { + while (pos < a->len && a->value[pos] == ' ') pos++; + if (pos == a->len || a->value[pos] < '0' || a->value[pos] > '9') + return TAI_PROTO_ERR_PKT_DECODE; + uint32_t v = 0; + do { + unsigned digit = a->value[pos++] - '0'; + if (digit > limits[i] || v > (limits[i] - digit) / 10) + return TAI_PROTO_ERR_PKT_DECODE; + v = v * 10 + digit; + } while (pos < a->len && a->value[pos] >= '0' && a->value[pos] <= '9'); + fields[i] = (uint16_t)v; + if (pos < a->len && a->value[pos] != ' ') + return TAI_PROTO_ERR_PKT_DECODE; + } + while (pos < a->len && a->value[pos] == ' ') pos++; + return pos == a->len ? TAI_OK : TAI_PROTO_ERR_PKT_DECODE; +} + +static int image_base64_digit(uint8_t c) +{ + 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; +} + +/* mbedTLS accepts whitespace and incomplete quartets; require canonical Base64. */ +static int image_base64_size(const uint8_t *body, size_t len, size_t *decoded) +{ + if (!len || len % 4) return TAI_PROTO_ERR_PKT_DECODE; + size_t pad = (body[len - 1] == '=') + (body[len - 2] == '='); + for (size_t i = 0; i < len - pad; i++) + if (image_base64_digit(body[i]) < 0) return TAI_PROTO_ERR_PKT_DECODE; + for (size_t i = len - pad; i < len; i++) + if (body[i] != '=') return TAI_PROTO_ERR_PKT_DECODE; + int last = image_base64_digit(body[len - pad - 1]); + if ((pad == 1 && (last & 3)) || (pad == 2 && (last & 15))) + return TAI_PROTO_ERR_PKT_DECODE; + *decoded = len / 4 * 3 - pad; + return TAI_OK; +} + +/* TuyaOpen sub-version 2: media header (8), IV (16), ciphertext || tag. + * Each complete Packet is authenticated separately, after transport reassembly. */ +static int media_image_keyed(tai_ctx_t *ctx, + const tai_attr_t *attrs, int attr_count, + const uint8_t *payload, size_t payload_len) +{ + /* Bound scratch by the complete Packet ceiling. An empty attr block is + * indistinguishable from omission here; process_app_packet checks its exact size. */ + size_t room = AGENTIC_KIT_TAI_FRAG_BUF_SIZE; + size_t overhead = 1 + (attr_count ? 4 : 0); + if (overhead > room) return TAI_PROTO_ERR_FRAG; + room -= overhead; + const tai_attr_t *ip = NULL; + for (int i = 0; i < attr_count; i++) { + size_t n = 4U + attrs[i].len; + if (n > room) return TAI_PROTO_ERR_FRAG; + room -= n; + if (attrs[i].type == TAI_ATTR_IMAGE_PARAMS) { + if (ip) return TAI_PROTO_ERR_PKT_DECODE; + ip = &attrs[i]; + } + } + if (payload_len > room) return TAI_PROTO_ERR_FRAG; + if (payload_len < 24) return TAI_PROTO_ERR_MEDIA_HDR; + uint16_t data_id; uint8_t flag; uint64_t ts; + tai_unpack_media_hdr(payload, 8, &data_id, &flag, &ts); + if (data_id != TAI_DATA_ID_IMAGE_DOWN) return TAI_PROTO_ERR_UNEXPECTED; + uint16_t fields[4] = {0}; + if (ip && parse_image_params(ip, fields) != TAI_OK) + return TAI_PROTO_ERR_PKT_DECODE; + + const tai_attr_t *eid = tai_attr_find(attrs, attr_count, TAI_ATTR_EVENT_ID); + const uint8_t *event = eid ? eid->value : (const uint8_t *)ctx->rx_event_id; + size_t event_len = eid ? eid->len : strlen(ctx->rx_event_id); + int start = flag == TAI_STREAM_START || flag == TAI_STREAM_ONE_SHOT; + uint8_t type; + if (start) { + if (!ip) return TAI_PROTO_ERR_PKT_DECODE; + type = (uint8_t)fields[0]; + } else { + if (!ctx->rx_image_active || ctx->rx_image_data_id != data_id || + strlen(ctx->rx_image_event_id) != event_len || + memcmp(ctx->rx_image_event_id, event, event_len) != 0 || + (ip && memcmp(fields, ctx->rx_image_params, sizeof(fields)) != 0)) + return TAI_PROTO_ERR_UNEXPECTED; + type = (uint8_t)ctx->rx_image_params[0]; + } + + const uint8_t *body = payload + 24; + size_t body_len = payload_len - 24; + uint8_t *decoded = NULL, *plain = NULL; + size_t decoded_size = 0, plain_size = 0; + int rc = TAI_OK; + if (!body_len) { + /* Reference empty sends contain an IV but no GCM tag. */ + if (flag == TAI_STREAM_MIDDLE) return TAI_PROTO_ERR_PKT_DECODE; + } else if (type != TAI_IMG_PAYLOAD_URL) { + if (type == TAI_IMG_PAYLOAD_BASE64) { + rc = image_base64_size(body, body_len, &decoded_size); + if (rc != TAI_OK) return rc; + if (decoded_size < 16) return TAI_PROTO_ERR_PKT_DECODE; + decoded = ctx->pal->malloc(decoded_size); + if (!decoded) return TAI_PROTO_ERR_IMAGE_PROCESSING; + size_t n = 0; + if (mbedtls_base64_decode(decoded, decoded_size, &n, body, body_len) != 0 || + n != decoded_size) { + rc = TAI_PROTO_ERR_PKT_DECODE; + goto done; + } + body = decoded; + body_len = n; + } + if (body_len < 16) { + rc = TAI_PROTO_ERR_PKT_DECODE; + goto done; + } + plain_size = body_len > 16 ? body_len - 16 : 1; + plain = ctx->pal->malloc(plain_size); + if (!plain) { + rc = TAI_PROTO_ERR_IMAGE_PROCESSING; + goto done; + } + rc = tai_image_gcm_decrypt(ctx->image_key, payload + 8, + body, body_len, plain, plain_size); + if (rc != TAI_OK) { + rc = rc == TAI_ERR_CRYPTO ? TAI_PROTO_ERR_IMAGE_AUTH + : TAI_PROTO_ERR_IMAGE_PROCESSING; + goto done; + } + body = plain; + body_len -= 16; + } + + /* Validate identity before latching, and commit Stream state only on success. */ + latch_event_id(ctx, attrs, attr_count); + if (start) { + ctx->rx_image_active = 1; + ctx->rx_image_data_id = data_id; + memcpy(ctx->rx_image_params, fields, sizeof(fields)); + memcpy(ctx->rx_image_event_id, ctx->rx_event_id, sizeof(ctx->rx_image_event_id)); + } + emit_image(ctx, body, body_len, + type == TAI_IMG_PAYLOAD_URL ? TAI_IMG_PAYLOAD_URL : TAI_IMG_PAYLOAD_RAW, + (uint8_t)fields[1], fields[2], fields[3], flag, data_id, ts); + if (flag == TAI_STREAM_ONE_SHOT || flag == TAI_STREAM_END) + tai_proto_reset_image(ctx); +done: + if (plain) { + mbedtls_platform_zeroize(plain, plain_size); + ctx->pal->free(plain); + } + if (decoded) ctx->pal->free(decoded); + return rc; +} + /* IMAGE packet payload: [data_id:2][48-bit stream_flag|ts_ms][image bytes…]. * A received image is delivered chunk-by-chunk (the caller reassembles by * stream_flag). image-params (attr 90) on START/ONE_SHOT carries @@ -708,6 +903,11 @@ static int media_image(tai_ctx_t *ctx, const tai_attr_t *attrs, int attr_count, const uint8_t *payload, size_t payload_len) { + if (ctx->has_image_key) { + int rc = media_image_keyed(ctx, attrs, attr_count, payload, payload_len); + if (rc != TAI_OK) tai_proto_reset_image(ctx); + return rc; + } if (payload_len < 8) { TAI_LOGW(ctx->pal, TAG, "image media header truncated (%zu < 8)", payload_len); return TAI_PROTO_ERR_MEDIA_HDR; @@ -715,7 +915,7 @@ static int media_image(tai_ctx_t *ctx, uint16_t data_id = 0; uint8_t stream_flag = 0; uint64_t ts_ms = 0; tai_unpack_media_hdr(payload, 8, &data_id, &stream_flag, &ts_ms); - uint8_t format = 0; uint16_t width = 0, height = 0; + uint8_t type = 0, format = 0; uint16_t width = 0, height = 0; const tai_attr_t *ip = tai_attr_find(attrs, attr_count, TAI_ATTR_IMAGE_PARAMS); if (ip && ip->len > 0) { char tmp[64]; @@ -724,13 +924,14 @@ static int media_image(tai_ctx_t *ctx, tmp[cplen] = '\0'; unsigned f[4] = {0}; sscanf(tmp, "%u %u %u %u", &f[0], &f[1], &f[2], &f[3]); + type = (uint8_t)f[0]; format = (uint8_t)f[1]; /* f[0]=payload_type, f[1]=format, f[2]=w, f[3]=h */ width = (uint16_t)f[2]; height = (uint16_t)f[3]; } latch_event_id(ctx, attrs, attr_count); - emit_image(ctx, payload + 8, payload_len - 8, format, width, height, + emit_image(ctx, payload + 8, payload_len - 8, type, format, width, height, stream_flag, data_id, ts_ms); return TAI_OK; } @@ -740,6 +941,32 @@ int tai_proto_dispatch(tai_ctx_t *ctx, const tai_attr_t *attrs, int attr_count, const uint8_t *payload, size_t payload_len) { + /* Never let a truncated/ambiguous Event ID enter the shared latch and later + * appear to match a keyed Image Stream that omitted its Attributes. */ + if (ctx->has_image_key) { + /* Control Packets have no body. A legacy signature trailer is not + * application data and must not silently pass the connect handshake. */ + if (payload_len && (pkt_type == TAI_PKT_AUTHENTICATE_RESPONSE || + pkt_type == TAI_PKT_SESSION_NEW || + pkt_type == TAI_PKT_PONG || + pkt_type == TAI_PKT_CONNECTION_CLOSE || + pkt_type == TAI_PKT_SESSION_CLOSE)) + return TAI_PROTO_ERR_PKT_DECODE; + int seen = 0; + for (int i = 0; i < attr_count; i++) { + if (attrs[i].type == TAI_ATTR_SECURITY_SUIT && + (attrs[i].len != 36 || attrs[i].value[0] != TAI_SIGN_NONE || + attrs[i].value[33] != 4 || attrs[i].value[34] != 3 || + attrs[i].value[35] != 3)) + return TAI_PROTO_ERR_UNEXPECTED; + if (attrs[i].type != TAI_ATTR_EVENT_ID) continue; + if (seen++ || !attrs[i].len || attrs[i].len >= sizeof(ctx->rx_event_id) || + memchr(attrs[i].value, '\0', attrs[i].len)) { + tai_proto_reset_image(ctx); + return TAI_PROTO_ERR_PKT_DECODE; + } + } + } switch (pkt_type) { case TAI_PKT_PONG: @@ -780,6 +1007,9 @@ int tai_proto_dispatch(tai_ctx_t *ctx, if (evt_type == TAI_EVT_END) { ctx->event_open = 0; } + if (evt_type == TAI_EVT_START || evt_type == TAI_EVT_ONE_SHOT || + evt_type == TAI_EVT_END || evt_type == TAI_EVT_CHAT_BREAK) + tai_proto_reset_image(ctx); latch_event_id(ctx, attrs, attr_count); emit_event(ctx, evt_type, evt_data, evt_data_len, @@ -857,6 +1087,8 @@ int tai_proto_dispatch(tai_ctx_t *ctx, "SESSION_CLOSE from server: session=%s code=%u", sid_s, code); ctx->session_open = 0; + tai_proto_reset_image(ctx); + if (ctx->has_image_key) ctx->rx_event_id[0] = '\0'; tai_emit_disconnect(ctx, TAI_DISCONNECT_SESSION_CLOSE, 0, code); break; } diff --git a/modules/rtc-tcp-client/test/image_generation_demo_test.c b/modules/rtc-tcp-client/test/image_generation_demo_test.c new file mode 100644 index 0000000..22e260e --- /dev/null +++ b/modules/rtc-tcp-client/test/image_generation_demo_test.c @@ -0,0 +1,749 @@ +/* Exercise the demo translation unit, not a second implementation. All SDK + * bootstrap/receive traffic is synthetic; no subprocess or cloud credentials. */ +#include +#include +#include +#include +#include +#include +#include +#include "iot_atop.h" +#include "tuya_ai.h" + +static int fake_init_default(void); +static iot_client_t *fake_iot_init(const iot_client_config_t *); +static void fake_iot_deinit(iot_client_t *); +static int fake_token(iot_client_t *, const char *, char *, size_t, iot_atop_rejection_t *); +static int fake_atop(iot_client_t *, const iot_atop_request_t *, iot_atop_response_t *); +static void fake_response_free(iot_client_t *, iot_atop_response_t *); +static tai_ctx_t *fake_ctx_init(void *, const tai_config_t *); +static int fake_connect(tai_ctx_t *); +static int fake_send(tai_ctx_t *, const char *, size_t); +static void fake_request_disconnect(tai_ctx_t *); +static void fake_disconnect(tai_ctx_t *); +static void fake_ctx_deinit(tai_ctx_t *); +static void *fake_realloc(void *, size_t); +static ssize_t fake_write(int, const void *, size_t); +static int fake_close(int); +static int fake_link(const char *, const char *); + +#define iot_init_default fake_init_default +#define iot_client_init fake_iot_init +#define iot_client_deinit fake_iot_deinit +#define iot_client_get_session_token_ex fake_token +#define iot_atop_call fake_atop +#define iot_atop_response_free fake_response_free +#define tai_ctx_init fake_ctx_init +#define tai_connect fake_connect +#define tai_send_text fake_send +#define tai_request_disconnect fake_request_disconnect +#define tai_disconnect fake_disconnect +#define tai_ctx_deinit fake_ctx_deinit +#define realloc fake_realloc +#define write fake_write +#define close fake_close +#define link fake_link +#define main image_generation_demo_main +#include "../../../examples/posix/ai/rtc-tcp-client/image_generation_demo.c" +#undef main +#undef iot_init_default +#undef iot_client_init +#undef iot_client_deinit +#undef iot_client_get_session_token_ex +#undef iot_atop_call +#undef iot_atop_response_free +#undef tai_ctx_init +#undef tai_connect +#undef tai_send_text +#undef tai_request_disconnect +#undef tai_disconnect +#undef tai_ctx_deinit +#undef realloc +#undef write +#undef close +#undef link + +static int failures, cases; +#define CHECK(c) do { if (!(c)) { fprintf(stderr, "FAIL %s:%d: %s\n", __func__, __LINE__, #c); failures++; } } while (0) +#define RUN(fn) do { int before = failures; cases++; fn(); printf("%s %s\n", before == failures ? "PASS" : "FAIL", #fn); } while (0) + +static const unsigned char png[] = { + 0x89,0x50,0x4e,0x47,0x0d,0x0a,0x1a,0x0a,0x00,0x00,0x00,0x0d,0x49,0x48,0x44,0x52, + 0x00,0x00,0x00,0x01,0x00,0x00,0x00,0x01,0x08,0x04,0x00,0x00,0x00,0xb5,0x1c,0x0c, + 0x02,0x00,0x00,0x00,0x0b,0x49,0x44,0x41,0x54,0x78,0xda,0x63,0x64,0xf8,0x0f,0x00, + 0x01,0x05,0x01,0x01,0x27,0x18,0xe3,0x66,0x00,0x00,0x00,0x00,0x49,0x45,0x4e,0x44, + 0xae,0x42,0x60,0x82 +}; +static const char jpeg_b64[] = + "/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/" + "2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjL/" + "wAARCAABAAEDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAb/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAAAAAABgf/" + "xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwCLAGVxf//Z"; +static const char good_token[] = + "{\"connect_conf\":{\"hosts\":[\"synthetic.invalid\"],\"domains\":[\"sni.invalid\"]," + "\"ecc_tls_port\":443,\"derived_client_id\":\"fake-derived\"}," + "\"session_conf\":{\"agentToken\":\"synthetic-agent-token\",\"bizConfig\":{\"bizCode\":1,\"bizTag\":2}}}"; +static const char good_key[] = "{\"secretValue\":\"AAAAAAAAAAAAAAAAAAAAAA==\"}"; +static char directory[PATH_MAX], ca_path[PATH_MAX]; +static const char *key_response = good_key, *token_response = good_token; +static int atop_status, token_status, init_fail, ctor_fail, connect_status, send_status; +static int oom, write_fault, close_fault, link_fault, delivery; +static int init_calls, token_calls, atop_calls, freed_responses, ctor_calls, connect_calls, send_calls; +static int no_ca_run; /* main() run with --allow-insecure-fallback and no CA */ +static int iot_alive, ctx_alive, requested, joined; +static tai_config_t captured; +static const char *borrowed_ca; +static pthread_t worker; +static int worker_started; + +static void reset_fakes(void) +{ + key_response = good_key; token_response = good_token; + atop_status = token_status = init_fail = ctor_fail = connect_status = send_status = 0; + oom = write_fault = close_fault = link_fault = delivery = 0; + init_calls = token_calls = atop_calls = freed_responses = ctor_calls = connect_calls = send_calls = 0; + iot_alive = ctx_alive = requested = joined = worker_started = 0; + cancelled = 0; + memset(&captured, 0, sizeof(captured)); +} + +static int fake_init_default(void) { init_calls++; return 0; } +static iot_client_t *fake_iot_init(const iot_client_config_t *cfg) +{ + CHECK(cfg->mqtt_disable_auto_connect && !cfg->mqtt_disable_tls); + CHECK(!strcmp(cfg->devid, getenv("TUYA_DEVICE_ID"))); + CHECK(!strcmp(cfg->secret_key, getenv("TUYA_SECRET_KEY"))); + CHECK(!strcmp(cfg->local_key, getenv("TUYA_LOCAL_KEY"))); + if (no_ca_run) CHECK(!cfg->cacert); + else CHECK(cfg->env == PROD && cfg->cacert && !strcmp(cfg->cacert, "synthetic CA\n")); + CHECK(!cfg->schema && !cfg->cert_bundle_attach); + if (init_fail) return NULL; + iot_client_t *client = calloc(1, sizeof(*client)); + client->cacert = cfg->cacert; + borrowed_ca = cfg->cacert; + iot_alive++; + return client; +} +static void fake_iot_deinit(iot_client_t *client) +{ + CHECK(iot_alive == 1 && client->cacert == borrowed_ca); + iot_alive--; + free(client); +} +static int fake_token(iot_client_t *client, const char *agent, char *out, size_t cap, + iot_atop_rejection_t *rejection) +{ + (void)rejection; + CHECK(agent == NULL); + CHECK(iot_alive && client->cacert == borrowed_ca); + token_calls++; + if (token_status) return token_status; + CHECK(strlen(token_response) < cap); + snprintf(out, cap, "%s", token_response); + return 0; +} +static int fake_atop(iot_client_t *client, const iot_atop_request_t *req, iot_atop_response_t *resp) +{ + (void)client; + CHECK(iot_alive && !ctx_alive); + CHECK(!strcmp(req->api, "thing.km.secret.get.device") && !strcmp(req->version, "1.0") && !strcmp(req->data, "{}")); + atop_calls++; + memset(resp, 0, sizeof(*resp)); + if (key_response) resp->result = strdup(key_response); + strcpy(resp->error_code, "synthetic-secret-code"); + strcpy(resp->error_msg, "synthetic-secret-error"); + return atop_status; +} +static void fake_response_free(iot_client_t *client, iot_atop_response_t *resp) +{ + (void)client; + CHECK(iot_alive && !resp->error_code[0] && !resp->error_msg[0]); + if (resp->result) + for (size_t i = 0; i < strlen(key_response); i++) CHECK(resp->result[i] == 0); + freed_responses++; + free(resp->result); + memset(resp, 0, sizeof(*resp)); +} +static tai_ctx_t *fake_ctx_init(void *mem, const tai_config_t *cfg) +{ + ctor_calls++; + CHECK(iot_alive && cfg->image_key && cfg->ca_cert == borrowed_ca && !cfg->disable_tls); + for (size_t i = 0; i < TAI_IMAGE_KEY_LEN; i++) CHECK(cfg->image_key[i] == 0); + CHECK(!strcmp(cfg->device_id, "fake-derived") && !strcmp(cfg->agent_token, "synthetic-agent-token")); + CHECK(!cfg->event_custom_param_json && !cfg->session_attrs_json); + captured = *cfg; + if (ctor_fail) return NULL; + ctx_alive++; + return mem; +} +static int fake_connect(tai_ctx_t *ctx) +{ + (void)ctx; connect_calls++; + CHECK(!iot_alive && ctx_alive && captured.ca_cert == borrowed_ca); + return connect_status; +} +static void *deliver_image(void *ctx) +{ + tai_image_msg_t image = {.data = png, .len = sizeof(png), .format = TAI_IMG_PNG, + .width = 1, .height = 1, .event_id = "fixture-event", .data_id = 6}; + if (delivery == 1) { + tai_event_msg_t end = {.event_type = TAI_EVT_END}; + captured.on_event(ctx, &end, captured.user_data); + } else if (delivery == 2) { + image.payload_type = TAI_IMG_PAYLOAD_URL; + captured.on_image(ctx, &image, captured.user_data); + } else { + captured.on_image(ctx, &image, captured.user_data); + tai_disconnect_msg_t close_msg = {.reason = TAI_DISCONNECT_CONNECTION_CLOSE}; + captured.on_disconnect(ctx, &close_msg, captured.user_data); + } + return NULL; +} +static int fake_send(tai_ctx_t *ctx, const char *text, size_t len) +{ + send_calls++; + CHECK(len == strlen(text) && len > 0); + if (send_status) return send_status; + if (delivery == 3) { on_signal(SIGINT); return 0; } + if (delivery == 4) return 0; + CHECK(!pthread_create(&worker, NULL, deliver_image, ctx)); + worker_started = 1; + return 0; +} +static void fake_request_disconnect(tai_ctx_t *ctx) +{ + (void)ctx; + /* Proves the application lock is released before the SDK stop request. */ + if (!ctx && captured.user_data) { + image_state_t *s = captured.user_data; + int rc = pthread_mutex_trylock(&s->mutex); + CHECK(rc == 0); + if (!rc) pthread_mutex_unlock(&s->mutex); + } + requested++; +} +static void fake_disconnect(tai_ctx_t *ctx) +{ + (void)ctx; + if (worker_started) { CHECK(!pthread_join(worker, NULL)); worker_started = 0; } + joined = 1; +} +static void fake_ctx_deinit(tai_ctx_t *ctx) +{ + (void)ctx; + CHECK(joined && ctx_alive && captured.ca_cert == borrowed_ca); + CHECK(!strcmp(captured.agent_token, "synthetic-agent-token")); + ctx_alive--; + captured.user_data = NULL; +} +static void *fake_realloc(void *p, size_t n) { return oom ? NULL : realloc(p, n); } +static ssize_t fake_write(int fd, const void *p, size_t n) +{ + if (ctx_alive) CHECK(joined); + if (write_fault == 1) { errno = ENOSPC; return -1; } + if (write_fault == 2) { write_fault = 3; errno = EINTR; return -1; } + if (write_fault == 3 && n > 3) n = 3; + if (write_fault == 4) return 0; + return write(fd, p, n); +} +static int fake_close(int fd) +{ + int rc = close(fd); + if (close_fault) { errno = EIO; return -1; } + return rc; +} +static int fake_link(const char *source, const char *dest) +{ + if (link_fault == 1) { errno = EPERM; return -1; } + if (link_fault == 2) { + int fd = open(dest, O_CREAT | O_EXCL | O_WRONLY, 0600); + CHECK(fd >= 0); + if (fd >= 0) { CHECK(write(fd, "existing", 8) == 8); close(fd); } + } + return link(source, dest); +} + +static int output_count(int remove_files) +{ + DIR *dir = opendir(directory); + CHECK(dir != NULL); + if (!dir) return 0; + struct dirent *entry; + int count = 0; + while ((entry = readdir(dir))) { + if (!strcmp(entry->d_name, ".") || !strcmp(entry->d_name, "..") || !strcmp(entry->d_name, "ca.pem")) continue; + count++; + if (remove_files) { + char path[PATH_MAX]; + snprintf(path, sizeof(path), "%s/%s", directory, entry->d_name); + CHECK(!unlink(path)); + } + } + closedir(dir); + return count; +} +static void verify_bytes(const char *path, const void *bytes, size_t len) +{ + unsigned char data[2048]; + FILE *f = fopen(path, "rb"); + CHECK(f != NULL); + if (!f) return; + size_t n = fread(data, 1, sizeof(data), f); + CHECK(n == len && !memcmp(data, bytes, len)); + CHECK(!fclose(f)); +} +static image_state_t new_state(void) +{ + image_state_t s = {0}; + return s; +} +static void start_state(image_state_t *s) +{ + *s = new_state(); + CHECK(!pthread_mutex_init(&s->mutex, NULL)); +} +static void end_state(image_state_t *s) +{ + discard_image(s); + CHECK(!pthread_mutex_destroy(&s->mutex)); +} +static tai_image_msg_t image_message(void) +{ + tai_image_msg_t m = {.data = png, .len = sizeof(png), .format = TAI_IMG_PNG, + .width = 1, .height = 1, .data_id = 6, .event_id = "fixture-event"}; + return m; +} +static void test_callback_file_and_lifetime(void) +{ + reset_fakes(); + image_state_t s; start_state(&s); + captured.user_data = &s; + unsigned char source[sizeof(png)]; memcpy(source, png, sizeof(png)); + char id[] = "fixture-event"; + tai_image_msg_t m = image_message(); m.data = source; m.event_id = id; + on_image(NULL, &m, &s); + memset(source, 0, sizeof(source)); memset(id, 'x', sizeof(id) - 1); + CHECK(s.outcome == 1 && !strcmp(s.event_id, "fixture-event") && requested == 1); + m = image_message(); m.payload_type = TAI_IMG_PAYLOAD_URL; + on_image(NULL, &m, &s); + tai_event_msg_t e = {.event_type = TAI_EVT_CHAT_BREAK}; on_event(NULL, &e, &s); + tai_disconnect_msg_t d = {.reason = TAI_DISCONNECT_PROTOCOL}; on_disconnect(NULL, &d, &s); + cancelled = 1; CHECK(wait_image(&s, 0) == 1); cancelled = 0; + char path[PATH_MAX]; CHECK(!save_image(&s, directory, path, sizeof(path))); + CHECK(!strcmp(path + strlen(path) - 4, ".png")); + verify_bytes(path, png, sizeof(png)); CHECK(output_count(1) == 1); + captured.user_data = NULL; + end_state(&s); +} +static void test_stream_empty_markers_and_metadata(void) +{ + reset_fakes(); image_state_t s; start_state(&s); + tai_image_msg_t m = image_message(); m.stream_flag = TAI_STREAM_START; m.len = 0; m.data = NULL; + on_image(NULL, &m, &s); + m.stream_flag = TAI_STREAM_MIDDLE; m.format = m.width = m.height = 0; + m.timestamp_ms = 999; m.len = 12; m.data = png; on_image(NULL, &m, &s); + m.data = png + 12; m.len = sizeof(png) - 12; m.timestamp_ms++; + on_image(NULL, &m, &s); + m.stream_flag = TAI_STREAM_END; m.len = 0; m.data = NULL; on_image(NULL, &m, &s); + CHECK(s.outcome == 1 && s.format == TAI_IMG_PNG && s.width == 1 && s.height == 1); + char path[PATH_MAX]; CHECK(!save_image(&s, directory, path, sizeof(path))); + verify_bytes(path, png, sizeof(png)); CHECK(output_count(1) == 1); end_state(&s); +} +static void test_jpeg_and_end_payload(void) +{ + reset_fakes(); size_t len; char *jpeg = b64_decode(jpeg_b64, &len); CHECK(jpeg != NULL); + if (!jpeg) return; + for (int streamed = 0; streamed < 2; streamed++) { + image_state_t s; start_state(&s); tai_image_msg_t m = image_message(); + m.data = (const uint8_t *)jpeg; m.len = len; m.format = TAI_IMG_JPEG; + if (streamed) { m.stream_flag = TAI_STREAM_START; m.len = 3; } + on_image(NULL, &m, &s); + if (streamed) { + m.stream_flag = TAI_STREAM_END; m.data += 3; m.len = len - 3; + on_image(NULL, &m, &s); + } + char path[PATH_MAX]; CHECK(!save_image(&s, directory, path, sizeof(path))); + CHECK(!strcmp(path + strlen(path) - 4, ".jpg")); verify_bytes(path, jpeg, len); + CHECK(output_count(1) == 1); end_state(&s); + } + free(jpeg); +} +static void test_stream_rejection_matrix(void) +{ + reset_fakes(); + for (int mode = 0; mode < 12; mode++) { + image_state_t s; start_state(&s); tai_image_msg_t m = image_message(); + if (mode < 7) { m.stream_flag = TAI_STREAM_START; on_image(NULL, &m, &s); } + m.stream_flag = TAI_STREAM_END; + switch (mode) { + case 0: m.event_id = "other"; break; + case 1: m.data_id++; break; + case 2: m.format = TAI_IMG_JPEG; break; + case 3: m.width++; break; + case 4: m.height++; break; + case 5: m.payload_type = TAI_IMG_PAYLOAD_URL; break; + case 6: m.len = IMAGE_LIMIT; break; + case 7: m.stream_flag = TAI_STREAM_MIDDLE; break; + case 8: break; + case 9: m.stream_flag = TAI_STREAM_ONE_SHOT; m.len = 0; break; + case 10: m.stream_flag = TAI_STREAM_ONE_SHOT; m.payload_type = TAI_IMG_PAYLOAD_BASE64; break; + case 11: m.stream_flag = 99; break; + } + on_image(NULL, &m, &s); + CHECK(s.outcome == -1 && !s.bytes && !s.len && s.error); + char path[PATH_MAX]; CHECK(save_image(&s, directory, path, sizeof(path)) != 0); + CHECK(output_count(0) == 0); end_state(&s); + } +} +static void test_replacement_and_event_transition(void) +{ + reset_fakes(); image_state_t s; start_state(&s); + tai_image_msg_t m = image_message(); m.stream_flag = TAI_STREAM_START; + on_image(NULL, &m, &s); + m.event_id = "replacement"; on_image(NULL, &m, &s); + CHECK(s.len == sizeof(png) && !strcmp(s.event_id, "replacement")); + tai_event_msg_t e = {.event_type = TAI_EVT_START, .event_id = "new-event"}; + on_event(NULL, &e, &s); CHECK(!s.active && !s.len && !s.outcome); + m = image_message(); on_image(NULL, &m, &s); CHECK(s.outcome == 1 && s.len == sizeof(png)); + end_state(&s); +} +static void test_terminal_events_disconnect_and_cancel(void) +{ + reset_fakes(); + for (int mode = 0; mode < 8; mode++) { + image_state_t s; start_state(&s); tai_image_msg_t m = image_message(); + m.stream_flag = TAI_STREAM_START; on_image(NULL, &m, &s); + if (mode < 3) { + unsigned events[] = {TAI_EVT_END, TAI_EVT_CHAT_BREAK, TAI_EVT_SERVER_TIMEOVER}; + tai_event_msg_t e = {.event_type = events[mode]}; on_event(NULL, &e, &s); + } else if (mode < 6) { + tai_disconnect_msg_t d = {.reason = mode == 3 ? TAI_DISCONNECT_PROTOCOL : + mode == 4 ? TAI_DISCONNECT_TRANSPORT : TAI_DISCONNECT_SESSION_CLOSE, + .detail = TAI_PROTO_ERR_IMAGE_AUTH}; on_disconnect(NULL, &d, &s); + } else { + if (mode == 6) on_signal(SIGTERM); + CHECK(wait_image(&s, 0) == -1); cancelled = 0; + } + CHECK(s.outcome == -1 && !s.len && !s.bytes); end_state(&s); + } +} +static void test_oom_and_cumulative_limit(void) +{ + reset_fakes(); image_state_t s; start_state(&s); tai_image_msg_t m = image_message(); + oom = 1; on_image(NULL, &m, &s); CHECK(s.outcome == -1 && strstr(s.error, "allocation")); + end_state(&s); oom = 0; start_state(&s); + unsigned char *large = calloc(1, IMAGE_LIMIT); CHECK(large != NULL); + if (large) { + m.data = large; m.len = IMAGE_LIMIT; m.stream_flag = TAI_STREAM_START; + on_image(NULL, &m, &s); CHECK(s.len == IMAGE_LIMIT && s.capacity == IMAGE_LIMIT); + m.len = 1; m.stream_flag = TAI_STREAM_END; on_image(NULL, &m, &s); + CHECK(s.outcome == -1 && !s.bytes && strstr(s.error, "16 MiB")); free(large); + } + end_state(&s); +} +static void test_signature_and_persistence_faults(void) +{ + reset_fakes(); + for (int mode = 0; mode < 8; mode++) { + image_state_t s; start_state(&s); tai_image_msg_t m = image_message(); + on_image(NULL, &m, &s); + if (mode == 0) s.format = TAI_IMG_JPEG; + if (mode == 1) s.bytes[0] = 0; + if (mode == 2) write_fault = 1; + if (mode == 3) close_fault = 1; + if (mode == 4) link_fault = 1; + if (mode == 5) link_fault = 2; + if (mode == 6) write_fault = 4; + char path[PATH_MAX]; + CHECK(save_image(&s, directory, path, mode == 7 ? 2 : sizeof(path)) != 0); + CHECK(output_count(0) == (mode == 5 ? 1 : 0)); + if (mode == 5) { + DIR *d = opendir(directory); struct dirent *e; + while ((e = readdir(d))) if (!strncmp(e->d_name, "image-", 6)) { + snprintf(path, sizeof(path), "%s/%s", directory, e->d_name); + verify_bytes(path, "existing", 8); + } + closedir(d); + } + output_count(1); end_state(&s); reset_fakes(); + } + image_state_t s; start_state(&s); tai_image_msg_t m = image_message(); on_image(NULL, &m, &s); + write_fault = 2; char path[PATH_MAX]; CHECK(!save_image(&s, directory, path, sizeof(path))); + verify_bytes(path, png, sizeof(png)); CHECK(output_count(1) == 1); end_state(&s); +} +static void test_cli_credentials_and_preflight(void) +{ + reset_fakes(); options_t o; + char *args[] = {"demo", "--prompt", "draw", "--ca-file", ca_path}; + CHECK(!parse_options(5, args, &o) && o.timeout == 120 && o.region == AY); + CHECK(parse_options(4, args, &o) < 0); + char *help[] = {"demo", "--help"}; CHECK(image_generation_demo_main(2, help) == 0); + const char *bad[] = {"0", "-1", "1x", "+1", "999999999999999999999999999999"}; + char *more[] = {"demo", "--prompt", "x", "--ca-file", ca_path, "--timeout", NULL}; + for (size_t i = 0; i < sizeof(bad)/sizeof(bad[0]); i++) { + more[6] = (char *)bad[i]; CHECK(parse_options(7, more, &o) < 0); + } + more[5] = "--region"; more[6] = "SG"; CHECK(!parse_options(7, more, &o) && o.region == SG); + more[6] = "invalid"; CHECK(parse_options(7, more, &o) < 0); + more[5] = "--agent-code"; more[6] = "other-agent"; + CHECK(parse_options(7, more, &o) < 0); + CHECK(image_generation_demo_main(7, more) == 2 && init_calls == 0); + more[5] = "--unknown"; CHECK(parse_options(7, more, &o) < 0); + iot_client_config_t cfg = {0}; + unsetenv("TUYA_LOCAL_KEY"); CHECK(credentials(&cfg) != 0); + setenv("TUYA_LOCAL_KEY", "", 1); CHECK(credentials(&cfg) != 0); + setenv("TUYA_LOCAL_KEY", "0123456789012345678901234567890123", 1); CHECK(credentials(&cfg) != 0); + setenv("TUYA_LOCAL_KEY", "synthetic-local", 1); CHECK(!credentials(&cfg)); + CHECK(!prepare_directory(directory)); CHECK(prepare_directory(ca_path) != 0); + char missing[PATH_MAX]; snprintf(missing, sizeof(missing), "%s/missing/child", directory); + CHECK(prepare_directory(missing) != 0); CHECK(read_ca(missing) == NULL); + char *ca = read_ca(ca_path); CHECK(ca && !strcmp(ca, "synthetic CA\n")); free(ca); + CHECK(init_calls == 0); +} +static void test_ca_limits(void) +{ + char path[PATH_MAX]; snprintf(path, sizeof(path), "%s/invalid-ca", directory); + FILE *f = fopen(path, "wb"); CHECK(f != NULL); fclose(f); + CHECK(read_ca(path) == NULL); + f = fopen(path, "wb"); CHECK(f != NULL); fwrite("x\0y", 1, 3, f); fclose(f); + CHECK(read_ca(path) == NULL); + f = fopen(path, "wb"); CHECK(f != NULL); + for (unsigned i = 0; i <= CA_LIMIT; i++) fputc('x', f); + fclose(f); CHECK(read_ca(path) == NULL); CHECK(!unlink(path)); +} +static void test_token_validation(void) +{ + tai_conn_params_t p; CHECK(!parse_connection(good_token, &p) && p.port == 443); + unsigned char encoded[2048]; size_t n; + CHECK(!mbedtls_base64_encode(encoded, sizeof(encoded), &n, (const unsigned char *)good_token, strlen(good_token))); + CHECK(!parse_connection((char *)encoded, &p)); + const char *bad[] = {"{}", "null", "[]", "broken", "{\"connect_conf\":{\"hosts\":[\"host\"]}}"}; + for (size_t i = 0; i < sizeof(bad)/sizeof(bad[0]); i++) CHECK(parse_connection(bad[i], &p) != 0); + const char *ports[] = {"0", "-1", "65536", "443.5", "\"443\"", "null"}; + for (size_t i = 0; i < sizeof(ports)/sizeof(ports[0]); i++) { + char text[1024]; snprintf(text, sizeof(text), + "{\"connect_conf\":{\"hosts\":[\"x\"],\"derived_client_id\":\"id\",\"tcpport\":%s}," + "\"session_conf\":{\"agentToken\":\"token\"}}", ports[i]); + CHECK(parse_connection(text, &p) != 0); + } +} +static void test_token_string_validation(void) +{ + const struct { const char *from, *to; int valid; } tests[] = { + {"connect_conf", "connect_conf\\u0000tail", 0}, + {"session_conf", "session_conf\\u0000tail", 0}, + {"hosts", "hosts\\u0000tail", 0}, + {"domains", "domains\\u0000tail", 0}, + {"ecc_tls_port", "ecc_tls_port\\u0000tail", 0}, + {"derived_client_id", "derived_client_id\\u0000tail", 0}, + {"agentToken", "agentToken\\u0000tail", 0}, + {"synthetic.invalid", "synthetic.invalid\\u0000tail", 0}, + {"sni.invalid", "sni.invalid\\u0000tail", 0}, + {"fake-derived", "fake-derived\\u0000tail", 0}, + {"synthetic-agent-token", "synthetic-agent-token\\u0000tail", 0}, + {"synthetic-agent-token", "\\u0000synthetic-agent-token", 0}, + {"bizCode", "unused\\u0000tail", 0}, + {"2}}}", "2},\"extra\":[{\"nested\":\"prefix\\u0000tail\"}]}}", 0}, + {"synthetic-agent-token", "prefix\\ud800", 0}, + {"synthetic-agent-token", "prefix\\udc00", 0}, + {"bizCode", "unused\\ud800", 0}, + {"synthetic-agent-token", "prefix\\\\u0000tail", 1}, + {"synthetic-agent-token", "prefix\\\\ud800tail", 1}, + {"synthetic-agent-token", "prefix\\\"quoted\\\"\\\\u0000tail", 1}, + {"synthetic-agent-token", "prefix\\ud83d\\ude00", 1}, + {"bizCode", "unused\\\\u0000tail", 1}, + }; + for (size_t i = 0; i < sizeof(tests)/sizeof(tests[0]); i++) { + const char *at = strstr(good_token, tests[i].from); + CHECK(at != NULL); + if (!at) continue; + char text[1024]; + int len = snprintf(text, sizeof(text), "%.*s%s%s", (int)(at - good_token), good_token, + tests[i].to, at + strlen(tests[i].from)); + CHECK(len > 0 && (size_t)len < sizeof(text)); + unsigned char encoded[2048]; size_t n; + CHECK(!mbedtls_base64_encode(encoded, sizeof(encoded), &n, + (const unsigned char *)text, strlen(text))); + for (int base64 = 0; base64 < 2; base64++) { + tai_conn_params_t p; + memset(&p, 0xab, sizeof(p)); + CHECK((parse_connection(base64 ? (char *)encoded : text, &p) == 0) == tests[i].valid); + if (!tests[i].valid) { + const unsigned char *bytes = (const unsigned char *)&p; + for (size_t j = 0; j < sizeof(p); j++) CHECK(bytes[j] == 0); + } else if (!strcmp(tests[i].to, "prefix\\\\u0000tail")) { + CHECK(!strcmp(p.agent_token, "prefix\\u0000tail")); + } + } + } + unsigned char encoded[2048]; size_t n; + tai_conn_params_t p; + /* Even a trailing decoded NUL is forbidden in the base64 payload. */ + CHECK(!mbedtls_base64_encode(encoded, sizeof(encoded), &n, + (const unsigned char *)good_token, sizeof(good_token))); + CHECK(parse_connection((char *)encoded, &p) != 0); +} +static void test_key_validation_and_cleanup(void) +{ + reset_fakes(); iot_alive = 1; + const char *bad[] = {NULL, "null", "[]", "{}", "{\"secretValue\":1}", + "{\"secretValue\":\"AAAAAAAAAAAAAAAAAAAAAB==\"}", + "{\"secretValue\":\"AAAAAAAAAAAAAAAAAAAAAA=\"}", + "{\"secretValue\":\"AAAAAAAAAAAAAAAAAAAAAA== \"}", + "{\"secretValue\":\"AAAAAAAAAAAAAAAAAAAA_A==\"}", + "{\"secretValue\":\"AAAAAAAAAAAAAAAAAAAAAA==\\u0000junk\"}", + "{\"secretValue\\u0000tail\":\"AAAAAAAAAAAAAAAAAAAAAA==\"}", + "{\"secretValue\":\"AAAAAAAAAAAAAAAAAAAAAA==\",\"extra\":[{\"nested\":\"prefix\\u0000tail\"}]}", + "{\"secretValue\":\"AAAAAAAAAAAAAAAAAAAAAA==\",\"extra\":[{\"name\\u0000tail\":\"secret\"}]}", + "{\"secretValue\":\"AAAAAAAAAAAAAAAAAAAAAA==\",\"extra\":\"prefix\\ud800\"}", + "{\"secretValue\":\"AAAAAAAAAAAAAAAAAAAAAA==\",\"extra\":\"prefix\\udc00\"}", + "{\"secretValue\":{\"nested\":[\"prefix\\u0000tail\"]}}", + "[{\"nested\\u0000tail\":\"secret\"}]", + "{\"extra\":[{\"nested\":\"prefix\\u0000tail\"}],\"secretValue\":", + "{\"extra\":[{\"name\\u0000tail\":\"secret\"}],\"secretValue\":"}; + uint8_t key[16]; + for (size_t i = 0; i < sizeof(bad)/sizeof(bad[0]); i++) { + memset(key, 0xab, sizeof(key)); key_response = bad[i]; + CHECK(fetch_image_key(NULL, key) != 0); + for (size_t j = 0; j < sizeof(key); j++) CHECK(key[j] == 0); + } + const char *good[] = {good_key, + "{\"secretValue\":\"AAAAAAAAAAAAAAAAAAAAAA==\",\"extra\":[{\"name\\\\u0000\":\"prefix\\\\u0000tail\"}]}", + "{\"secretValue\":\"AAAAAAAAAAAAAAAAAAAAAA==\",\"extra\":\"prefix\\\"quoted\\\"\\\\u0000tail\"}", + "{\"secretValue\":\"AAAAAAAAAAAAAAAAAAAAAA==\",\"extra\":\"prefix\\ud83d\\ude00\"}"}; + for (size_t i = 0; i < sizeof(good)/sizeof(good[0]); i++) { + key_response = good[i]; CHECK(!fetch_image_key(NULL, key)); + } + key_response = good_key; + atop_status = OPRT_ATOP_BUSINESS_ERROR; CHECK(fetch_image_key(NULL, key) == atop_status); + atop_status = OPRT_TLS_HANDSHAKE_FAILED; CHECK(fetch_image_key(NULL, key) == atop_status); + CHECK(freed_responses == atop_calls && !ctor_calls); iot_alive = 0; +} +static int run_main(void) +{ + char *args[] = {"image_generation_demo", "--prompt", "synthetic prompt", "--ca-file", ca_path, + "--output-dir", directory, "--timeout", "1"}; + int rc = image_generation_demo_main(9, args); + CHECK(!iot_alive && !ctx_alive && !worker_started); + return rc; +} +static void test_main_preflight_and_secret_logs(void) +{ + reset_fakes(); + char *args[] = {"demo", "--prompt", "x", "--ca-file", "/no/such/ca.pem", + "--output-dir", directory}; + CHECK(image_generation_demo_main(7, args) != 0 && !init_calls); + args[4] = ca_path; args[6] = ca_path; + CHECK(image_generation_demo_main(7, args) != 0 && !init_calls); + unsetenv("TUYA_DEVICE_ID"); args[6] = directory; + CHECK(image_generation_demo_main(7, args) != 0 && !init_calls); + setenv("TUYA_DEVICE_ID", "synthetic-device", 1); + FILE *logs = tmpfile(); CHECK(logs != NULL); + if (!logs) return; + int original = dup(STDERR_FILENO); CHECK(original >= 0); + fflush(stderr); CHECK(dup2(fileno(logs), STDERR_FILENO) >= 0); + atop_status = OPRT_ATOP_BUSINESS_ERROR; + CHECK(run_main() != 0); + fflush(stderr); CHECK(dup2(original, STDERR_FILENO) >= 0); close(original); + rewind(logs); char text[2048] = {0}; fread(text, 1, sizeof(text) - 1, logs); fclose(logs); + CHECK(strstr(text, "Image-key acquisition failed")); + CHECK(!strstr(text, "synthetic-secret") && !strstr(text, "AAAAAAAAAAAAAAAAAAAAAA==")); + CHECK(!strstr(text, "synthetic-agent-token")); +} + +static void test_insecure_fallback(void) +{ + reset_fakes(); + options_t o; + char *flag[] = {"demo", "--prompt", "draw", "--allow-insecure-fallback"}; + CHECK(!parse_options(4, flag, &o) && !o.ca_file && o.allow_insecure); + char *bare[] = {"demo", "--prompt", "draw"}; + CHECK(parse_options(3, bare, &o) < 0); + char *stray[] = {"demo", "--prompt", "draw", "--allow-insecure-fallback", "junk"}; + CHECK(parse_options(5, stray, &o) < 0); /* the flag takes no value */ + + /* Without a CA the opt-in reaches tai_config_t and the run still succeeds. */ + reset_fakes(); + no_ca_run = 1; + char *run[] = {"image_generation_demo", "--prompt", "synthetic prompt", + "--allow-insecure-fallback", "--output-dir", directory, "--timeout", "1"}; + CHECK(image_generation_demo_main(8, run) == 0); + no_ca_run = 0; + CHECK(!iot_alive && !ctx_alive && !worker_started); + /* Without a CA the run still succeeds: the module verifies only when a + * trust source is configured, so the demo's opt-in is a demo-side gate. */ + CHECK(!captured.ca_cert); + CHECK(output_count(1) == 1); + + /* A supplied but unreadable CA never silently falls back to unverified TLS. */ + reset_fakes(); + char *bad[] = {"demo", "--prompt", "x", "--ca-file", "/no/such/ca.pem", + "--allow-insecure-fallback", "--output-dir", directory}; + CHECK(image_generation_demo_main(8, bad) != 0 && !init_calls); +} +static void test_main_success_threaded_and_failures(void) +{ + for (int mode = 0; mode < 15; mode++) { + reset_fakes(); + switch (mode) { + case 1: token_status = OPRT_TLS_HANDSHAKE_FAILED; break; + case 2: token_response = "{}"; break; + case 3: key_response = "null"; break; + case 4: atop_status = OPRT_ATOP_BUSINESS_ERROR; break; + case 5: ctor_fail = 1; break; + case 6: connect_status = TAI_ERR_TLS; break; + case 7: send_status = TAI_ERR_NET; break; + case 8: delivery = 1; break; + case 9: delivery = 2; break; + case 10: delivery = 3; break; + case 11: delivery = 4; break; + case 12: init_fail = 1; break; + case 13: write_fault = 1; break; + case 14: oom = 1; break; + } + CHECK((run_main() == 0) == (mode == 0)); + CHECK(output_count(1) == (mode == 0 ? 1 : 0)); + CHECK(token_calls <= 1 && atop_calls <= 1 && connect_calls <= 1 && send_calls <= 1); + CHECK(freed_responses == atop_calls); + if (mode == 1 || mode == 2 || mode == 3 || mode == 4) CHECK(!ctor_calls); + } +} + +int main(void) +{ + char temp[] = "/tmp/image demo test-XXXXXX"; + char *dir = mkdtemp(temp); + if (!dir) { perror("mkdtemp"); return 1; } + snprintf(directory, sizeof(directory), "%s", dir); + snprintf(ca_path, sizeof(ca_path), "%s/ca.pem", directory); + FILE *f = fopen(ca_path, "wb"); + if (!f) return 1; + fputs("synthetic CA\n", f); fclose(f); + const char *names[] = {"TUYA_DEVICE_ID", "TUYA_SECRET_KEY", "TUYA_LOCAL_KEY"}; + char *old[3] = {0}; + for (size_t i = 0; i < 3; i++) { const char *v = getenv(names[i]); if (v) old[i] = strdup(v); } + setenv(names[0], "synthetic-device", 1); setenv(names[1], "synthetic-secret", 1); + setenv(names[2], "synthetic-local", 1); + RUN(test_main_success_threaded_and_failures); + RUN(test_main_preflight_and_secret_logs); + RUN(test_insecure_fallback); + RUN(test_callback_file_and_lifetime); + RUN(test_stream_empty_markers_and_metadata); + RUN(test_jpeg_and_end_payload); + RUN(test_stream_rejection_matrix); + RUN(test_replacement_and_event_transition); + RUN(test_terminal_events_disconnect_and_cancel); + RUN(test_oom_and_cumulative_limit); + RUN(test_signature_and_persistence_faults); + RUN(test_cli_credentials_and_preflight); + RUN(test_ca_limits); + RUN(test_token_validation); + RUN(test_token_string_validation); + RUN(test_key_validation_and_cleanup); + CHECK(output_count(1) == 0); CHECK(!unlink(ca_path)); CHECK(!rmdir(directory)); + for (size_t i = 0; i < 3; i++) { + if (old[i]) { setenv(names[i], old[i], 1); free(old[i]); } else unsetenv(names[i]); + } + printf("=== image_generation_demo_test: %d cases, %d failures ===\n", cases, failures); + return failures ? 1 : 0; +} diff --git a/modules/rtc-tcp-client/test/keyed_tls_peer.h b/modules/rtc-tcp-client/test/keyed_tls_peer.h new file mode 100644 index 0000000..c80c6b8 --- /dev/null +++ b/modules/rtc-tcp-client/test/keyed_tls_peer.h @@ -0,0 +1,200 @@ +/* Local verified TLS peer. Reuses committed, synthetic IoT test certificates + * (valid through February 2036); no generated credentials or external process. */ +#ifndef KEYED_TLS_PEER_H +#define KEYED_TLS_PEER_H + +#include +#include +#include +#include "mbedtls/ssl.h" +#include "mbedtls/x509_crt.h" +#include "mbedtls/pk.h" +#include "rng.h" + +typedef struct { + int listener, socket, mode, error; + uint16_t port; + pthread_t thread; + mbedtls_ssl_context ssl; + mbedtls_ssl_config config; + mbedtls_x509_crt cert; + mbedtls_pk_context key; + uint8_t handshake[8192]; + size_t handshake_len; +} keyed_peer_t; + +static keyed_peer_t keyed_peer; +static char keyed_ca[4096]; + +static int keyed_fixture_path(char *path, size_t cap, const char *name) +{ + const char *slash = strrchr(__FILE__, '/'); + if (!slash) return -1; + int n = snprintf(path, cap, "%.*s/../../iot-client/test/config/%s", + (int)(slash - __FILE__), __FILE__, name); + return n > 0 && (size_t)n < cap ? 0 : -1; +} + +static int keyed_rng(void *arg, unsigned char *buf, size_t len) +{ + (void)arg; + return rng_bytes(tai_pal_posix(), buf, len); +} + +static int keyed_bio_send(void *arg, const unsigned char *buf, size_t len) +{ + int fd = *(int *)arg; +#ifdef MSG_NOSIGNAL + int n = (int)send(fd, buf, len, MSG_NOSIGNAL); +#else + int n = (int)send(fd, buf, len, 0); +#endif + return n > 0 ? n : MBEDTLS_ERR_SSL_INTERNAL_ERROR; +} + +static int keyed_bio_recv(void *arg, unsigned char *buf, size_t len) +{ + int n = (int)recv(*(int *)arg, buf, len, 0); + return n >= 0 ? n : MBEDTLS_ERR_SSL_INTERNAL_ERROR; +} + +static int keyed_read_exact(keyed_peer_t *p, uint8_t *buf, size_t len) +{ + while (len) { + int n = mbedtls_ssl_read(&p->ssl, buf, len); + if (n <= 0) return -1; + buf += n; + len -= (size_t)n; + } + return 0; +} + +static int keyed_read_frame(keyed_peer_t *p, uint8_t *buf, size_t cap) +{ + if (cap < 5 || keyed_read_exact(p, buf, 5)) return -1; + size_t n = ((size_t)buf[3] << 8) | buf[4]; + if (n > cap - 5 || keyed_read_exact(p, buf + 5, n)) return -1; + return (int)n + 5; +} + +static int keyed_peer_write(const uint8_t *buf, size_t len) +{ + while (len) { + int n = mbedtls_ssl_write(&keyed_peer.ssl, buf, len); + if (n <= 0) return -1; + buf += n; + len -= (size_t)n; + } + return 0; +} + +static void *keyed_peer_handshake(void *arg) +{ + keyed_peer_t *p = arg; + struct pollfd wait = { .fd = p->listener, .events = POLLIN }; + if (poll(&wait, 1, 5000) <= 0) { p->error = -1; return NULL; } + p->socket = accept(p->listener, NULL, NULL); + if (p->socket < 0) { p->error = -2; return NULL; } + struct timeval timeout = { .tv_sec = 5 }; + setsockopt(p->socket, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout)); + setsockopt(p->socket, SOL_SOCKET, SO_SNDTIMEO, &timeout, sizeof(timeout)); +#ifdef SO_NOSIGPIPE + int one = 1; + setsockopt(p->socket, SOL_SOCKET, SO_NOSIGPIPE, &one, sizeof(one)); +#endif + mbedtls_ssl_set_bio(&p->ssl, &p->socket, keyed_bio_send, keyed_bio_recv, NULL); + p->error = mbedtls_ssl_handshake(&p->ssl); + if (p->error) return NULL; + for (int i = 0; i < 2; i++) { + int n = keyed_read_frame(p, p->handshake + p->handshake_len, + sizeof(p->handshake) - p->handshake_len); + if (n < 0) { p->error = -3; return NULL; } + p->handshake_len += (size_t)n; + } + /* SessionNew status=0, no body. Mode 1 downgrades the Frame, mode 2 + * appends a legacy signature, mode 3 advertises a mismatched suite. + * Mode 4 confirms via AuthenticateResponse status=200 instead. */ + uint8_t ack[96] = {0x23, 0, 1, 0, 11, 0x0f, 0, 0, 0, 6, + 0, 44, 0, 2, 0, 0}; + size_t n = 16; + if (p->mode == 1) ack[0] = 0x22; + if (p->mode == 2) { + /* A correctly computed legacy HMAC is still forbidden on this profile. */ + uint8_t key[32] = {0}; + ack[4] += 32; + p->error = mbedtls_md_hmac(mbedtls_md_info_from_type(MBEDTLS_MD_SHA256), + key, sizeof(key), ack, n, ack + n); + if (p->error) return NULL; + n += 32; + } + if (p->mode == 3) { + ack[9] += 40; + ack[n++] = 0; ack[n++] = 10; ack[n++] = 0; ack[n++] = 36; + memset(ack + n, 0, 36); + ack[n + 33] = 4; ack[n + 34] = 3; ack[n + 35] = 2; + n += 36; ack[4] += 40; + } + if (p->mode == 4) { ack[5] = 7; ack[11] = 30; ack[15] = 200; } + p->error = keyed_peer_write(ack, n); + return NULL; +} + +static void keyed_peer_close(void) +{ + keyed_peer_t *p = &keyed_peer; + if (p->socket >= 0) close(p->socket); + if (p->listener >= 0) close(p->listener); + mbedtls_ssl_free(&p->ssl); + mbedtls_ssl_config_free(&p->config); + mbedtls_x509_crt_free(&p->cert); + mbedtls_pk_free(&p->key); + p->socket = p->listener = -1; +} + +static int keyed_peer_start(int mode) +{ + keyed_peer_t *p = &keyed_peer; + memset(p, 0, sizeof(*p)); + p->socket = p->listener = -1; + p->mode = mode; + mbedtls_ssl_init(&p->ssl); + mbedtls_ssl_config_init(&p->config); + mbedtls_x509_crt_init(&p->cert); + mbedtls_pk_init(&p->key); + char path[4096]; + if (keyed_fixture_path(path, sizeof(path), "root_cert.pem")) goto fail; + FILE *f = fopen(path, "rb"); + if (!f) goto fail; + size_t n = fread(keyed_ca, 1, sizeof(keyed_ca) - 1, f); + int bad = ferror(f) || !feof(f); + fclose(f); + keyed_ca[n] = 0; + if (bad) goto fail; + if (keyed_fixture_path(path, sizeof(path), "cert.pem") || + mbedtls_x509_crt_parse_file(&p->cert, path)) goto fail; + if (keyed_fixture_path(path, sizeof(path), "key.pem") || + mbedtls_pk_parse_keyfile(&p->key, path, NULL, keyed_rng, NULL)) goto fail; + if (mbedtls_ssl_config_defaults(&p->config, MBEDTLS_SSL_IS_SERVER, + MBEDTLS_SSL_TRANSPORT_STREAM, MBEDTLS_SSL_PRESET_DEFAULT)) goto fail; + mbedtls_ssl_conf_rng(&p->config, keyed_rng, NULL); + mbedtls_ssl_conf_max_tls_version(&p->config, MBEDTLS_SSL_VERSION_TLS1_2); + if (mbedtls_ssl_conf_own_cert(&p->config, &p->cert, &p->key) || + mbedtls_ssl_setup(&p->ssl, &p->config)) goto fail; + p->listener = socket(AF_INET, SOCK_STREAM, 0); + if (p->listener < 0) goto fail; + struct sockaddr_in addr = {0}; + addr.sin_family = AF_INET; + addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + socklen_t alen = sizeof(addr); + if (bind(p->listener, (struct sockaddr *)&addr, alen) || + getsockname(p->listener, (struct sockaddr *)&addr, &alen) || + listen(p->listener, 1)) goto fail; + p->port = ntohs(addr.sin_port); + if (pthread_create(&p->thread, NULL, keyed_peer_handshake, p)) goto fail; + return 0; +fail: + keyed_peer_close(); + return -1; +} + +#endif diff --git a/modules/rtc-tcp-client/test/test_core.c b/modules/rtc-tcp-client/test/test_core.c index 475710e..a5249d4 100644 --- a/modules/rtc-tcp-client/test/test_core.c +++ b/modules/rtc-tcp-client/test/test_core.c @@ -18,6 +18,7 @@ #include "../src/tai_internal.h" #include "rng.h" #include "test_log.h" +#include "mbedtls/base64.h" /* ------------------------------------------------------------------------- * Minimal stub PAL. TLS and crypto are no longer PAL responsibilities -- @@ -417,6 +418,185 @@ static void test_crypto(void) PASS(); } +/* Independent synthetic vectors, generated with Python cryptography AESGCM + * and cross-checked with PyCryptodome AES.MODE_GCM (16-byte nonce/tag, no AAD). + * First plaintext: bytes([0,255,128,1]) + bytes(range(33)); key: range(16), + * IV: range(16,32). Other vectors use zero key/IV and 17 or 0 zero bytes. + * These are primitive fixtures, NOT evidence for any image wire profile. */ +static const uint8_t image_key[16] = { + 0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15 +}; +static const uint8_t image_iv[16] = { + 16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31 +}; +static const uint8_t image_plain[37] = { + 0,255,128,1,0,1,2,3,4,5,6,7,8,9,10,11,12,13,14, + 15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32 +}; +static const uint8_t image_body[53] = { + 0xc5,0x61,0x77,0xb7,0x7d,0xb3,0x26,0x8b,0xd2,0x1d,0xc5,0x85,0xa9,0x26,0x25,0xdc, + 0xf9,0xf0,0x2e,0x90,0x7d,0x17,0xb5,0xeb,0xbd,0x93,0x91,0x81,0x5b,0x5a,0x69,0xfb, + 0x3f,0x39,0x75,0x18,0xc1,0xe0,0x83,0x79,0x48,0xa8,0x6f,0x6a,0xde,0xeb,0x9f,0x2f, + 0x76,0x14,0x26,0x8f,0x70 +}; + +static void test_image_gcm(void) +{ + TEST("image GCM independent vectors and authentication"); + uint8_t out[38], zero[38] = {0}; + memset(out, 0xa5, sizeof(out)); + CHECK(tai_image_gcm_decrypt(image_key, image_iv, image_body, + sizeof(image_body), out, 37) == TAI_OK); + CHECK(memcmp(out, image_plain, 37) == 0); + CHECK(out[37] == 0xa5); + + static const uint8_t zero_body[33] = { + 0xa3,0xb2,0x2b,0x84,0x49,0xaf,0xaf,0xbc,0xd6,0xc0,0x9f,0x2c,0xfa,0x9d,0xe2,0xbe, + 0x93,0x5d,0x63,0x0c,0x18,0xef,0x6c,0x58,0x70,0xf0,0x97,0x45,0x51,0xd6,0x5f,0x20,0x23 + }; + static const uint8_t empty_tag[16] = { + 0xe8,0x23,0xb7,0xf1,0xa1,0xd3,0xf1,0xa0,0x46,0x2e,0xbd,0xb2,0xca,0xe3,0xb3,0x50 + }; + CHECK(tai_image_gcm_decrypt(zero, zero, zero_body, sizeof(zero_body), + out, sizeof(out)) == TAI_OK); + CHECK(memcmp(out, zero, 17) == 0); + out[0] = 0xa5; + CHECK(tai_image_gcm_decrypt(zero, zero, empty_tag, 16, out, 0) == TAI_OK); + CHECK(out[0] == 0xa5); + uint8_t bad_tag[16]; + memcpy(bad_tag, empty_tag, 16); + bad_tag[0] ^= 1; + CHECK(tai_image_gcm_decrypt(zero, zero, bad_tag, 16, out, 0) == TAI_ERR_CRYPTO); + CHECK(out[0] == 0xa5); + + /* Flip every byte in each authenticated input, including all tag bytes. */ + for (size_t i = 0; i < 16 + 16 + sizeof(image_body); i++) { + uint8_t key[16], iv[16], body[sizeof(image_body)]; + memcpy(key, image_key, sizeof(key)); + memcpy(iv, image_iv, sizeof(iv)); + memcpy(body, image_body, sizeof(body)); + if (i < 16) key[i] ^= 1; + else if (i < 32) iv[i - 16] ^= 1; + else body[i - 32] ^= 1; + memset(out, 0xa5, sizeof(out)); + CHECK(tai_image_gcm_decrypt(key, iv, body, sizeof(body), + out, sizeof(out)) == TAI_ERR_CRYPTO); + CHECK(memcmp(out, zero, 37) == 0); + CHECK(out[37] == 0xa5); + } + PASS(); +} + +static void test_image_gcm_encrypt(void) +{ + TEST("image GCM encrypt independent vectors and bounds"); + uint8_t out[54], sentinel[54]; + memset(out, 0xa5, sizeof(out)); + CHECK(tai_image_gcm_encrypt(image_key, image_iv, image_plain, 37, + out, 53) == TAI_OK); + CHECK(memcmp(out, image_body, 53) == 0); + CHECK(out[53] == 0xa5); + static const uint8_t zero_body[33] = { + 0xa3,0xb2,0x2b,0x84,0x49,0xaf,0xaf,0xbc,0xd6,0xc0,0x9f,0x2c,0xfa,0x9d,0xe2,0xbe, + 0x93,0x5d,0x63,0x0c,0x18,0xef,0x6c,0x58,0x70,0xf0,0x97,0x45,0x51,0xd6,0x5f,0x20,0x23 + }; + static const uint8_t empty_tag[16] = { + 0xe8,0x23,0xb7,0xf1,0xa1,0xd3,0xf1,0xa0,0x46,0x2e,0xbd,0xb2,0xca,0xe3,0xb3,0x50 + }; + uint8_t zero[17] = {0}; + CHECK(tai_image_gcm_encrypt(zero, zero, zero, 17, out, 33) == TAI_OK); + CHECK(memcmp(out, zero_body, 33) == 0); + CHECK(tai_image_gcm_encrypt(zero, zero, zero, 0, out, 16) == TAI_OK); + CHECK(memcmp(out, empty_tag, 16) == 0); + + memset(sentinel, 0xa5, sizeof(sentinel)); + memcpy(out, sentinel, sizeof(out)); + CHECK(tai_image_gcm_encrypt(NULL, image_iv, image_plain, 37, out, 53) == TAI_ERR_ARGS); + CHECK(tai_image_gcm_encrypt(image_key, NULL, image_plain, 37, out, 53) == TAI_ERR_ARGS); + CHECK(tai_image_gcm_encrypt(image_key, image_iv, NULL, 37, out, 53) == TAI_ERR_ARGS); + CHECK(tai_image_gcm_encrypt(image_key, image_iv, image_plain, 37, NULL, 53) == TAI_ERR_ARGS); + CHECK(tai_image_gcm_encrypt(image_key, image_iv, image_plain, SIZE_MAX, out, SIZE_MAX) == TAI_ERR_ARGS); + CHECK(tai_image_gcm_encrypt(image_key, image_iv, image_plain, PTRDIFF_MAX, out, SIZE_MAX) == TAI_ERR_ARGS); + for (size_t n = 0; n < 53; n++) + CHECK(tai_image_gcm_encrypt(image_key, image_iv, image_plain, 37, out, n) == TAI_ERR_MEM); + CHECK(memcmp(out, sentinel, sizeof(out)) == 0); + + uint8_t storage[160], before[160]; + const size_t offsets[] = {64, 65, 63, 12}; /* Last overlaps only the output tag. */ + for (size_t i = 0; i < sizeof(offsets) / sizeof(offsets[0]); i++) { + memset(storage, 0xa5, sizeof(storage)); + memcpy(storage + 64, image_plain, 37); + memcpy(before, storage, sizeof(before)); + CHECK(tai_image_gcm_encrypt(image_key, image_iv, storage + 64, 37, + storage + offsets[i], 53) == TAI_ERR_ARGS); + CHECK(memcmp(storage, before, sizeof(storage)) == 0); + } + CHECK(tai_image_gcm_encrypt(storage, image_iv, image_plain, 37, storage, 53) == TAI_ERR_ARGS); + CHECK(tai_image_gcm_encrypt(image_key, storage, image_plain, 37, storage, 53) == TAI_ERR_ARGS); + memcpy(storage, image_plain, 37); + CHECK(tai_image_gcm_encrypt(image_key, image_iv, storage, 37, storage + 37, 53) == TAI_OK); + CHECK(memcmp(storage, image_plain, 37) == 0); + CHECK(memcmp(storage + 37, image_body, 53) == 0); + PASS(); +} + +static void test_image_gcm_bounds(void) +{ + TEST("image GCM short lengths, capacity and nonoverlap"); + uint8_t out[37], sentinel[37]; + memset(sentinel, 0xa5, sizeof(sentinel)); + memcpy(out, sentinel, sizeof(out)); + for (size_t n = 0; n < 16; n++) { + CHECK(tai_image_gcm_decrypt(image_key, image_iv, image_body, n, + out, sizeof(out)) == TAI_ERR_ARGS); + CHECK(memcmp(out, sentinel, sizeof(out)) == 0); + } + CHECK(tai_image_gcm_decrypt(image_key, image_iv, image_body, 53, + out, 36) == TAI_ERR_MEM); + CHECK(tai_image_gcm_decrypt(NULL, image_iv, image_body, 53, + out, sizeof(out)) == TAI_ERR_ARGS); + CHECK(tai_image_gcm_decrypt(image_key, NULL, image_body, 53, + out, sizeof(out)) == TAI_ERR_ARGS); + CHECK(tai_image_gcm_decrypt(image_key, image_iv, NULL, 53, + out, sizeof(out)) == TAI_ERR_ARGS); + CHECK(tai_image_gcm_decrypt(image_key, image_iv, image_body, 53, + NULL, sizeof(out)) == TAI_ERR_ARGS); + CHECK(memcmp(out, sentinel, sizeof(out)) == 0); + for (size_t n = 16; n < sizeof(image_body); n++) { + uint8_t zero[37] = {0}; + memcpy(out, sentinel, sizeof(out)); + CHECK(tai_image_gcm_decrypt(image_key, image_iv, image_body, n, + out, sizeof(out)) == TAI_ERR_CRYPTO); + CHECK(memcmp(out, zero, n - 16) == 0); + CHECK(memcmp(out + (n - 16), sentinel, sizeof(out) - (n - 16)) == 0); + } + + uint8_t storage[128], before[128]; + /* Output overlaps ciphertext, just the tag, or precedes the input. */ + const size_t offsets[] = {32, 33, 69, 31}; + for (size_t i = 0; i < sizeof(offsets) / sizeof(offsets[0]); i++) { + memset(storage, 0xa5, sizeof(storage)); + memcpy(storage + 32, image_body, sizeof(image_body)); + memcpy(before, storage, sizeof(before)); + CHECK(tai_image_gcm_decrypt(image_key, image_iv, storage + 32, 53, + storage + offsets[i], 37) == TAI_ERR_ARGS); + CHECK(memcmp(storage, before, sizeof(storage)) == 0); + } + memcpy(storage, image_key, 16); + CHECK(tai_image_gcm_decrypt(storage, image_iv, image_body, 53, + storage, 37) == TAI_ERR_ARGS); + memcpy(storage, image_iv, 16); + CHECK(tai_image_gcm_decrypt(image_key, storage, image_body, 53, + storage, 37) == TAI_ERR_ARGS); + /* Adjacent, disjoint output is allowed. */ + memcpy(storage, image_body, 53); + CHECK(tai_image_gcm_decrypt(image_key, image_iv, storage, 53, + storage + 53, 37) == TAI_OK); + CHECK(memcmp(storage + 53, image_plain, 37) == 0); + CHECK(memcmp(storage, image_body, 53) == 0); + PASS(); +} + /* ------------------------------------------------------------------------- * 9. Protocol builder: ClientHello * ------------------------------------------------------------------------- */ @@ -585,6 +765,726 @@ static void test_hmac_sg(void) PASS(); } +/* Complete offline PAL for constructor/lifecycle tests. Any network, worker, + * sleep or PAL allocation activity is observable, without opening real sockets. */ +static int image_network_calls, image_worker_calls, image_alloc_calls; +static int image_mutex_calls, image_mutex_fail_at, image_mutex_destroys; +static int image_callback_calls; + +static void *image_tcp_connect(const char *host, uint16_t port, uint32_t timeout) +{ + (void)host; (void)port; (void)timeout; + image_network_calls++; + return NULL; +} +static int image_tcp_send(void *h, const uint8_t *b, size_t n, uint32_t t) +{ + (void)h; (void)b; (void)n; (void)t; + image_network_calls++; + return PAL_ERR_NET; +} +static int image_tcp_recv(void *h, uint8_t *b, size_t n, uint32_t t) +{ + (void)h; (void)b; (void)n; (void)t; + image_network_calls++; + return PAL_ERR_NET; +} +static void image_tcp_close(void *h) +{ + (void)h; + image_network_calls++; +} +static int image_tcp_poll(void *h, int events, uint32_t timeout) +{ + (void)h; (void)events; (void)timeout; + image_network_calls++; + return PAL_ERR_NET; +} +static int image_thread_create(void **h, void *(*fn)(void *), void *arg) +{ + (void)h; (void)fn; (void)arg; + image_worker_calls++; + return -1; +} +static int image_thread_join(void *h) +{ + (void)h; + image_worker_calls++; + return 0; +} +static void image_sleep(uint32_t ms) +{ + (void)ms; + image_worker_calls++; +} +static void *image_malloc(size_t n) +{ + image_alloc_calls++; + return malloc(n); +} +static void *image_mutex_create(void) +{ + image_mutex_calls++; + return image_mutex_calls == image_mutex_fail_at ? NULL : &g_stub_mutex; +} +static void image_mutex_destroy(void *h) +{ + (void)h; + image_mutex_destroys++; +} +static void image_disconnect_cb(tai_ctx_t *ctx, const tai_disconnect_msg_t *m, void *u) +{ + (void)ctx; (void)m; (void)u; + image_callback_calls++; +} +static pal_t image_test_pal(void) +{ + pal_t pal = g_stub_pal; + pal.tcp_connect = image_tcp_connect; + pal.tcp_send = image_tcp_send; + pal.tcp_recv = image_tcp_recv; + pal.tcp_close = image_tcp_close; + pal.tcp_poll = image_tcp_poll; + pal.thread_create = image_thread_create; + pal.thread_join = image_thread_join; + pal.sleep_ms = image_sleep; + pal.malloc = image_malloc; + pal.mutex_create = image_mutex_create; + pal.mutex_destroy = image_mutex_destroy; + return pal; +} + +static size_t image_entropy_scratch_len; +static int image_entropy_allocs, image_entropy_frees; + +static void *image_entropy_malloc(size_t len) +{ + image_entropy_scratch_len = len; + image_entropy_allocs++; + void *p = malloc(len); + if (p) memset(p, 0xa5, len); + return p; +} + +static void image_entropy_free(void *p) +{ + int wiped = 1; + for (size_t i = 0; i < image_entropy_scratch_len; i++) + if (((uint8_t *)p)[i]) wiped = 0; + CHECK(wiped); + image_entropy_frees++; + free(p); +} + +/* Send-only stub, deliberately before rng_init: the real RNG fails closed + * without an entropy seed. No Connection profile or handshake is exercised. */ +static void test_image_send_only_rng_failure(void) +{ + TEST("image send-only RNG failure emits nothing"); + pal_t pal = image_test_pal(); + pal.malloc = image_entropy_malloc; + pal.free = image_entropy_free; + static struct tai_ctx ctx; + const size_t lengths[] = {0, 1, sizeof(image_plain)}; + for (int api = 0; api < 3; api++) { + for (size_t i = 0; i < sizeof(lengths) / sizeof(lengths[0]); i++) { + memset(&ctx, 0, sizeof(ctx)); + ctx.pal = &pal; + ctx.mutex = &g_stub_mutex; + ctx.connected = ctx.disable_tls = ctx.has_image_key = 1; + ctx.proto_ver = TAI_VER_21; + ctx.frame_ver = 3; + ctx.sig_len = 0; + ctx.sign_level = TAI_SIGN_NONE; + memcpy(ctx.image_key, image_key, 16); + memcpy(ctx.event_id, "unchanged", 10); + int rc; + if (api == 0) + rc = tai_send_image(&ctx, image_plain, lengths[i], TAI_IMG_JPEG, 640, 480); + else if (api == 1) + rc = tai_send_image_with_text(&ctx, "test", 4, image_plain, lengths[i], TAI_IMG_JPEG, 640, 480); + else + rc = tai_send_image_audio_start(&ctx, image_plain, lengths[i], TAI_IMG_JPEG, 640, 480, + TAI_AUDIO_PCM, 1, 16, 16000); + CHECK(rc == TAI_ERR_CRYPTO); + CHECK(image_entropy_allocs == image_entropy_frees); + CHECK(image_entropy_scratch_len == (lengths[i] ? lengths[i] + 32 : 16)); + CHECK(ctx.seq == 0 && ctx.event_open == 0 && ctx.audio_started == 0); + CHECK(strcmp(ctx.event_id, "unchanged") == 0); + CHECK(memcmp(ctx.image_key, image_key, 16) == 0); + } + } + CHECK(image_entropy_allocs == 9 && image_entropy_frees == 9); + CHECK(image_network_calls == 0 && image_worker_calls == 0); + PASS(); +} + +/* Must run BEFORE the process-wide RNG is seeded so the second mutex failure + * exercises the constructor's rng_init failure, not its already-ready path. */ +static void test_image_key_init_failure(void) +{ + TEST("image key not retained on constructor failure"); + pal_t pal = image_test_pal(); + tai_config_t cfg = { .pal = &pal, .image_key = image_key }; + static struct tai_ctx mem; + uint8_t zero[16] = {0}; + CHECK(tai_ctx_init(NULL, &cfg) == NULL); + CHECK(tai_ctx_init(&mem, NULL) == NULL); + cfg.pal = &g_stub_pal; /* Incomplete PAL rejected before construction. */ + CHECK(tai_ctx_init(&mem, &cfg) == NULL); + CHECK(memcmp(mem.image_key, zero, 16) == 0); + cfg.pal = &pal; + for (int fail_at = 1; fail_at <= 2; fail_at++) { + image_mutex_calls = image_mutex_destroys = 0; + image_mutex_fail_at = fail_at; + memset(&mem, 0xa5, sizeof(mem)); + CHECK(tai_ctx_init(&mem, &cfg) == NULL); + CHECK(image_mutex_calls == fail_at); + CHECK(image_mutex_destroys == fail_at - 1); + CHECK(mem.has_image_key == 0); + CHECK(memcmp(mem.image_key, zero, 16) == 0); + } + image_mutex_fail_at = 0; + CHECK(image_network_calls == 0); + CHECK(image_worker_calls == 0); + CHECK(image_alloc_calls == 0); + PASS(); +} + +static void test_image_key_ownership(void) +{ + TEST("image key owned, isolated, persistent and wiped"); + pal_t pal = image_test_pal(); + uint8_t caller_key[16], zero[16] = {0}; + memcpy(caller_key, image_key, 16); + tai_config_t cfg = { + .pal = &pal, .host = "offline.invalid", .port = 443, + .device_id = "synthetic-device", .local_key = "synthetic-local-key", + .on_disconnect = image_disconnect_cb + }; + static struct tai_ctx legacy_mem, first_mem, second_mem; + tai_ctx_t *legacy = tai_ctx_init(&legacy_mem, &cfg); + CHECK(legacy != NULL); + if (!legacy) return; + CHECK(legacy->has_image_key == 0); + CHECK(legacy->proto_ver == TAI_VER_21 && legacy->frame_ver == 2); + CHECK(legacy->sign_level == TAI_SIGN_HMAC_SHA256 && legacy->sig_len == 32); + CHECK(memcmp(legacy->image_key, zero, 16) == 0); + cfg.image_key = caller_key; + tai_ctx_t *first = tai_ctx_init(&first_mem, &cfg); + CHECK(first != NULL); + if (!first) { tai_ctx_deinit(legacy); return; } + CHECK(first->has_image_key == 1); + CHECK(memcmp(first->image_key, image_key, 16) == 0); + memset(caller_key, 0, sizeof(caller_key)); + tai_ctx_t *second = tai_ctx_init(&second_mem, &cfg); + CHECK(second != NULL); + if (!second) { tai_ctx_deinit(first); tai_ctx_deinit(legacy); return; } + CHECK(second->has_image_key == 1); + CHECK(memcmp(second->image_key, zero, 16) == 0); + CHECK(memcmp(first->image_key, image_key, 16) == 0); + memset(caller_key, 0x5a, sizeof(caller_key)); + CHECK(image_network_calls == 0); + CHECK(image_worker_calls == 0); + CHECK(image_alloc_calls == 0); + + tai_ctx_t *contexts[] = {first, second}; + for (size_t i = 0; i < 2; i++) { + tai_ctx_t *ctx = contexts[i]; + const uint8_t *expected = i ? zero : image_key; + CHECK(ctx->proto_ver == TAI_VER_21 && ctx->frame_ver == 3); + CHECK(ctx->sign_level == TAI_SIGN_NONE && ctx->sig_len == 0); + /* Explicit legacy key derivation must not replace the fixed image key. */ + CHECK(tai_crypto_derive_keys(ctx->proto_ver, + (const uint8_t *)cfg.local_key, strlen(cfg.local_key), + image_iv, sizeof(image_iv), ctx->encrypt_key, + ctx->sign_key, ctx->pal) == TAI_OK); + uint8_t frame_key[32], random_before[32]; + memcpy(frame_key, ctx->encrypt_key, 32); + memcpy(random_before, ctx->encrypt_random, 32); + /* disable_tls rejects a keyed context before touching any state, so + * repeated failures must leave the key and the profile untouched. (A + * keyed TLS connect with no trust source now proceeds to the network.) */ + ctx->disable_tls = 1; + for (int attempt = 0; attempt < 2; attempt++) { + CHECK(tai_connect(ctx) == TAI_ERR_TLS); + CHECK(ctx->has_image_key == 1); + CHECK(memcmp(ctx->image_key, expected, 16) == 0); + CHECK(memcmp(ctx->encrypt_key, frame_key, 32) == 0); + CHECK(memcmp(ctx->sign_key, frame_key, 32) == 0); + CHECK(memcmp(ctx->encrypt_random, random_before, 32) == 0); + CHECK(ctx->seq == 0 && ctx->session_id[0] == '\0'); + CHECK(ctx->tls == NULL && ctx->raw_tcp == NULL); + CHECK(!ctx->connected && !ctx->connecting && !ctx->session_open); + CHECK(!ctx->running && ctx->thread_handle == NULL); + tai_disconnect(ctx); + CHECK(ctx->has_image_key == 1); + CHECK(memcmp(ctx->image_key, expected, 16) == 0); + } + } + CHECK(image_network_calls == 0); + CHECK(image_worker_calls == 0); + CHECK(image_alloc_calls == 0); + CHECK(image_callback_calls == 0); + tai_ctx_deinit(first); + CHECK(first_mem.has_image_key == 0); + CHECK(memcmp(first_mem.image_key, zero, 16) == 0); + CHECK(second->has_image_key == 1); + tai_ctx_deinit(second); + CHECK(second_mem.has_image_key == 0); + CHECK(memcmp(second_mem.image_key, zero, 16) == 0); + for (size_t i = 0; i < sizeof(caller_key); i++) CHECK(caller_key[i] == 0x5a); + /* No-key construction still reaches the legacy Connection path. */ + legacy->disable_tls = 1; + CHECK(tai_connect(legacy) == TAI_ERR_NET); + CHECK(image_network_calls == 1); + tai_ctx_deinit(legacy); + PASS(); +} + +static void image_cert_bundle_attach(void *ssl_config) +{ + (void)ssl_config; + CHECK(0); /* Pre-network guards must never configure TLS. */ +} + +static void test_image_connection_guards(void) +{ + TEST("keyed Connection profile and pre-network TLS guards"); + pal_t pal = image_test_pal(); + const struct { + const char *ca; + int bundle, raw, expected; + } cases[] = { + {NULL, 0, 0, TAI_ERR_ARGS}, + {"", 0, 0, TAI_ERR_ARGS}, + {NULL, 0, 1, TAI_ERR_TLS}, + {"", 0, 1, TAI_ERR_TLS}, + {"synthetic-ca", 0, 1, TAI_ERR_TLS}, + {NULL, 1, 1, TAI_ERR_TLS}, + {"synthetic-ca", 1, 1, TAI_ERR_TLS}, + {"synthetic-ca", 0, 0, TAI_ERR_ARGS}, + {NULL, 1, 0, TAI_ERR_ARGS}, + {"", 1, 0, TAI_ERR_ARGS} + }; + int networks = image_network_calls, workers = image_worker_calls; + int allocs = image_alloc_calls, callbacks = image_callback_calls; + static struct tai_ctx mem; + for (size_t i = 0; i < sizeof(cases) / sizeof(cases[0]); i++) { + /* No endpoint: accepted trust configuration stops at argument validation, + * before certificate parsing, entropy generation or networking. */ + tai_config_t cfg = { + .pal = &pal, .image_key = image_key, + .protocol_version = TAI_VER_21, .sign_level = TAI_SIGN_HMAC_SHA1, + .ca_cert = cases[i].ca, .disable_tls = cases[i].raw, + .cert_bundle_attach = cases[i].bundle ? image_cert_bundle_attach : NULL, + .on_disconnect = image_disconnect_cb + }; + tai_ctx_t *ctx = tai_ctx_init(&mem, &cfg); + CHECK(ctx != NULL); + if (!ctx) continue; + CHECK(ctx->has_image_key && ctx->proto_ver == TAI_VER_21); + CHECK(ctx->frame_ver == 3 && ctx->sig_len == 0 && ctx->sign_level == TAI_SIGN_NONE); + CHECK(ctx->ca_cert == cfg.ca_cert && ctx->cert_bundle_attach == cfg.cert_bundle_attach); + CHECK(tai_connect(ctx) == cases[i].expected); + if (cases[i].expected == TAI_ERR_ARGS) { + ctx->proto_ver = 20; + CHECK(tai_connect(ctx) == TAI_ERR_PROTO); + ctx->proto_ver = TAI_VER_21; + ctx->frame_ver = 2; + CHECK(tai_connect(ctx) == TAI_ERR_PROTO); + ctx->frame_ver = 3; + ctx->sign_level = TAI_SIGN_HMAC_SHA256; + CHECK(tai_connect(ctx) == TAI_ERR_PROTO); + ctx->sign_level = TAI_SIGN_NONE; + ctx->sig_len = 32; + CHECK(tai_connect(ctx) == TAI_ERR_PROTO); + } + CHECK(!ctx->connected && !ctx->connecting && !ctx->running); + CHECK(ctx->tls == NULL && ctx->raw_tcp == NULL && ctx->thread_handle == NULL); + CHECK(ctx->seq == 0 && ctx->session_id[0] == '\0'); + CHECK(memcmp(ctx->image_key, image_key, 16) == 0); + tai_ctx_deinit(ctx); + } + CHECK(image_network_calls == networks && image_worker_calls == workers); + CHECK(image_alloc_calls == allocs && image_callback_calls == callbacks); + PASS(); +} + +/* Offline complete-Packet dispatch: independent ciphertext, synthetic Data ID. + * This does not exercise the Connection handshake or TLS verification. */ +static struct { + int calls, allocs, frees, fail_at; + tai_image_msg_t msg; + uint8_t bytes[256]; + char event[64]; + void *alloc[2]; + size_t sizes[2]; +} image_rx; + +static void image_rx_cb(tai_ctx_t *ctx, const tai_image_msg_t *m, void *u) +{ + (void)ctx; (void)u; + image_rx.calls++; + image_rx.msg = *m; + CHECK(m->len <= sizeof(image_rx.bytes)); + if (m->len <= sizeof(image_rx.bytes)) memcpy(image_rx.bytes, m->data, m->len); + snprintf(image_rx.event, sizeof(image_rx.event), "%s", m->event_id); +} + +static void *image_rx_malloc(size_t n) +{ + int i = image_rx.allocs++; + if (image_rx.allocs == image_rx.fail_at) return NULL; + CHECK(i < 2); + if (i >= 2) return NULL; + image_rx.alloc[i] = malloc(n); + image_rx.sizes[i] = n; + return image_rx.alloc[i]; +} + +static void image_rx_free(void *p) +{ + for (int i = 0; i < 2; i++) { + if (p != image_rx.alloc[i]) continue; + /* Fixture plaintext allocations must be wiped on success AND failure. */ + if (image_rx.sizes[i] == sizeof(image_plain) || image_rx.sizes[i] == 1) { + int wiped = 1; + for (size_t j = 0; j < image_rx.sizes[i]; j++) + if (((uint8_t *)p)[j]) wiped = 0; + CHECK(wiped); + } + image_rx.alloc[i] = NULL; + } + image_rx.frees++; + free(p); +} + +static void image_rx_init(tai_ctx_t *ctx, pal_t *pal) +{ + memset(ctx, 0, sizeof(*ctx)); + memset(&image_rx, 0, sizeof(image_rx)); + *pal = g_stub_pal; + pal->malloc = image_rx_malloc; + pal->free = image_rx_free; + ctx->pal = pal; + ctx->proto_ver = TAI_VER_21; + ctx->frame_ver = 3; + ctx->sig_len = 0; + ctx->sign_level = TAI_SIGN_NONE; + ctx->has_image_key = 1; + memcpy(ctx->image_key, image_key, 16); + ctx->on_image = image_rx_cb; +} + +static size_t image_rx_payload(uint8_t *out, uint8_t flag, int base64) +{ + /* Independent header: Data ID 6, timestamp 0x0102030405, IV at offset 8. */ + const uint8_t header[8] = {0, 6, 0, 1, 2, 3, 4, 5}; + memcpy(out, header, 8); + out[2] = flag << 6; + memcpy(out + 8, image_iv, 16); + size_t n = sizeof(image_body); + if (base64) + CHECK(mbedtls_base64_encode(out + 24, 128, &n, image_body, sizeof(image_body)) == 0); + else + memcpy(out + 24, image_body, n); + return 24 + n; +} + +static int image_rx_dispatch(tai_ctx_t *ctx, const char *params, const char *event, + const uint8_t *payload, size_t len) +{ + tai_attr_t attrs[2]; + int n = 0; + if (params) attrs[n++] = tai_attr_strv(TAI_ATTR_IMAGE_PARAMS, params); + if (event) attrs[n++] = tai_attr_strv(TAI_ATTR_EVENT_ID, event); + /* All scratch must have been released by the preceding callback/dispatch. */ + CHECK(!image_rx.alloc[0] && !image_rx.alloc[1]); + image_rx.allocs = image_rx.frees = 0; + return tai_proto_dispatch(ctx, TAI_PKT_IMAGE, attrs, n, payload, len); +} + +static void test_image_receive_types(void) +{ + TEST("image receive RAW/Base64 exact plaintext and URL"); + static struct tai_ctx ctx; + pal_t pal; + uint8_t payload[160], before[160]; + for (int b64 = 0; b64 <= 1; b64++) { + image_rx_init(&ctx, &pal); + size_t len = image_rx_payload(payload, TAI_STREAM_ONE_SHOT, b64); + memcpy(before, payload, len); + CHECK(image_rx_dispatch(&ctx, b64 ? "1 2 640 480" : "0 2 640 480", + "image-event", payload, len) == TAI_OK); + CHECK(image_rx.calls == 1 && image_rx.msg.len == sizeof(image_plain)); + CHECK(memcmp(image_rx.bytes, image_plain, sizeof(image_plain)) == 0); + CHECK(image_rx.msg.payload_type == TAI_IMG_PAYLOAD_RAW); + CHECK(image_rx.msg.format == TAI_IMG_PNG && image_rx.msg.width == 640 && image_rx.msg.height == 480); + CHECK(image_rx.msg.data_id == 6 && image_rx.msg.timestamp_ms == 0x0102030405ULL); + CHECK(image_rx.msg.stream_flag == TAI_STREAM_ONE_SHOT); + CHECK(strcmp(image_rx.event, "image-event") == 0); + CHECK(image_rx.allocs == b64 + 1 && image_rx.frees == image_rx.allocs); + CHECK(memcmp(before, payload, len) == 0 && !ctx.rx_image_active); + } + image_rx_init(&ctx, &pal); + image_rx_payload(payload, TAI_STREAM_ONE_SHOT, 0); + const char url[] = "https://example.invalid/image"; + memcpy(payload + 24, url, sizeof(url) - 1); + CHECK(image_rx_dispatch(&ctx, "2 1 0 0", NULL, payload, 24 + sizeof(url) - 1) == TAI_OK); + CHECK(image_rx.calls == 1 && image_rx.msg.payload_type == TAI_IMG_PAYLOAD_URL); + CHECK(image_rx.msg.len == sizeof(url) - 1 && memcmp(image_rx.bytes, url, sizeof(url) - 1) == 0); + CHECK(image_rx.allocs == 0); + + /* Legacy body includes everything after byte 8, even an IV/ciphertext. */ + for (int type = 0; type < 3; type++) { + image_rx_init(&ctx, &pal); + ctx.has_image_key = 0; + ctx.frame_ver = 2; + ctx.sig_len = 32; + ctx.sign_level = TAI_SIGN_HMAC_SHA256; + size_t len = image_rx_payload(payload, TAI_STREAM_ONE_SHOT, 0); + char params[] = "0 1 640 480"; + params[0] += type; + CHECK(image_rx_dispatch(&ctx, params, NULL, payload, len) == TAI_OK); + CHECK(image_rx.msg.payload_type == type && image_rx.msg.len == len - 8); + CHECK(memcmp(image_rx.bytes, payload + 8, len - 8) == 0 && image_rx.allocs == 0); + } + PASS(); +} + +static void test_image_receive_streams(void) +{ + TEST("image Stream identity, omitted attrs and resets"); + static struct tai_ctx ctx; + pal_t pal; + uint8_t payload[160], evt[2] = {0, TAI_EVT_END}; + image_rx_init(&ctx, &pal); + size_t len = image_rx_payload(payload, TAI_STREAM_START, 1); + CHECK(image_rx_dispatch(&ctx, "1 1 640 480", "event-a", payload, len) == TAI_OK); + CHECK(ctx.rx_image_active); + payload[2] = TAI_STREAM_MIDDLE << 6; + CHECK(image_rx_dispatch(&ctx, NULL, NULL, payload, len) == TAI_OK); + CHECK(image_rx.calls == 2 && image_rx.msg.format == 0 && image_rx.msg.width == 0 && image_rx.msg.height == 0); + CHECK(image_rx.msg.payload_type == TAI_IMG_PAYLOAD_RAW && strcmp(image_rx.event, "event-a") == 0); + CHECK(memcmp(image_rx.bytes, image_plain, sizeof(image_plain)) == 0); + payload[2] = TAI_STREAM_END << 6; + CHECK(image_rx_dispatch(&ctx, "1 1 640 480", "event-a", payload, len) == TAI_OK); + CHECK(!ctx.rx_image_active && image_rx.calls == 3); + CHECK(image_rx_dispatch(&ctx, NULL, NULL, payload, len) == TAI_PROTO_ERR_UNEXPECTED); + CHECK(image_rx.calls == 3); + + for (int conflict = 0; conflict < 4; conflict++) { + image_rx_init(&ctx, &pal); + len = image_rx_payload(payload, TAI_STREAM_START, 0); + CHECK(image_rx_dispatch(&ctx, "0 1 640 480", "event-a", payload, len) == TAI_OK); + payload[2] = TAI_STREAM_MIDDLE << 6; + if (conflict == 0) payload[1]++; + CHECK(image_rx_dispatch(&ctx, conflict == 1 ? "1 1 640 480" : + conflict == 3 ? "0 1 641 480" : NULL, + conflict == 2 ? "event-b" : NULL, + payload, len) == TAI_PROTO_ERR_UNEXPECTED); + CHECK(image_rx.calls == 1 && !ctx.rx_image_active); + CHECK(strcmp(ctx.rx_event_id, "event-a") == 0); + } + for (int reset = 0; reset < 5; reset++) { + image_rx_init(&ctx, &pal); + len = image_rx_payload(payload, TAI_STREAM_START, 0); + CHECK(image_rx_dispatch(&ctx, "0 1 640 480", "event-a", payload, len) == TAI_OK); + if (reset == 0) tai_proto_reset_image(&ctx); + else if (reset == 1) + CHECK(tai_proto_dispatch(&ctx, TAI_PKT_SESSION_CLOSE, NULL, 0, NULL, 0) == TAI_OK); + else { + evt[1] = reset == 2 ? TAI_EVT_END : reset == 3 ? TAI_EVT_START : TAI_EVT_CHAT_BREAK; + tai_attr_t attr = tai_attr_strv(TAI_ATTR_EVENT_ID, "event-b"); + CHECK(tai_proto_dispatch(&ctx, TAI_PKT_EVENT, &attr, 1, evt, 2) == TAI_OK); + } + CHECK(!ctx.rx_image_active && memcmp(ctx.image_key, image_key, 16) == 0); + payload[2] = TAI_STREAM_MIDDLE << 6; + CHECK(image_rx_dispatch(&ctx, NULL, NULL, payload, len) == TAI_PROTO_ERR_UNEXPECTED); + CHECK(image_rx.calls == 1); + } + /* EventStart supplies identity when all Image Packets omit event-id. */ + image_rx_init(&ctx, &pal); + tai_attr_t event_attr = tai_attr_strv(TAI_ATTR_EVENT_ID, "event-inherited"); + evt[1] = TAI_EVT_START; + CHECK(tai_proto_dispatch(&ctx, TAI_PKT_EVENT, &event_attr, 1, evt, 2) == TAI_OK); + len = image_rx_payload(payload, TAI_STREAM_START, 0); + CHECK(image_rx_dispatch(&ctx, "0 1 1 1", NULL, payload, len) == TAI_OK); + CHECK(strcmp(image_rx.event, "event-inherited") == 0); + payload[2] = TAI_STREAM_MIDDLE << 6; + CHECK(image_rx_dispatch(&ctx, NULL, "event-inherited", payload, len) == TAI_OK); + CHECK(image_rx.calls == 2); + + /* Consecutive streams must not inherit the previous encoding. */ + image_rx_init(&ctx, &pal); + len = image_rx_payload(payload, TAI_STREAM_ONE_SHOT, 1); + CHECK(image_rx_dispatch(&ctx, "1 1 1 1", NULL, payload, len) == TAI_OK); + len = image_rx_payload(payload, TAI_STREAM_ONE_SHOT, 0); + CHECK(image_rx_dispatch(&ctx, "0 1 1 1", NULL, payload, len) == TAI_OK); + CHECK(image_rx.calls == 2 && memcmp(image_rx.bytes, image_plain, 37) == 0); + PASS(); +} + +static void test_image_receive_bounds(void) +{ + TEST("image short headers/bodies, empty markers and tag-only"); + static struct tai_ctx ctx; + pal_t pal; + uint8_t payload[160]; + image_rx_init(&ctx, &pal); + image_rx_payload(payload, TAI_STREAM_ONE_SHOT, 0); + for (size_t n = 0; n < 24; n++) + CHECK(image_rx_dispatch(&ctx, "0 1 1 1", NULL, payload, n) == TAI_PROTO_ERR_MEDIA_HDR); + for (size_t n = 1; n < sizeof(image_body); n++) { + CHECK(image_rx_dispatch(&ctx, "0 1 1 1", NULL, payload, 24 + n) == + (n < 16 ? TAI_PROTO_ERR_PKT_DECODE : TAI_PROTO_ERR_IMAGE_AUTH)); + CHECK(image_rx.calls == 0 && image_rx.allocs == image_rx.frees); + } + CHECK(image_rx_dispatch(&ctx, "0 1 1 1", NULL, payload, + AGENTIC_KIT_TAI_FRAG_BUF_SIZE + 1U) == TAI_PROTO_ERR_FRAG); + static uint8_t bounded[AGENTIC_KIT_TAI_FRAG_BUF_SIZE]; + memset(bounded, 'x', sizeof(bounded)); + memcpy(bounded, payload, 24); + ctx.on_image = NULL; + /* 1 Packet byte + 4 block bytes + 4 Attribute bytes + 7 params bytes. */ + CHECK(image_rx_dispatch(&ctx, "2 1 1 1", NULL, bounded, sizeof(bounded) - 16) == TAI_OK); + CHECK(image_rx_dispatch(&ctx, "2 1 1 1", NULL, bounded, sizeof(bounded) - 15) == TAI_PROTO_ERR_FRAG); + bounded[2] = TAI_STREAM_START << 6; + CHECK(image_rx_dispatch(&ctx, "2 1 1 1", NULL, bounded, 24) == TAI_OK); + bounded[2] = TAI_STREAM_MIDDLE << 6; + /* No attrs means only the one-byte Packet header consumes capacity. */ + for (size_t spare = 4; spare > 0; spare--) + CHECK(image_rx_dispatch(&ctx, NULL, NULL, bounded, sizeof(bounded) - spare) == TAI_OK); + CHECK(image_rx_dispatch(&ctx, NULL, NULL, bounded, sizeof(bounded)) == TAI_PROTO_ERR_FRAG); + payload[24] ^= 1; + CHECK(image_rx_dispatch(&ctx, "0 1 1 1", NULL, payload, 24 + sizeof(image_body)) == TAI_PROTO_ERR_IMAGE_AUTH); + CHECK(image_rx.calls == 0 && image_rx.allocs == image_rx.frees); + for (int b64 = 0; b64 <= 1; b64++) { + image_rx_init(&ctx, &pal); + image_rx_payload(payload, TAI_STREAM_START, b64); + CHECK(image_rx_dispatch(&ctx, b64 ? "1 1 1 1" : "0 1 1 1", NULL, payload, 24) == TAI_OK); + payload[2] = TAI_STREAM_END << 6; + CHECK(image_rx_dispatch(&ctx, NULL, NULL, payload, 24) == TAI_OK); + payload[2] = TAI_STREAM_ONE_SHOT << 6; + CHECK(image_rx_dispatch(&ctx, b64 ? "1 1 1 1" : "0 1 1 1", NULL, payload, 24) == TAI_OK); + CHECK(image_rx.calls == 3 && image_rx.msg.len == 0 && image_rx.allocs == 0); + payload[2] = TAI_STREAM_START << 6; + CHECK(image_rx_dispatch(&ctx, "0 1 1 1", NULL, payload, 24) == TAI_OK); + payload[2] = TAI_STREAM_MIDDLE << 6; + CHECK(image_rx_dispatch(&ctx, NULL, NULL, payload, 24) == TAI_PROTO_ERR_PKT_DECODE); + CHECK(image_rx.calls == 4); + } + static const uint8_t empty_tag[16] = { + 0xe8,0x23,0xb7,0xf1,0xa1,0xd3,0xf1,0xa0,0x46,0x2e,0xbd,0xb2,0xca,0xe3,0xb3,0x50 + }; + for (int b64 = 0; b64 <= 1; b64++) { + image_rx_init(&ctx, &pal); + memset(ctx.image_key, 0, 16); + image_rx_payload(payload, TAI_STREAM_ONE_SHOT, 0); + memset(payload + 8, 0, 16); + size_t n = 16; + if (b64) CHECK(mbedtls_base64_encode(payload + 24, 128, &n, empty_tag, 16) == 0); + else memcpy(payload + 24, empty_tag, 16); + CHECK(image_rx_dispatch(&ctx, b64 ? "1 1 1 1" : "0 1 1 1", NULL, payload, 24 + n) == TAI_OK); + CHECK(image_rx.calls == 1 && image_rx.msg.len == 0 && image_rx.allocs == image_rx.frees); + payload[24] ^= 1; + CHECK(image_rx_dispatch(&ctx, b64 ? "1 1 1 1" : "0 1 1 1", NULL, payload, 24 + n) == TAI_PROTO_ERR_IMAGE_AUTH); + CHECK(image_rx.calls == 1 && image_rx.allocs == image_rx.frees); + } + PASS(); +} + +static void test_image_receive_malformed(void) +{ + TEST("image params, Event IDs and Base64 reject ambiguity"); + static struct tai_ctx ctx; + pal_t pal; + uint8_t payload[160]; + image_rx_init(&ctx, &pal); + size_t len = image_rx_payload(payload, TAI_STREAM_ONE_SHOT, 0); + const char *bad[] = {NULL, "", "0 1 1", "0 1 1 1 2", "-1 1 1 1", "+0 1 1 1", + "3 1 1 1", "0 256 1 1", "0 1 65536 1", "0 1 1 65536", "0 1 4294967296 1", + "0 1 18446744073709551616 1", "0 1 1 1x", "0 1 1\t1"}; + for (size_t i = 0; i < sizeof(bad) / sizeof(bad[0]); i++) + CHECK(image_rx_dispatch(&ctx, bad[i], NULL, payload, len) == TAI_PROTO_ERR_PKT_DECODE); + tai_attr_t attrs[3] = {tai_attr_strv(TAI_ATTR_IMAGE_PARAMS, "0 1 1 1"), + tai_attr_strv(TAI_ATTR_IMAGE_PARAMS, "0 1 1 1")}; + CHECK(tai_proto_dispatch(&ctx, TAI_PKT_IMAGE, attrs, 2, payload, len) == TAI_PROTO_ERR_PKT_DECODE); + const uint8_t embedded[] = {'0',' ','1',' ','1',' ','1',0,'2'}; + attrs[0] = tai_attr_bytesv(TAI_ATTR_IMAGE_PARAMS, embedded, sizeof(embedded)); + CHECK(tai_proto_dispatch(&ctx, TAI_PKT_IMAGE, attrs, 1, payload, len) == TAI_PROTO_ERR_PKT_DECODE); + attrs[0] = tai_attr_strv(TAI_ATTR_IMAGE_PARAMS, "0 1 1 1"); + char long_id[65]; memset(long_id, 'a', 64); long_id[64] = 0; + CHECK(image_rx_dispatch(&ctx, "0 1 1 1", long_id, payload, len) == TAI_PROTO_ERR_PKT_DECODE); + CHECK(image_rx_dispatch(&ctx, "0 1 1 1", "", payload, len) == TAI_PROTO_ERR_PKT_DECODE); + attrs[1] = tai_attr_strv(TAI_ATTR_EVENT_ID, "event-a"); + attrs[2] = attrs[1]; + CHECK(tai_proto_dispatch(&ctx, TAI_PKT_IMAGE, attrs, 3, payload, len) == TAI_PROTO_ERR_PKT_DECODE); + attrs[1] = tai_attr_bytesv(TAI_ATTR_EVENT_ID, (const uint8_t *)"a\0b", 3); + CHECK(tai_proto_dispatch(&ctx, TAI_PKT_IMAGE, attrs, 2, payload, len) == TAI_PROTO_ERR_PKT_DECODE); + /* An overlong Event must not truncate into a later matching image identity. */ + uint8_t evt[2] = {0, TAI_EVT_START}; + attrs[0] = tai_attr_strv(TAI_ATTR_EVENT_ID, long_id); + CHECK(tai_proto_dispatch(&ctx, TAI_PKT_EVENT, attrs, 1, evt, 2) == TAI_PROTO_ERR_PKT_DECODE); + CHECK(ctx.rx_event_id[0] == 0); + const char *bad64[] = {"A", "AAA", "====", "AA=A", "A===", "AA==AAAA", "AB==", "AAB=", + "AAAA\n", " AAA", "AA-A", "AA_A", "AA==", "AAAA"}; + for (size_t i = 0; i < sizeof(bad64) / sizeof(bad64[0]); i++) { + size_t n = strlen(bad64[i]); + memcpy(payload + 24, bad64[i], n); + CHECK(image_rx_dispatch(&ctx, "1 1 1 1", NULL, payload, 24 + n) == TAI_PROTO_ERR_PKT_DECODE); + } + CHECK(image_rx.calls == 0 && image_rx.allocs == image_rx.frees); + for (size_t n = 1; n < 16; n++) { + size_t encoded = 0; + CHECK(mbedtls_base64_encode(payload + 24, 128, &encoded, image_body, n) == 0); + CHECK(image_rx_dispatch(&ctx, "1 1 1 1", NULL, payload, 24 + encoded) == TAI_PROTO_ERR_PKT_DECODE); + CHECK(image_rx.calls == 0 && image_rx.allocs == 0); + } + /* Exactly bounded, non-NUL-terminated params, including maximum values. */ + const uint8_t valid[] = {'0',' ','2','5','5',' ','6','5','5','3','5',' ','6','5','5','3','5'}; + attrs[0] = tai_attr_bytesv(TAI_ATTR_IMAGE_PARAMS, valid, sizeof(valid)); + len = image_rx_payload(payload, TAI_STREAM_ONE_SHOT, 0); + CHECK(tai_proto_dispatch(&ctx, TAI_PKT_IMAGE, attrs, 1, payload, len) == TAI_OK); + CHECK(image_rx.calls == 1 && image_rx.msg.format == 255 && image_rx.msg.width == 65535 && image_rx.msg.height == 65535); + PASS(); +} + +static void test_image_receive_failures(void) +{ + TEST("image tampering and allocation failure never deliver"); + static struct tai_ctx ctx; + pal_t pal; + uint8_t payload[160], corrupt[sizeof(image_body)]; + for (int b64 = 0; b64 <= 1; b64++) { + for (size_t i = 0; i < 16 + sizeof(image_body); i++) { + image_rx_init(&ctx, &pal); + size_t len = image_rx_payload(payload, TAI_STREAM_ONE_SHOT, b64); + if (i < 16) payload[8 + i] ^= 1; + else { + memcpy(corrupt, image_body, sizeof(corrupt)); + corrupt[i - 16] ^= 1; + size_t n = sizeof(corrupt); + if (b64) CHECK(mbedtls_base64_encode(payload + 24, 128, &n, corrupt, sizeof(corrupt)) == 0); + else memcpy(payload + 24, corrupt, n); + len = 24 + n; + } + CHECK(image_rx_dispatch(&ctx, b64 ? "1 1 1 1" : "0 1 1 1", NULL, payload, len) == TAI_PROTO_ERR_IMAGE_AUTH); + CHECK(image_rx.calls == 0 && image_rx.allocs == image_rx.frees && !ctx.rx_image_active); + } + for (int fail = 1; fail <= b64 + 1; fail++) { + image_rx_init(&ctx, &pal); + size_t len = image_rx_payload(payload, TAI_STREAM_START, b64); + image_rx.fail_at = fail; + CHECK(image_rx_dispatch(&ctx, b64 ? "1 1 1 1" : "0 1 1 1", NULL, payload, len) == TAI_PROTO_ERR_IMAGE_PROCESSING); + CHECK(image_rx.calls == 0 && image_rx.frees == fail - 1 && !ctx.rx_image_active); + CHECK(!image_rx.alloc[0] && !image_rx.alloc[1]); + } + } + PASS(); +} + /* ------------------------------------------------------------------------- * main * ------------------------------------------------------------------------- */ @@ -592,7 +1492,9 @@ int main(void) { printf("=== Tuya AI Foundation — unit tests ===\n"); - rng_init(&g_stub_pal); /* seed the shared DRBG (stub pal supplies the mutex) */ + test_image_send_only_rng_failure(); + test_image_key_init_failure(); + CHECK(rng_init(&g_stub_pal) == 0); /* seed after constructor failure injection */ test_varint(); test_attrs_v21(); @@ -600,6 +1502,16 @@ int main(void) test_transport(); test_payloads(); test_crypto(); + test_image_gcm(); + test_image_gcm_encrypt(); + test_image_gcm_bounds(); + test_image_key_ownership(); + test_image_connection_guards(); + test_image_receive_types(); + test_image_receive_streams(); + test_image_receive_bounds(); + test_image_receive_malformed(); + test_image_receive_failures(); test_proto_client_hello(); test_hmac_sg(); test_compile_time_log_ceiling(); diff --git a/modules/rtc-tcp-client/test/test_integration.c b/modules/rtc-tcp-client/test/test_integration.c index 3cf0844..4601337 100644 --- a/modules/rtc-tcp-client/test/test_integration.c +++ b/modules/rtc-tcp-client/test/test_integration.c @@ -25,8 +25,12 @@ #include "../src/tai_internal.h" #include "tai_pal_loopback.h" #include "test_log.h" +#include "mbedtls/gcm.h" +#include "mbedtls/base64.h" +#include "mbedtls/md.h" extern const pal_t *tai_pal_posix(void); +#include "keyed_tls_peer.h" /* ========================================================================= * Test harness @@ -353,13 +357,16 @@ static size_t server_send(tai_ctx_t *ctx, uint8_t frame[8300]; int flen = tai_frame_encode(TAI_FRAG_NONE, seq, app, (size_t)alen, - ctx->sign_key, 32, + ctx->sign_key, ctx->sig_len, ctx->pal, frame, sizeof(frame)); if (flen <= 0) { fprintf(stderr, " server_send: frame_encode rc=%d\n", flen); return 0; } - tai_loopback_push_recv(frame, (size_t)flen); + if (ctx->has_image_key) { + frame[0] = (uint8_t)((frame[0] & 0xf0) | 3); + if (keyed_peer_write(frame, (size_t)flen)) return 0; + } else tai_loopback_push_recv(frame, (size_t)flen); return (size_t)flen; } @@ -863,6 +870,468 @@ static void test_image_audio_query(void) tai_ctx_deinit(ctx); } +/* Send-only stub: no tai_connect, handshake, receive worker or signing bypass. + * These buffers deliberately exceed the SDK's receive reassembly capacity. */ +#define IMAGE_UPLINK_LARGE (100003U + AGENTIC_KIT_TAI_FRAG_BUF_SIZE + \ + 2U * AGENTIC_KIT_TAI_MAX_FRAGMENT_PAYLOAD) +static struct { + uint8_t wire[2 * IMAGE_UPLINK_LARGE + 16384]; + size_t wire_len; + int writes, fail_write, allocs, frees, fail_alloc, keyed; + size_t sizes[8]; + void *blocks[8]; +} image_capture; +static pal_t image_capture_pal; +static const uint8_t image_uplink_key[16] = { + 0x00, 0x91, 0x02, 0x83, 0x04, 0x75, 0x06, 0x67, + 0x08, 0x59, 0x0a, 0x4b, 0x0c, 0x3d, 0x0e, 0x2f +}; +static const char image_uplink_text[] = "describe\0this image"; + +static uint64_t image_capture_time(void) { return UINT64_C(0x0102030405); } + +static void *image_capture_malloc(size_t size) +{ + int i = image_capture.allocs++; + CHECK(i < 8); + if (i >= 8) return NULL; + image_capture.sizes[i] = size; + if (i == image_capture.fail_alloc) return NULL; + /* A broken overflow guard must fail, not ask the host for exabytes. */ + if (size > sizeof(image_capture.wire)) return NULL; + uint8_t *p = malloc(size + 16); + if (p) memset(p, 0xa5, size + 16); + image_capture.blocks[i] = p; + return p; +} + +static void image_capture_free(void *ptr) +{ + if (!ptr) return; + for (int i = 0; i < 8; i++) { + if (image_capture.blocks[i] != ptr) continue; + uint8_t *p = ptr; + size_t size = image_capture.sizes[i]; + int guard_ok = 1, wiped = 1; + for (size_t j = 0; j < 16; j++) guard_ok &= p[size + j] == 0xa5; + CHECK(guard_ok); + if (image_capture.keyed && i == 0) { + for (size_t j = 0; j < size; j++) wiped &= p[j] == 0; + CHECK(wiped); /* inspect BEFORE free, including IV and tag */ + } + image_capture.frees++; + image_capture.blocks[i] = NULL; + free(ptr); + return; + } + CHECK(0); /* untracked pointer or double free */ +} + +static int image_capture_send(void *handle, const uint8_t *buf, size_t len, + uint32_t timeout_ms) +{ + (void)timeout_ms; + CHECK(handle == &image_capture); + int index = image_capture.writes++; + if (index == image_capture.fail_write) return PAL_ERR_NET; + CHECK(len <= sizeof(image_capture.wire) - image_capture.wire_len); + if (len > sizeof(image_capture.wire) - image_capture.wire_len) return PAL_ERR_NET; + memcpy(image_capture.wire + image_capture.wire_len, buf, len); + image_capture.wire_len += len; + return (int)len; +} + +static void image_capture_reset(int keyed) +{ + for (int i = 0; i < 8; i++) { + CHECK(image_capture.blocks[i] == NULL); + free(image_capture.blocks[i]); /* contain a leaking implementation */ + image_capture.blocks[i] = NULL; + } + memset(image_capture.sizes, 0, sizeof(image_capture.sizes)); + image_capture.wire_len = 0; + image_capture.writes = image_capture.allocs = image_capture.frees = 0; + image_capture.fail_write = image_capture.fail_alloc = -1; + image_capture.keyed = keyed; +} + +static tai_ctx_t *image_uplink_setup(tai_ctx_t *storage, int keyed) +{ + image_capture_reset(keyed); + tai_config_t cfg = { + .pal = tai_pal_posix(), .disable_tls = 1, + .protocol_version = TAI_VER_21, .sign_level = TAI_SIGN_HMAC_SHA256, + .image_key = keyed ? image_uplink_key : NULL, + .event_user_data_json = "{}", + }; + tai_ctx_t *ctx = tai_ctx_init(storage, &cfg); + CHECK(ctx != NULL); + if (!ctx) return NULL; + image_capture_pal = *cfg.pal; + image_capture_pal.tcp_send = image_capture_send; + image_capture_pal.time_ms = image_capture_time; + image_capture_pal.malloc = image_capture_malloc; + image_capture_pal.free = image_capture_free; + ctx->pal = &image_capture_pal; + ctx->raw_tcp = &image_capture; + ctx->connected = ctx->session_open = 1; + strcpy(ctx->session_id, "send-only-session"); + for (size_t i = 0; i < sizeof(ctx->sign_key); i++) { + ctx->sign_key[i] = (uint8_t)(0x30 + i); + ctx->encrypt_key[i] = (uint8_t)(0xe0 + i); + } + CHECK_EQ_INT(ctx->frame_ver, keyed ? 3 : 2); + CHECK_EQ_INT(ctx->sig_len, keyed ? 0 : 32); + CHECK_EQ_INT(ctx->sign_level, keyed ? TAI_SIGN_NONE : TAI_SIGN_HMAC_SHA256); + return ctx; +} + +static void image_uplink_teardown(tai_ctx_t *ctx) +{ + /* The stub handle is not a real POSIX connection. */ + ctx->raw_tcp = NULL; + tai_ctx_deinit(ctx); + image_capture_reset(0); +} + +/* api: 0 = image, 1 = image + text, 2 = image + audio start. */ +static int image_uplink_send(tai_ctx_t *ctx, int api, const uint8_t *image, size_t len) +{ + if (api == 0) return tai_send_image(ctx, image, len, TAI_IMG_JPEG, 640, 480); + if (api == 1) return tai_send_image_with_text(ctx, + image_uplink_text, sizeof(image_uplink_text) - 1, + image, len, TAI_IMG_JPEG, 640, 480); + return tai_send_image_audio_start(ctx, image, len, TAI_IMG_JPEG, 640, 480, + TAI_AUDIO_PCM, 1, 16, 16000); +} + +static uint32_t image_wire_uint(const uint8_t *p, size_t n) +{ + uint32_t value = 0; + while (n--) value = (value << 8) | *p++; + return value; +} + +/* Independent peer decoder: no production Frame/Packet decode, HMAC or + * image-decrypt helpers. Consume one whole Packet, checking every Frame. */ +static int image_peer_next(const tai_ctx_t *ctx, size_t *offset, uint16_t *seq, + uint8_t *app, size_t cap, captured_pkt_t *pkt, + int *frames) +{ + size_t used = 0; + *frames = 0; + for (;;) { + if (image_capture.wire_len - *offset < 5) return -1; + const uint8_t *f = image_capture.wire + *offset; + size_t flen = 5 + image_wire_uint(f + 3, 2); + const size_t sig_len = ctx->has_image_key ? 0 : 32; + if ((f[0] & 0x3f) != (ctx->has_image_key ? 0x23 : 0x22) || + flen < 6 + sig_len || flen > image_capture.wire_len - *offset) return -1; + if (image_wire_uint(f + 1, 2) != *seq) return -1; + if (++*seq == 0) *seq = 1; + size_t signed_len = flen - sig_len; + uint8_t sample[64], signature[32]; + const uint8_t *input = f; + size_t input_len = signed_len; + if (signed_len > 64) { + memcpy(sample, f, 32); + memcpy(sample + 32, f + signed_len - 32, 32); + input = sample; + input_len = sizeof(sample); + } + int rc = mbedtls_md_hmac(mbedtls_md_info_from_type(MBEDTLS_MD_SHA256), + ctx->sign_key, 32, input, input_len, signature); + if (sig_len && (rc || memcmp(signature, f + signed_len, sig_len))) return -1; + unsigned frag = f[0] >> 6; + if ((*frames == 0 && frag != TAI_FRAG_NONE && frag != TAI_FRAG_FIRST) || + (*frames != 0 && frag != TAI_FRAG_MIDDLE && frag != TAI_FRAG_LAST)) return -1; + size_t n = signed_len - 5; + if (n > AGENTIC_KIT_TAI_MAX_FRAGMENT_PAYLOAD || n > cap - used) return -1; + memcpy(app + used, f + 5, n); + used += n; + *offset += flen; + ++*frames; + if (frag == TAI_FRAG_NONE || frag == TAI_FRAG_LAST) break; + } + memset(pkt, 0, sizeof(*pkt)); + if (!used) return -1; + pkt->pkt_type = app[0] >> 1; + size_t pos = 1; + if (app[0] & 1) { + if (used < 5) return -1; + size_t attr_len = image_wire_uint(app + 1, 4); + if (attr_len > used - 5) return -1; + size_t end = 5 + attr_len; + pos = 5; + while (pos < end) { + if (end - pos < 4 || pkt->attr_count == AGENTIC_KIT_TAI_MAX_ATTRS) return -1; + tai_attr_t *a = &pkt->attrs[pkt->attr_count++]; + a->type = (uint16_t)image_wire_uint(app + pos, 2); + a->len = (uint16_t)image_wire_uint(app + pos + 2, 2); + pos += 4; + if (a->len > end - pos) return -1; + a->value = app + pos; + pos += a->len; + } + } + pkt->payload = app + pos; + pkt->payload_len = used - pos; + return 0; +} + +static void image_peer_check(tai_ctx_t *ctx, int api, const uint8_t *expected, + size_t len, uint16_t first_seq, uint8_t iv[16]) +{ + static uint8_t app[IMAGE_UPLINK_LARGE + 4096], plain[IMAGE_UPLINK_LARGE]; + const uint8_t types[] = { TAI_PKT_EVENT, TAI_PKT_TEXT, TAI_PKT_IMAGE, + TAI_PKT_EVENT, TAI_PKT_EVENT }; + size_t offset = 0; + int count = api == 2 ? 2 : (api == 1 ? 5 : 4); + for (int i = 0; i < count; i++) { + captured_pkt_t pkt; + int frames = 0; + int rc = image_peer_next(ctx, &offset, &first_seq, app, sizeof(app), &pkt, &frames); + CHECK_EQ_INT(rc, 0); + if (rc) return; + int index = i + (api != 1 && i > 0); + CHECK_EQ_INT(pkt.pkt_type, types[index]); + if (pkt.pkt_type == TAI_PKT_EVENT) { + CHECK_EQ_INT(pkt.payload_len, 2); + if (pkt.payload_len == 2) + CHECK_EQ_INT(image_wire_uint(pkt.payload, 2), + i == 0 ? TAI_EVT_START : (i == count - 1 ? TAI_EVT_END : TAI_EVT_PAYLOADS_END)); + } else if (pkt.pkt_type == TAI_PKT_TEXT) { + const uint8_t header[] = { 0, TAI_DATA_ID_TEXT_UP, 0, 0 }; + CHECK_EQ_INT(pkt.payload_len, 4 + sizeof(image_uplink_text) - 1); + if (pkt.payload_len == 4 + sizeof(image_uplink_text) - 1) { + CHECK(memcmp(pkt.payload, header, 4) == 0); + CHECK(memcmp(pkt.payload + 4, image_uplink_text, sizeof(image_uplink_text) - 1) == 0); + } + } else if (pkt.pkt_type == TAI_PKT_IMAGE) { + const uint8_t header[] = { 0, TAI_DATA_ID_IMAGE_UP, 0, 1, 2, 3, 4, 5 }; + CHECK_EQ_INT(pkt.attr_count, 1); + if (pkt.attr_count == 1) { + CHECK_EQ_INT(pkt.attrs[0].type, TAI_ATTR_IMAGE_PARAMS); + CHECK_EQ_INT(pkt.attrs[0].len, 11); + if (pkt.attrs[0].len == 11) + CHECK(memcmp(pkt.attrs[0].value, "0 1 640 480", 11) == 0); + } + size_t extra = ctx->has_image_key ? (len ? 32 : 16) : 0; + CHECK_EQ_INT(pkt.payload_len, 8 + len + extra); + if (pkt.payload_len != 8 + len + extra) return; + CHECK(memcmp(pkt.payload, header, ctx->disable_tls ? sizeof(header) : 3) == 0); + if (len == IMAGE_UPLINK_LARGE) { + CHECK(len > sizeof(ctx->frag_buf)); + CHECK(frames >= 3); + } + const uint8_t *body = pkt.payload + 8; + if (!ctx->has_image_key) { + CHECK(memcmp(body, expected, len) == 0); + continue; + } + memcpy(iv, body, 16); + if (!len) continue; /* empty marker is IV only, never a tag */ + mbedtls_gcm_context gcm; + mbedtls_gcm_init(&gcm); + rc = mbedtls_gcm_setkey(&gcm, MBEDTLS_CIPHER_ID_AES, image_uplink_key, 128); + CHECK_EQ_INT(rc, 0); + if (!rc) { + rc = mbedtls_gcm_auth_decrypt(&gcm, len, body, 16, NULL, 0, + body + 16 + len, 16, body + 16, plain); + CHECK_EQ_INT(rc, 0); + if (!rc) CHECK(memcmp(plain, expected, len) == 0); + } + /* The derived Frame encryption key must NOT authenticate the image. */ + rc = mbedtls_gcm_setkey(&gcm, MBEDTLS_CIPHER_ID_AES, ctx->encrypt_key, 128); + CHECK_EQ_INT(rc, 0); + if (!rc) CHECK_EQ_INT(mbedtls_gcm_auth_decrypt(&gcm, len, body, 16, NULL, 0, + body + 16 + len, 16, body + 16, plain), MBEDTLS_ERR_GCM_AUTH_FAILED); + mbedtls_gcm_free(&gcm); + } + } + CHECK_EQ_INT(offset, image_capture.wire_len); +} + +static void image_uplink_check_allocs(size_t len, int keyed) +{ + /* EventStart already allocates its JSON wrapper, even without an image key. */ + CHECK_EQ_INT(image_capture.allocs, keyed ? 2 : 1); + CHECK_EQ_INT(image_capture.frees, image_capture.allocs); + if (keyed) CHECK_EQ_INT(image_capture.sizes[0], len ? len + 32 : 16); + CHECK_EQ_INT(image_capture.sizes[keyed ? 1 : 0], strlen("chatAttributes") + 4 + 32); + for (int i = 0; i < 8; i++) CHECK(image_capture.blocks[i] == NULL); +} + +static void test_image_uplink_encrypted(void) +{ + SECTION("image_uplink_encrypted"); + static tai_ctx_t storage; + static uint8_t image[IMAGE_UPLINK_LARGE], original[IMAGE_UPLINK_LARGE]; + for (size_t i = 0; i < sizeof(image); i++) original[i] = (uint8_t)(i * 37 + i / 251); + const size_t lengths[] = { 0, 1, 17, 259, IMAGE_UPLINK_LARGE }; + for (int api = 0; api < 3; api++) { + tai_ctx_t *ctx = image_uplink_setup(&storage, 1); + if (!ctx) return; + for (size_t j = 0; j < sizeof(lengths) / sizeof(lengths[0]); j++) { + uint8_t previous_iv[16] = {0}; + for (int repeat = 0; repeat < 2; repeat++) { + image_capture_reset(1); + memcpy(image, original, sizeof(image)); + uint16_t seq = (uint16_t)(ctx->seq + 1); + CHECK_EQ_INT(image_uplink_send(ctx, api, image, lengths[j]), TAI_OK); + CHECK(memcmp(image, original, sizeof(image)) == 0); + uint8_t iv[16] = {0}; + image_peer_check(ctx, api, original, lengths[j], seq, iv); + if (repeat) CHECK(memcmp(iv, previous_iv, 16) != 0); + memcpy(previous_iv, iv, 16); + CHECK(memcmp(ctx->image_key, image_uplink_key, 16) == 0); + image_uplink_check_allocs(lengths[j], 1); + CHECK_EQ_INT(ctx->event_open, api == 2); + if (api == 2) CHECK_EQ_INT(tai_send_image_audio_end(ctx), TAI_OK); + } + } + image_uplink_teardown(ctx); + } +} + +static void test_image_uplink_legacy(void) +{ + SECTION("image_uplink_legacy"); + static tai_ctx_t storage; + static uint8_t image[IMAGE_UPLINK_LARGE], original[IMAGE_UPLINK_LARGE]; + for (size_t i = 0; i < sizeof(image); i++) original[i] = (uint8_t)(i * 19 + 7); + const size_t lengths[] = { 0, 1, 17, 259, IMAGE_UPLINK_LARGE }; + for (int api = 0; api < 3; api++) { + for (size_t j = 0; j < sizeof(lengths) / sizeof(lengths[0]); j++) { + tai_ctx_t *ctx = image_uplink_setup(&storage, 0); + if (!ctx) return; + memcpy(image, original, sizeof(image)); + CHECK_EQ_INT(image_uplink_send(ctx, api, image, lengths[j]), TAI_OK); + image_peer_check(ctx, api, original, lengths[j], 1, NULL); + CHECK(memcmp(image, original, sizeof(image)) == 0); + image_uplink_check_allocs(lengths[j], 0); + image_uplink_teardown(ctx); + } + } +} + +static void test_image_uplink_prepare_failures(void) +{ + SECTION("image_uplink_prepare_failures"); + static tai_ctx_t storage; + const uint8_t image[17] = { 0x91, 0, 0x73 }; + static char oversized[AGENTIC_KIT_TAI_TX_CTRL_BUF_SIZE]; + memset(oversized, '"', sizeof(oversized) - 1); + for (int api = 0; api < 3; api++) { + for (int empty = 0; empty < 2; empty++) { + for (int failure = 0; failure < 3; failure++) { + tai_ctx_t *ctx = image_uplink_setup(&storage, 1); + if (!ctx) return; + size_t len = empty ? 0 : sizeof(image); + /* First allocation: image; second: EventStart JSON. The third + * scenario builds an oversized EventStart after encryption. */ + if (failure < 2) image_capture.fail_alloc = failure; + else ctx->event_user_data_json = oversized; + CHECK_EQ_INT(image_uplink_send(ctx, api, image, len), TAI_ERR_MEM); + CHECK_EQ_INT(image_capture.writes, 0); + CHECK_EQ_INT(image_capture.wire_len, 0); + CHECK_EQ_INT(ctx->event_open, 0); + CHECK_EQ_INT(ctx->seq, 0); + CHECK_EQ_INT(image_capture.allocs, failure == 0 ? 1 : 2); + CHECK_EQ_INT(image_capture.frees, failure); + CHECK_EQ_INT(image_capture.sizes[0], len ? len + 32 : 16); + if (failure == 0) CHECK(ctx->event_id[0] == '\0'); + image_uplink_teardown(ctx); + } + } + } +} + +static void test_image_uplink_write_failures(void) +{ + SECTION("image_uplink_write_failures"); + static tai_ctx_t storage; + static uint8_t image[IMAGE_UPLINK_LARGE], original[IMAGE_UPLINK_LARGE]; + for (size_t i = 0; i < sizeof(image); i++) original[i] = (uint8_t)(i * 13 + 9); + const size_t lengths[] = { 0, 17, IMAGE_UPLINK_LARGE }; + for (int api = 0; api < 3; api++) { + for (size_t j = 0; j < sizeof(lengths) / sizeof(lengths[0]); j++) { + tai_ctx_t *ctx = image_uplink_setup(&storage, 1); + if (!ctx) return; + memcpy(image, original, sizeof(image)); + CHECK_EQ_INT(image_uplink_send(ctx, api, image, lengths[j]), TAI_OK); + int writes = image_capture.writes; + CHECK(writes > 0); + image_uplink_check_allocs(lengths[j], 1); + image_uplink_teardown(ctx); + for (int fail = 0; fail < writes; fail++) { + ctx = image_uplink_setup(&storage, 1); + if (!ctx) return; + image_capture.fail_write = fail; + CHECK_EQ_INT(image_uplink_send(ctx, api, image, lengths[j]), TAI_ERR_NET); + CHECK_EQ_INT(image_capture.writes, fail + 1); + if (!fail) CHECK_EQ_INT(image_capture.wire_len, 0); + CHECK(memcmp(image, original, sizeof(image)) == 0); + image_uplink_check_allocs(lengths[j], 1); + image_uplink_teardown(ctx); + } + } + } +} + +static void test_image_uplink_argument_bounds(void) +{ + SECTION("image_uplink_argument_bounds"); + static tai_ctx_t storage; + const uint8_t image = 0x7a; + for (int keyed = 0; keyed < 2; keyed++) { + const size_t overhead = sizeof(storage.tx_hdr_buf) + (keyed ? 32 : 0); + const size_t invalid[] = { SIZE_MAX, SIZE_MAX - overhead + 1, + (size_t)PTRDIFF_MAX, (size_t)PTRDIFF_MAX - overhead + 1 }; + for (int api = 0; api < 3; api++) { + tai_ctx_t *ctx = image_uplink_setup(&storage, keyed); + if (!ctx) return; + for (size_t i = 0; i < sizeof(invalid) / sizeof(invalid[0]); i++) { + CHECK_EQ_INT(image_uplink_send(ctx, api, &image, invalid[i]), TAI_ERR_ARGS); + } + if (api == 1) { + const size_t text_invalid[] = { SIZE_MAX, + SIZE_MAX - sizeof(ctx->tx_hdr_buf) + 1, + (size_t)PTRDIFF_MAX - sizeof(ctx->tx_hdr_buf) + 1 }; + for (size_t i = 0; i < sizeof(text_invalid) / sizeof(text_invalid[0]); i++) + CHECK_EQ_INT(tai_send_image_with_text(ctx, "x", text_invalid[i], + &image, 1, TAI_IMG_JPEG, 640, 480), TAI_ERR_ARGS); + } + CHECK_EQ_INT(image_uplink_send(ctx, api, NULL, 0), TAI_ERR_ARGS); + CHECK_EQ_INT(image_uplink_send(ctx, api, NULL, 1), TAI_ERR_ARGS); + CHECK_EQ_INT(image_capture.allocs, 0); + CHECK_EQ_INT(image_capture.writes, 0); + CHECK_EQ_INT(image_capture.wire_len, 0); + CHECK_EQ_INT(ctx->seq, 0); + CHECK_EQ_INT(ctx->event_open, 0); + CHECK(ctx->event_id[0] == '\0'); + if (keyed) { + /* Accept the exact arithmetic boundary (and >4 GiB), but fail + * allocation before ever reading the one-byte sentinel. */ + size_t limit = SIZE_MAX < (size_t)PTRDIFF_MAX + ? SIZE_MAX - overhead : (size_t)PTRDIFF_MAX - overhead; + size_t valid[] = { limit, + limit > UINT32_MAX ? (size_t)UINT32_MAX + 1 : limit }; + for (size_t i = 0; i < sizeof(valid) / sizeof(valid[0]); i++) { + image_capture_reset(1); + image_capture.fail_alloc = 0; + CHECK_EQ_INT(image_uplink_send(ctx, api, &image, valid[i]), TAI_ERR_MEM); + CHECK_EQ_INT(image_capture.allocs, 1); + CHECK(image_capture.sizes[0] == valid[i] + 32); + CHECK_EQ_INT(image_capture.writes, 0); + CHECK(ctx->event_id[0] == '\0'); + } + } + image_uplink_teardown(ctx); + } + } +} + /* ========================================================================= * Test 4: server EOF triggers on_disconnect * ========================================================================= */ @@ -2684,12 +3153,481 @@ static void test_pal_sleep(void) close(listener); } +/* Keyed parser tests use a real verified TLS Connection. Legacy comparison + * cases retain the loopback PAL. Ciphertext is the independent vector in core. */ +static const uint8_t parser_image_key[TAI_IMAGE_KEY_LEN] = { + 0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15 +}; +static const uint8_t parser_image_body[] = { + 0xc5,0x61,0x77,0xb7,0x7d,0xb3,0x26,0x8b,0xd2,0x1d,0xc5,0x85,0xa9,0x26,0x25,0xdc, + 0xf9,0xf0,0x2e,0x90,0x7d,0x17,0xb5,0xeb,0xbd,0x93,0x91,0x81,0x5b,0x5a,0x69,0xfb, + 0x3f,0x39,0x75,0x18,0xc1,0xe0,0x83,0x79,0x48,0xa8,0x6f,0x6a,0xde,0xeb,0x9f,0x2f, + 0x76,0x14,0x26,0x8f,0x70 +}; +static const uint8_t parser_image_plain[] = { + 0,255,128,1,0,1,2,3,4,5,6,7,8,9,10,11,12,13,14, + 15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32 +}; +static struct { + int released, queries, active, frag_state, fail_alloc, allocs, frees; + size_t frag_len, len; + uint8_t bytes[AGENTIC_KIT_TAI_FRAG_BUF_SIZE]; + uint8_t type, flag, format; + uint16_t data_id, width, height; + uint64_t timestamp; + char event[64]; + pthread_t callback_thread; + uint8_t connection_alive; +} image_parser; + +static int image_parser_flow(tai_ctx_t *ctx, void *ud) +{ + (void)ud; + pthread_mutex_lock(&g_st.mtx); + image_parser.queries++; + image_parser.frag_len = ctx->frag_len; + image_parser.frag_state = ctx->frag_state; + image_parser.active = ctx->rx_image_active; + int ready = image_parser.released; + pthread_mutex_unlock(&g_st.mtx); + return ready; +} + +static void image_parser_callback(tai_ctx_t *ctx, const tai_image_msg_t *msg, void *ud) +{ + (void)ctx; (void)ud; + pthread_mutex_lock(&g_st.mtx); + image_parser.len = msg->len; + if (msg->len <= sizeof(image_parser.bytes)) + memcpy(image_parser.bytes, msg->data, msg->len); + image_parser.type = msg->payload_type; + image_parser.flag = msg->stream_flag; + image_parser.format = msg->format; + image_parser.width = msg->width; + image_parser.height = msg->height; + image_parser.data_id = msg->data_id; + image_parser.timestamp = msg->timestamp_ms; + snprintf(image_parser.event, sizeof(image_parser.event), "%s", msg->event_id); + image_parser.callback_thread = pthread_self(); + g_st.image_calls++; + pthread_mutex_unlock(&g_st.mtx); +} + +static void image_parser_disconnect(tai_ctx_t *ctx, const tai_disconnect_msg_t *msg, void *ud) +{ + pthread_mutex_lock(&g_st.mtx); + image_parser.connection_alive = msg->connection_alive; + image_parser.active = ctx->rx_image_active; + pthread_mutex_unlock(&g_st.mtx); + on_disconnect(ctx, msg, ud); +} + +static void *image_parser_malloc(size_t n) +{ + pthread_mutex_lock(&g_st.mtx); + int fail = ++image_parser.allocs == image_parser.fail_alloc; + pthread_mutex_unlock(&g_st.mtx); + return fail ? NULL : malloc(n); +} + +static void image_parser_free(void *p) +{ + pthread_mutex_lock(&g_st.mtx); + image_parser.frees++; + pthread_mutex_unlock(&g_st.mtx); + free(p); +} + +static tai_ctx_t *image_parser_setup(tai_ctx_t *storage, pal_t *pal, int keyed) +{ + memset(&image_parser, 0, sizeof(image_parser)); + *pal = keyed ? *tai_pal_posix() : *tai_pal_loopback(); + tai_config_t options = { .pal = pal, .on_flow_control = image_parser_flow }; + tai_ctx_t *ctx; + if (keyed) { + st_reset(); + int started = keyed_peer_start(0); + CHECK_EQ_INT(started, 0); + if (started) return NULL; + options.host = "127.0.0.1"; + options.tls_sni = "localhost"; + options.port = keyed_peer.port; + options.ca_cert = keyed_ca; + options.image_key = parser_image_key; + options.device_id = "keyed-test-device"; + options.on_text = on_text; + options.on_event = on_event; + ctx = tai_ctx_init(storage, &options); + } else ctx = setup_ctx_config(storage, &options); + CHECK(ctx != NULL); + if (!ctx) return NULL; + ctx->on_image = image_parser_callback; + ctx->on_disconnect = image_parser_disconnect; + int rc = tai_connect(ctx); + if (keyed) { + pthread_join(keyed_peer.thread, NULL); + CHECK_EQ_INT(keyed_peer.error, 0); + } + CHECK_EQ_INT(rc, TAI_OK); + if (rc != TAI_OK) { + tai_ctx_deinit(ctx); + if (keyed) keyed_peer_close(); + return NULL; + } + CHECK(WAIT_FOR(image_parser.queries > 0, 1000)); + pthread_mutex_lock(&g_st.mtx); + pal->malloc = image_parser_malloc; + pal->free = image_parser_free; + image_parser.released = 1; + pthread_mutex_unlock(&g_st.mtx); + return ctx; +} + +static void image_parser_stop(tai_ctx_t *ctx) +{ + tai_request_disconnect(ctx); + if (ctx->thread_handle) { + ctx->pal->thread_join(ctx->thread_handle); + ctx->thread_handle = NULL; + } + pal_t *pal = (pal_t *)ctx->pal; + pal->malloc = malloc; + pal->free = free; + tai_disconnect(ctx); + if (ctx->has_image_key) keyed_peer_close(); +} + +static size_t image_parser_payload(uint8_t *payload, uint8_t flag, int base64) +{ + const uint8_t header[] = {0, 6, 0, 1, 2, 3, 4, 5}; + memcpy(payload, header, sizeof(header)); + payload[2] = flag << 6; + for (int i = 0; i < 16; i++) payload[8 + i] = (uint8_t)(16 + i); + size_t len = sizeof(parser_image_body); + if (base64) { + int rc = mbedtls_base64_encode(payload + 24, 128, &len, + parser_image_body, sizeof(parser_image_body)); + CHECK_EQ_INT(rc, 0); + if (rc) return 0; + } else memcpy(payload + 24, parser_image_body, len); + return 24 + len; +} + +static int image_parser_frame(tai_ctx_t *ctx, uint8_t frag, + const uint8_t *app, size_t len, uint16_t *seq) +{ + uint8_t frame[AGENTIC_KIT_TAI_MAX_FRAGMENT_PAYLOAD + 37]; + int n = tai_frame_encode(frag, (*seq)++, app, len, ctx->sign_key, + ctx->sig_len, ctx->pal, frame, sizeof(frame)); + CHECK(n > 0); + if (n <= 0) return 0; + if (ctx->has_image_key) { + frame[0] = (uint8_t)((frame[0] & 0xf0) | 3); + int rc = keyed_peer_write(frame, (size_t)n); + CHECK_EQ_INT(rc, 0); + return rc == 0; + } + tai_loopback_push_recv(frame, (size_t)n); + return 1; +} + +/* Split the attr prefix, IV and tag for the small vector, and use bounded + * Frames for large Packets. Observe worker-owned reassembly at admission gates, + * not via racy test-thread reads or sleeps that could pass without parsing. */ +static void image_parser_fragmented(tai_ctx_t *ctx, const uint8_t *app, + size_t len, uint16_t *seq) +{ + pthread_mutex_lock(&g_st.mtx); + int calls = g_st.image_calls; + pthread_mutex_unlock(&g_st.mtx); + size_t off = 0; + while (off < len) { + size_t end = off == 0 ? 3 : len; + if (len < 256 && off == 3) end = 5 + tai_r32(app + 1) + 10; + else if (len < 256 && off > 0 && off < len - 7) end = len - 7; + if (end - off > AGENTIC_KIT_TAI_MAX_FRAGMENT_PAYLOAD) + end = off + AGENTIC_KIT_TAI_MAX_FRAGMENT_PAYLOAD; + uint8_t frag = off == 0 ? TAI_FRAG_FIRST + : end == len ? TAI_FRAG_LAST : TAI_FRAG_MIDDLE; + if (!image_parser_frame(ctx, frag, app + off, end - off, seq)) return; + if (end != len) { + CHECK(WAIT_FOR(image_parser.frag_state && image_parser.frag_len == end, 1000)); + pthread_mutex_lock(&g_st.mtx); + CHECK_EQ_INT(g_st.image_calls, calls); + CHECK_EQ_INT(g_st.disconnect_calls, 0); + pthread_mutex_unlock(&g_st.mtx); + } + off = end; + } +} + +static void image_parser_check_fatal(tai_ctx_t *ctx, int detail, int images, int prior_closes) +{ + CHECK(WAIT_FOR(g_st.disconnect_calls > prior_closes, 1000)); + image_parser_stop(ctx); /* join before asserting exactly one terminal callback */ + CHECK_EQ_INT(g_st.disconnect_calls, prior_closes + 1); + CHECK_EQ_INT(g_st.disconnect_reason, TAI_DISCONNECT_PROTOCOL); + CHECK_EQ_INT(g_st.disconnect_detail, detail); + CHECK_EQ_INT(g_st.disconnect_code, 0); + CHECK_EQ_INT(image_parser.connection_alive, 0); + CHECK_EQ_INT(g_st.image_calls, images); + CHECK_EQ_INT(g_st.text_calls, 0); + CHECK_EQ_INT(image_parser.active, 0); + CHECK_EQ_INT(ctx->rx_image_active, 0); + CHECK_EQ_INT(ctx->frag_len, 0); + CHECK_EQ_INT(ctx->frag_state, 0); + CHECK(memcmp(ctx->image_key, parser_image_key, TAI_IMAGE_KEY_LEN) == 0); +} + +static void test_image_parser_fragmented(void) +{ + SECTION("image_TLS_fragmented_RAW_Base64"); + static tai_ctx_t storage; + for (int base64 = 0; base64 <= 1; base64++) { + pal_t pal; + tai_ctx_t *ctx = image_parser_setup(&storage, &pal, 1); + if (!ctx) return; + uint8_t payload[160], app[256]; + size_t len = image_parser_payload(payload, TAI_STREAM_ONE_SHOT, base64); + tai_attr_t attrs[] = { + tai_attr_strv(TAI_ATTR_IMAGE_PARAMS, base64 ? "1 2 640 480" : "0 2 640 480"), + tai_attr_strv(TAI_ATTR_EVENT_ID, "parser-image"), + }; + int n = tai_packet_encode(TAI_VER_21, TAI_PKT_IMAGE, attrs, 2, + payload, len, app, sizeof(app)); + CHECK(n > 0); + uint16_t seq = 100; + if (n > 0) image_parser_fragmented(ctx, app, (size_t)n, &seq); + CHECK(WAIT_FOR(g_st.image_calls == 1, 1000)); + CHECK(WAIT_FOR(!image_parser.active && image_parser.frees == base64 + 1, 1000)); + image_parser_stop(ctx); + CHECK_EQ_INT(image_parser.len, sizeof(parser_image_plain)); + CHECK(memcmp(image_parser.bytes, parser_image_plain, sizeof(parser_image_plain)) == 0); + CHECK_EQ_INT(image_parser.type, TAI_IMG_PAYLOAD_RAW); + CHECK_EQ_INT(image_parser.flag, TAI_STREAM_ONE_SHOT); + CHECK_EQ_INT(image_parser.format, TAI_IMG_PNG); + CHECK_EQ_INT(image_parser.width, 640); + CHECK_EQ_INT(image_parser.height, 480); + CHECK_EQ_INT(image_parser.data_id, 6); + CHECK_EQ_INT(image_parser.timestamp, UINT64_C(0x0102030405)); + CHECK(strcmp(image_parser.event, "parser-image") == 0); + CHECK(!pthread_equal(pthread_self(), image_parser.callback_thread)); + CHECK_EQ_INT(image_parser.allocs, base64 + 1); + CHECK_EQ_INT(image_parser.frees, base64 + 1); + CHECK_EQ_INT(g_st.image_calls, 1); + CHECK_EQ_INT(g_st.disconnect_calls, 0); + tai_ctx_deinit(ctx); + } +} + +static void test_image_parser_rejections(void) +{ + SECTION("image_TLS_fail_fast_and_legacy_attrs"); + static tai_ctx_t storage; + /* 0: tag, 1/2: hidden duplicate params/Event ID, 3: partial TLV header, + * 4: truncated TLV value, 5: block overflow, 6/7/8: scratch allocation. */ + for (int test = 0; test < 9; test++) { + for (int keyed = 1; keyed >= (test >= 1 && test <= 4 ? 0 : 1); keyed--) { + pal_t pal; + tai_ctx_t *ctx = image_parser_setup(&storage, &pal, keyed); + if (!ctx) return; + uint8_t payload[160], app[1024]; + int base64 = test >= 7; + size_t len = image_parser_payload(payload, TAI_STREAM_ONE_SHOT, base64); + tai_attr_t attrs[AGENTIC_KIT_TAI_MAX_ATTRS + 1]; + attrs[0] = tai_attr_strv(TAI_ATTR_IMAGE_PARAMS, base64 ? "1 2 640 480" : "0 2 640 480"); + attrs[1] = tai_attr_strv(TAI_ATTR_EVENT_ID, "parser-image"); + int count = 2; + if (test == 1 || test == 2) { + for (int i = 2; i < AGENTIC_KIT_TAI_MAX_ATTRS; i++) + attrs[i] = tai_attr_bytesv(0xfffe, NULL, 0); + attrs[AGENTIC_KIT_TAI_MAX_ATTRS] = attrs[test - 1]; + count = AGENTIC_KIT_TAI_MAX_ATTRS + 1; + } + if (test == 0) payload[len - 1] ^= 1; /* valid Frame HMAC, bad GCM tag */ + int n = tai_packet_encode(TAI_VER_21, TAI_PKT_IMAGE, attrs, count, + payload, len, app, sizeof(app)); + CHECK(n > 0); + if (n <= 0) { image_parser_stop(ctx); tai_ctx_deinit(ctx); return; } + if (test == 3 || test == 4) { + size_t block = tai_r32(app + 1), pos = 5 + block; + size_t extra = test == 3 ? 3 : 4; + memmove(app + pos + extra, app + pos, (size_t)n - pos); + memset(app + pos, 0, extra); + if (test == 4) tai_w16(app + pos + 2, 1); + tai_w32(app + 1, (uint32_t)(block + extra)); + n += (int)extra; + } else if (test == 5) tai_w32(app + 1, UINT32_MAX); + pthread_mutex_lock(&g_st.mtx); + if (test >= 6) image_parser.fail_alloc = test == 8 ? 2 : 1; + pthread_mutex_unlock(&g_st.mtx); + uint16_t seq = 100; + if (test == 0) image_parser_fragmented(ctx, app, (size_t)n, &seq); + else image_parser_frame(ctx, TAI_FRAG_NONE, app, (size_t)n, &seq); + if (keyed) { + server_send_text(ctx, "must not dispatch", 17, TAI_STREAM_ONE_SHOT, 0, seq++); + image_parser_check_fatal(ctx, test == 0 ? TAI_PROTO_ERR_IMAGE_AUTH + : test >= 6 ? TAI_PROTO_ERR_IMAGE_PROCESSING : TAI_PROTO_ERR_PKT_DECODE, 0, 0); + CHECK_EQ_INT(image_parser.allocs, test == 0 ? 1 : test >= 6 ? (test == 8 ? 2 : 1) : 0); + CHECK_EQ_INT(image_parser.frees, test == 0 || test == 8 ? 1 : 0); + } else { + CHECK(WAIT_FOR(g_st.image_calls == 1, 1000)); + image_parser_stop(ctx); + CHECK_EQ_INT(g_st.disconnect_calls, 0); + CHECK_EQ_INT(image_parser.len, len - 8); + CHECK(memcmp(image_parser.bytes, payload + 8, len - 8) == 0); + } + tai_ctx_deinit(ctx); + } + } +} + +static void test_image_parser_capacity(void) +{ + SECTION("image_TLS_exact_continuation_capacity"); + static tai_ctx_t storage; + static uint8_t app[AGENTIC_KIT_TAI_FRAG_BUF_SIZE + 5]; + static uint8_t plain[AGENTIC_KIT_TAI_FRAG_BUF_SIZE]; + for (int test = 0; test < 4; test++) { + pal_t pal; + tai_ctx_t *ctx = image_parser_setup(&storage, &pal, 1); + if (!ctx) return; + uint8_t start[160]; + image_parser_payload(start, TAI_STREAM_START, 0); + tai_attr_t attrs[] = { + tai_attr_strv(TAI_ATTR_IMAGE_PARAMS, "0 2 640 480"), + tai_attr_strv(TAI_ATTR_EVENT_ID, "parser-image"), + }; + CHECK(server_send(ctx, TAI_PKT_IMAGE, attrs, 2, start, 24, 100) > 0); + CHECK(WAIT_FOR(g_st.image_calls == 1 && image_parser.active, 1000)); + /* Omitted attrs: exact ceiling/+1. Explicit empty block: exact/+1. */ + size_t prefix = test < 2 ? 1 : 5; + size_t total = AGENTIC_KIT_TAI_FRAG_BUF_SIZE + (test & 1); + size_t len = total - prefix - 24 - 16; + for (size_t i = 0; i < len; i++) plain[i] = (uint8_t)(i * 19 + i / 251); + app[0] = (TAI_PKT_IMAGE << 1) | (test >= 2); + if (test >= 2) tai_w32(app + 1, 0); + memcpy(app + prefix, start, 24); + app[prefix + 2] = TAI_STREAM_END << 6; + mbedtls_gcm_context gcm; + mbedtls_gcm_init(&gcm); + int rc = mbedtls_gcm_setkey(&gcm, MBEDTLS_CIPHER_ID_AES, parser_image_key, 128); + if (!rc) rc = mbedtls_gcm_crypt_and_tag(&gcm, MBEDTLS_GCM_ENCRYPT, len, + start + 8, 16, NULL, 0, plain, app + prefix + 24, 16, app + total - 16); + mbedtls_gcm_free(&gcm); + CHECK_EQ_INT(rc, 0); + if (rc) { image_parser_stop(ctx); tai_ctx_deinit(ctx); return; } + uint16_t seq = 101; + image_parser_fragmented(ctx, app, total, &seq); + if (test & 1) image_parser_check_fatal(ctx, TAI_PROTO_ERR_FRAG, 1, 0); + else { + CHECK(WAIT_FOR(g_st.image_calls == 2 && !image_parser.active, 1000)); + image_parser_stop(ctx); + CHECK_EQ_INT(image_parser.len, len); + CHECK(memcmp(image_parser.bytes, plain, len) == 0); + CHECK_EQ_INT(image_parser.flag, TAI_STREAM_END); + CHECK_EQ_INT(image_parser.type, TAI_IMG_PAYLOAD_RAW); + CHECK(strcmp(image_parser.event, "parser-image") == 0); + CHECK_EQ_INT(g_st.disconnect_calls, 0); + CHECK_EQ_INT(g_st.image_calls, 2); + } + tai_ctx_deinit(ctx); + } +} + +static void test_image_parser_resets(void) +{ + SECTION("image_TLS_lifecycle_resets"); + static tai_ctx_t storage; + for (int reset = 0; reset < 7; reset++) { + pal_t pal; + tai_ctx_t *ctx = image_parser_setup(&storage, &pal, 1); + if (!ctx) return; + uint8_t payload[160]; + size_t len = image_parser_payload(payload, TAI_STREAM_START, 0); + tai_attr_t attrs[] = { + tai_attr_strv(TAI_ATTR_IMAGE_PARAMS, "0 2 640 480"), + tai_attr_strv(TAI_ATTR_EVENT_ID, "parser-image"), + }; + CHECK(server_send(ctx, TAI_PKT_IMAGE, attrs, 2, payload, len, 100) > 0); + CHECK(WAIT_FOR(g_st.image_calls == 1 && image_parser.active, 1000)); + if (reset == 4) { + uint8_t app[256]; + int n = tai_packet_encode(TAI_VER_21, TAI_PKT_IMAGE, attrs, 2, + payload, len, app, sizeof(app)); + CHECK(n > 10); + uint16_t seq = 101; + if (n > 10) image_parser_frame(ctx, TAI_FRAG_FIRST, app, 10, &seq); + CHECK(WAIT_FOR(image_parser.frag_state && image_parser.frag_len == 10, 1000)); + image_parser_stop(ctx); + CHECK_EQ_INT(g_st.image_calls, 1); + CHECK_EQ_INT(ctx->frag_len, 0); + CHECK_EQ_INT(ctx->frag_state, 0); + CHECK_EQ_INT(ctx->rx_image_active, 0); + CHECK(ctx->rx_image_event_id[0] == 0 && ctx->rx_event_id[0] == 0); + CHECK(memcmp(ctx->image_key, parser_image_key, TAI_IMAGE_KEY_LEN) == 0); + int started = keyed_peer_start(0); + CHECK_EQ_INT(started, 0); + if (!started) { + ctx->port = keyed_peer.port; + int rc = tai_connect(ctx); + pthread_join(keyed_peer.thread, NULL); + CHECK_EQ_INT(rc, TAI_OK); + CHECK_EQ_INT(keyed_peer.error, 0); + CHECK(memcmp(ctx->image_key, parser_image_key, TAI_IMAGE_KEY_LEN) == 0); + if (rc == TAI_OK) { + payload[2] = TAI_STREAM_ONE_SHOT << 6; + CHECK(server_send(ctx, TAI_PKT_IMAGE, attrs, 2, payload, len, 100) > 0); + CHECK(WAIT_FOR(g_st.image_calls == 2, 1000)); + CHECK(memcmp(image_parser.bytes, parser_image_plain, sizeof(parser_image_plain)) == 0); + } + image_parser_stop(ctx); + } + CHECK_EQ_INT(g_st.disconnect_calls, 0); + } else { + if (reset == 3) { + CHECK(server_send(ctx, TAI_PKT_SESSION_CLOSE, NULL, 0, NULL, 0, 101) > 0); + CHECK(WAIT_FOR(g_st.disconnect_calls == 1, 1000)); + pthread_mutex_lock(&g_st.mtx); + CHECK_EQ_INT(g_st.disconnect_reason, TAI_DISCONNECT_SESSION_CLOSE); + CHECK_EQ_INT(image_parser.connection_alive, 1); + pthread_mutex_unlock(&g_st.mtx); + } else { + /* Same/omitted IDs must reset without relying on an ID change. */ + if (reset == 6) { + uint8_t event[2]; + tai_w16(event, TAI_EVT_CHAT_BREAK); + CHECK(server_send(ctx, TAI_PKT_EVENT, NULL, 0, + event, sizeof(event), 101) > 0); + } else { + CHECK(server_send_event_id(ctx, reset == 0 ? TAI_EVT_END : + reset == 1 ? TAI_EVT_START : TAI_EVT_CHAT_BREAK, + reset == 2 ? "next-event" : "parser-image", 101) > 0); + } + CHECK(WAIT_FOR(g_st.event_calls == 1, 1000)); + if (reset >= 5) + CHECK(strcmp(g_st.last_event_event_id, "parser-image") == 0); + } + CHECK(WAIT_FOR(!image_parser.active, 1000)); + payload[2] = TAI_STREAM_MIDDLE << 6; + CHECK(server_send(ctx, TAI_PKT_IMAGE, NULL, 0, payload, len, 102) > 0); + image_parser_check_fatal(ctx, TAI_PROTO_ERR_UNEXPECTED, 1, reset == 3); + } + tai_ctx_deinit(ctx); + uint8_t zero[TAI_IMAGE_KEY_LEN] = {0}; + CHECK_EQ_INT(ctx->has_image_key, 0); + CHECK(memcmp(ctx->image_key, zero, sizeof(zero)) == 0); + } +} + /* ========================================================================= * main * ========================================================================= */ +#include "test_keyed_connection.h" + int main(void) { - printf("=== Tuya AI -- integration tests (loopback PAL) ===\n"); + printf("=== Tuya AI -- integration tests (loopback PAL and verified TLS) ===\n"); /* Logging is suppressed by default; set TAI_LOOPBACK_VERBOSE=1 to * enable -- the gate the loopback PAL's old lb_log handler applied, * now a mode default of this build's compile-time sink. */ @@ -2707,7 +3645,20 @@ int main(void) test_text_query(); test_audio_roundtrip(); test_image_query(); + test_image_uplink_encrypted(); + test_image_uplink_legacy(); + test_image_uplink_prepare_failures(); + test_image_uplink_write_failures(); + test_image_uplink_argument_bounds(); test_image_recv(); + test_keyed_tls_connection(); + test_keyed_tls_without_trust(); + test_keyed_tls_rejections(); + test_keyed_tls_worker_unknown_version(); + test_image_parser_fragmented(); + test_image_parser_rejections(); + test_image_parser_capacity(); + test_image_parser_resets(); test_image_audio_query(); test_disconnect_eof(); test_session_close_then_transport(); diff --git a/modules/rtc-tcp-client/test/test_keyed_connection.h b/modules/rtc-tcp-client/test/test_keyed_connection.h new file mode 100644 index 0000000..9ddd00c --- /dev/null +++ b/modules/rtc-tcp-client/test/test_keyed_connection.h @@ -0,0 +1,250 @@ +/* Included in the existing integration binary to share its independent peer + * decoder and callback assertions without adding another fixture target. */ +static mbedtls_x509_crt keyed_bundle; +static int keyed_bundle_calls; + +static void keyed_attach_bundle(void *config) +{ + keyed_bundle_calls++; + mbedtls_ssl_conf_ca_chain(config, &keyed_bundle, NULL); +} + +static void keyed_check_handshake(tai_ctx_t *ctx, uint16_t first_seq) +{ + image_capture_reset(0); + memcpy(image_capture.wire, keyed_peer.handshake, keyed_peer.handshake_len); + image_capture.wire_len = keyed_peer.handshake_len; + uint8_t app[8192]; + size_t offset = 0; + captured_pkt_t pkt; + int frames; + int rc = image_peer_next(ctx, &offset, &first_seq, app, sizeof(app), &pkt, &frames); + CHECK_EQ_INT(rc, 0); + if (rc) return; + CHECK_EQ_INT(frames, 1); + CHECK_EQ_INT(pkt.pkt_type, 1); + CHECK_EQ_INT(pkt.payload_len, 0); + CHECK_EQ_INT(pkt.attr_count, 5); + int suites = 0; + for (int i = 0; i < pkt.attr_count; i++) { + const tai_attr_t *a = &pkt.attrs[i]; + const uint16_t types[] = {11, 12, 10, 15, 20}; + if (i < 5) CHECK_EQ_INT(a->type, types[i]); + if (a->type == 11) CHECK(a->len == 1 && a->value[0] == TAI_CLIENT_DEVICE); + if (a->type == 12) CHECK(a->len == 12 && memcmp(a->value, "keyed-device", 12) == 0); + if (a->type == 15) CHECK(a->len == 4 && image_wire_uint(a->value, 4) == AGENTIC_KIT_TAI_MAX_FRAGMENT_PAYLOAD); + if (a->type == 20) CHECK(a->len == 4 && image_wire_uint(a->value, 4) == 60000); + if (a->type != 10) continue; + suites++; + CHECK_EQ_INT(a->len, 36); + if (a->len == 36) { + CHECK_EQ_INT(a->value[0], 0); + CHECK(memcmp(a->value + 1, ctx->encrypt_random, 32) == 0); + const uint8_t tail[] = {4, 3, 3}; + CHECK(memcmp(a->value + 33, tail, 3) == 0); + uint8_t zero[32] = {0}; + CHECK(memcmp(a->value + 1, zero, 32) != 0); + } + } + CHECK_EQ_INT(suites, 1); + rc = image_peer_next(ctx, &offset, &first_seq, app, sizeof(app), &pkt, &frames); + CHECK_EQ_INT(rc, 0); + if (rc) return; + CHECK_EQ_INT(frames, 1); + CHECK_EQ_INT(pkt.pkt_type, 7); + const uint8_t ids[] = {0,6,0,1,0,3,0,5,0,6,0,2,0,4,0,6}; + CHECK_EQ_INT(pkt.payload_len, sizeof(ids)); + if (pkt.payload_len == sizeof(ids)) CHECK(memcmp(pkt.payload, ids, sizeof(ids)) == 0); + CHECK_EQ_INT(offset, image_capture.wire_len); +} + +static struct { int packets, error; } keyed_capture; + +static void *keyed_capture_uplink(void *arg) +{ + (void)arg; + for (int i = 0; i < keyed_capture.packets;) { + size_t pos = image_capture.wire_len; + int n = keyed_read_frame(&keyed_peer, image_capture.wire + pos, + sizeof(image_capture.wire) - pos); + if (n < 0) { keyed_capture.error = -1; break; } + unsigned frag = image_capture.wire[pos] >> 6; + image_capture.wire_len += (size_t)n; + if (frag == 0 || frag == 3) i++; + } + return NULL; +} + +static void test_keyed_tls_connection(void) +{ + SECTION("keyed_verified_TLS_handshake_and_all_image_APIs"); + static tai_ctx_t storage; + static uint8_t image[IMAGE_UPLINK_LARGE]; + for (size_t i = 0; i < sizeof(image); i++) image[i] = (uint8_t)(i * 37 + i / 251); + uint8_t previous_random[32] = {0}; + for (int mode = 0; mode < 3; mode++) { + int bundle = mode == 1; + int started = keyed_peer_start(mode == 2 ? 1 : bundle ? 4 : 0); + CHECK_EQ_INT(started, 0); + if (started) return; + mbedtls_x509_crt_init(&keyed_bundle); + CHECK_EQ_INT(mbedtls_x509_crt_parse(&keyed_bundle, + (const uint8_t *)keyed_ca, strlen(keyed_ca) + 1), 0); + keyed_bundle_calls = 0; + tai_config_t cfg = { + .pal = tai_pal_posix(), .host = "127.0.0.1", .tls_sni = "localhost", + .port = keyed_peer.port, .device_id = "keyed-device", + .image_key = image_uplink_key, .ca_cert = bundle ? NULL : keyed_ca, + .cert_bundle_attach = bundle ? keyed_attach_bundle : NULL, + .event_user_data_json = "{}", .on_disconnect = on_disconnect, + }; + st_reset(); + tai_ctx_t *ctx = tai_ctx_init(&storage, &cfg); + CHECK(ctx != NULL); + int rc = ctx ? tai_connect(ctx) : TAI_ERR_MEM; + pthread_join(keyed_peer.thread, NULL); + CHECK_EQ_INT(rc, TAI_OK); + CHECK_EQ_INT(keyed_peer.error, 0); + CHECK_EQ_INT(keyed_bundle_calls, bundle); + if (rc == TAI_OK) { + CHECK(ctx->tls != NULL && ctx->raw_tcp == NULL && ctx->connected); + CHECK_EQ_INT(ctx->frame_ver, 3); + CHECK_EQ_INT(ctx->sig_len, 0); + CHECK_EQ_INT(ctx->sign_level, 0); + keyed_check_handshake(ctx, 1); + CHECK(memcmp(previous_random, ctx->encrypt_random, 32) != 0); + memcpy(previous_random, ctx->encrypt_random, 32); + for (int api = 0; api < 3; api++) { + image_capture_reset(0); + keyed_capture.packets = api == 2 ? 2 : api == 1 ? 5 : 4; + keyed_capture.error = 0; + pthread_t reader; + int trc = pthread_create(&reader, NULL, keyed_capture_uplink, NULL); + CHECK_EQ_INT(trc, 0); + if (trc) break; + uint16_t seq = (uint16_t)(ctx->seq + 1); + size_t len = api == 2 ? IMAGE_UPLINK_LARGE : 259; + CHECK_EQ_INT(image_uplink_send(ctx, api, image, len), TAI_OK); + pthread_join(reader, NULL); + CHECK_EQ_INT(keyed_capture.error, 0); + uint8_t iv[16]; + image_peer_check(ctx, api, image, len, seq, iv); + } + tai_disconnect(ctx); + CHECK_EQ_INT(g_st.disconnect_calls, 0); + } + if (ctx) tai_ctx_deinit(ctx); + keyed_peer_close(); + mbedtls_x509_crt_free(&keyed_bundle); + } +} + +static void test_keyed_tls_without_trust(void) +{ + SECTION("keyed_TLS_runs_unverified_when_no_CA_or_bundle"); + static tai_ctx_t storage; + /* No CA and no bundle: TLS still runs, the peer is unverified, and connect + * warns but proceeds -- the same rule the IoT path follows. */ + int started = keyed_peer_start(0); + CHECK_EQ_INT(started, 0); + if (started) return; + st_reset(); + tai_config_t cfg = { + .pal = tai_pal_posix(), .host = "127.0.0.1", .tls_sni = "localhost", + .port = keyed_peer.port, .device_id = "keyed-device", + .image_key = image_uplink_key, .ca_cert = NULL, + .event_user_data_json = "{}", .on_disconnect = on_disconnect, + }; + tai_ctx_t *ctx = tai_ctx_init(&storage, &cfg); + CHECK(ctx != NULL); + int rc = ctx ? tai_connect(ctx) : TAI_ERR_MEM; + pthread_join(keyed_peer.thread, NULL); + CHECK_EQ_INT(rc, TAI_OK); + CHECK_EQ_INT(keyed_peer.error, 0); + if (rc == TAI_OK) { + CHECK(ctx->tls != NULL && ctx->raw_tcp == NULL && ctx->connected); + keyed_check_handshake(ctx, 1); + tai_disconnect(ctx); + CHECK_EQ_INT(g_st.disconnect_calls, 0); + } + if (ctx) tai_ctx_deinit(ctx); + keyed_peer_close(); +} + +static void test_keyed_tls_worker_unknown_version(void) +{ + SECTION("keyed_TLS_worker_rejects_unsupported_Frame_version"); + static tai_ctx_t storage; + pal_t pal; + tai_ctx_t *ctx = image_parser_setup(&storage, &pal, 1); + if (!ctx) return; + /* Version 2 and 3 are both valid v2.1 (the cloud sends its control/ack + * Frames as version 2). An unsupported version nibble is fatal, and the + * following valid Frame must not be delivered once the first has done so. */ + const uint8_t wire[] = { + 0x21,0,2,0,6, 0x44,0,4,0,0,'x', + 0x23,0,3,0,6, 0x44,0,4,0,0,'y' + }; + CHECK_EQ_INT(keyed_peer_write(wire, sizeof(wire)), 0); + image_parser_check_fatal(ctx, TAI_PROTO_ERR_BAD_VERSION, 0, 0); + tai_ctx_deinit(ctx); +} + +static void test_keyed_tls_rejections(void) +{ + SECTION("keyed_TLS_signed_ack_bad_suite_and_untrusted_peer"); + static tai_ctx_t storage; + char wrong_ca[4096], path[4096]; + const char *slash = strrchr(__FILE__, '/'); + CHECK(slash != NULL); + if (!slash) return; + snprintf(path, sizeof(path), "%.*s/../../../third_party/mbedtls/framework/data_files/test-ca2.crt", + (int)(slash - __FILE__), __FILE__); + FILE *f = fopen(path, "rb"); + CHECK(f != NULL); + if (!f) return; + size_t n = fread(wrong_ca, 1, sizeof(wrong_ca) - 1, f); + CHECK(!ferror(f) && feof(f)); + fclose(f); + wrong_ca[n] = 0; + /* Modes 4/5 reject CA/hostname via ca_cert; 6/7 repeat via the bundle. */ + for (int mode = 2; mode <= 7; mode++) { + int started = keyed_peer_start(mode <= 3 ? mode : 0); + CHECK_EQ_INT(started, 0); + if (started) return; + int bundle = mode >= 6; + const char *ca = mode == 4 || mode == 6 ? wrong_ca : keyed_ca; + mbedtls_x509_crt_init(&keyed_bundle); + if (bundle) + CHECK_EQ_INT(mbedtls_x509_crt_parse(&keyed_bundle, + (const uint8_t *)ca, strlen(ca) + 1), 0); + keyed_bundle_calls = 0; + st_reset(); + tai_config_t cfg = { + .pal = tai_pal_posix(), .host = "127.0.0.1", .port = keyed_peer.port, + .tls_sni = mode == 5 || mode == 7 ? "wrong-host.invalid" : "localhost", + .device_id = "keyed-device", .image_key = parser_image_key, + .ca_cert = bundle ? NULL : ca, + .cert_bundle_attach = bundle ? keyed_attach_bundle : NULL, + .on_disconnect = on_disconnect, + }; + tai_ctx_t *ctx = tai_ctx_init(&storage, &cfg); + CHECK(ctx != NULL); + int rc = ctx ? tai_connect(ctx) : TAI_ERR_MEM; + pthread_join(keyed_peer.thread, NULL); + CHECK_EQ_INT(rc, mode >= 4 ? TAI_ERR_TLS : TAI_ERR_PROTO); + CHECK_EQ_INT(keyed_bundle_calls, bundle); + if (mode >= 4) { + CHECK(keyed_peer.error != 0); + CHECK_EQ_INT(keyed_peer.handshake_len, 0); + } else CHECK_EQ_INT(keyed_peer.error, 0); + if (ctx) { + CHECK(!ctx->connected && !ctx->running && !ctx->thread_handle && !ctx->tls); + CHECK(memcmp(ctx->image_key, parser_image_key, 16) == 0); + tai_ctx_deinit(ctx); + } + CHECK_EQ_INT(g_st.disconnect_calls, 0); + keyed_peer_close(); + mbedtls_x509_crt_free(&keyed_bundle); + } +}