diff --git a/CHANGELOG.md b/CHANGELOG.md index 7efc953..a5318fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,6 +45,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- iot-client — route BLE and QR/MQTT Self activation and later TLS requests with the opaque registration key and DNS-provided CA; document credential/key persistence and preserve legacy records without a key (#43). - tuya-ble — accept incoming Trsmitr versions >= 2 for app Pairing compatibility while keeping TX at version 4 (#39). - Docs — the pair-by-ble callback sample no longer claims credentials are never logged; the demo's DEBUG protocol log prints the credential JSON on purpose, for device bring-up. diff --git a/docs-site/docs/reference/iot-client.md b/docs-site/docs/reference/iot-client.md index 2150838..61eb76e 100644 --- a/docs-site/docs/reference/iot-client.md +++ b/docs-site/docs/reference/iot-client.md @@ -149,6 +149,7 @@ IoT Client 模块(CMake 目标 `tuya_iot_client`,产物 `libtuya_iot_client. | `local_key` | `char[32]` | 本地加密密钥 | | `region` | `iot_region_t` | 数据中心区域 | | `env` | `iot_env_t` | 环境 | +| `registration_key` | `char[5]` | App 原始注册 key,1~4 个可打印字节,末尾及剩余空间补零;全空时沿用旧 `env` 路由。须随激活凭据持久化、重启恢复 | | `mqtt_disable_tls` | `bool` | `false`(默认)使用 MQTTS,`true` 使用明文 MQTT | | `mqtt_disable_auto_connect` | `bool` | `false`(默认)初始化后自动连接 MQTT;`true` 需手动调用 [`iot_client_connect()`](#iot_client_connect) | | `skip_version_report` | `bool` | `false`(默认)初始化时上报 SDK meta 和固件版本;`true` 跳过这两次上报(仅在云端已有当前版本时设置) | @@ -178,7 +179,7 @@ IoT Client 模块(CMake 目标 `tuya_iot_client`,产物 `libtuya_iot_client. | `feature` | `const char *` | Feature 信息(可为 NULL) | | `skill_param` | `const char *` | Skill 参数(可为 NULL) | | `timeout_ms` | `int` | 激活超时时间(毫秒) | -| `env` | `iot_env_t` | 环境:`PROD`(默认)或 `PRE` | +| `env` | `iot_env_t` | 激活消息监听的引导环境枚举(默认 `PROD`);收到 App 注册 key 后,Self 地址由 DNS 决定,不改写此枚举 | | `mqtt_disable_tls` | `bool` | TLS 开关 | | `mqtt_disable_auto_connect` | `bool` | `false`(默认)激活后自动连接 MQTT;`true` 需手动调用 [`iot_client_connect()`](#iot_client_connect) | | `skip_version_report` | `bool` | `false`(默认)激活后上报 SDK meta 和固件版本;`true` 跳过这两次上报(仅在云端已有当前版本时设置) | @@ -202,6 +203,7 @@ IoT Client 模块(CMake 目标 `tuya_iot_client`,产物 `libtuya_iot_client. | `local_key` | `char[32]` | 本地加密密钥 | | `region` | `iot_region_t` | 服务器区域 | | `env` | `iot_env_t` | 环境 | +| `registration_key` | `char[5]` | App 原始注册 key;须随激活凭据保存,重启时恢复到 `iot_client_config_t` | ## API 函数 {#api-函数} @@ -237,6 +239,9 @@ iot_client_t *iot_client_init_on_boarding(const iot_on_boarding_config_t *config ``` 阻塞等待 App 扫码激活。内部通过 MQTT 监听激活事件,激活成功后返回包含 `devid`、`secret_key`、`local_key` 的客户端实例。 +与 TuyaOpen 一致,MQTT 激活消息的 `data.env` 原样作为注册 key 保存,缺省为 `pro`,接受 1~4 个可打印字节(如 `pr_0`、`da_0`、`pro`、`x_ab`)。设备不把它映射为环境枚举。用该 key 查询 IoT DNS 的 `httpsSelfUrl`、`mqttsSelfUrl` 及 CA,激活和后续连接均走 DNS 返回的 Self 地址;不采用消息中的 `httpsUrl`。`client->env` 保持 `config.env`。 + +两种 App 配网入口均需保持 `mqtt_disable_tls=false`,并配置可信的引导 CA 或证书包。DNS 无有效 Self 地址或 CA 时返回失败,不回退线上。 **返回值:** 成功返回 `iot_client_t *`;超时或失败返回 `NULL`。 @@ -250,14 +255,35 @@ iot_client_t *iot_client_init_on_boarding_with_token( const char *token); ``` -使用预知的激活 Token 直接发起激活请求,跳过 MQTT 等待。Region 由 token 前两个字符自动推导。 +使用预知的激活 Token 直接发起激活请求,跳过 MQTT 等待。Region 由 token 前两个字符自动推导。涂鸦 App 的 BLE 配网 Token 固定为 `[区域码:2][激活 token:8][secret:4]`,例如 `AYH73H8u7Apr_0`。末尾 4 字节 `secret` 是不透明值,设备原样作为 IoT DNS 请求的 `env` 参数,用于获取该环境对应的 Self HTTPS/MQTT 地址和 CA;设备不把它映射成 `pre`、`pro` 或 `prod`,也不根据它设置 `client->env`。`config.env` 只保留为客户端环境枚举,不决定这条 App token 路由。QR/MQTT 激活也使用原始注册 key,但其 `data.env` 可以是三字节的 `pro`,不改变 BLE 的固定 14 字节格式。 **参数:** - `config` — 配网配置 -- `token` — 激活 Token(格式:`{region}{token}{secret}`,如 `AYH73H8u7Ap4pX`) +- `token` — App BLE 配网 Token,格式为 `[区域码:2][激活 token:8][secret:4]`,例如 `AYH73H8u7Apr_0` **返回值:** 成功返回 `iot_client_t *`;失败返回 `NULL`。 +#### 持久化与重启恢复 {#registration-key-persistence} + +两种激活入口都返回 `client->registration_key`。应用须把 `devid`、`secret_key`、`local_key`、`region`、`env` 和 `registration_key` 一起安全保存;仅保存三个设备凭据会丢失 App 选择的 DNS 路由。SDK 不代管 NVS 或文件存储。 + +```c +/* 激活成功后,构造应用要保存的字段;不要直接序列化含指针的配置结构。 */ +iot_client_config_t restored = {0}; +snprintf(restored.devid, sizeof(restored.devid), "%s", client->devid); +snprintf(restored.secret_key, sizeof(restored.secret_key), "%s", client->secret_key); +snprintf(restored.local_key, sizeof(restored.local_key), "%s", client->local_key); +restored.region = client->region; +restored.env = client->env; +memcpy(restored.registration_key, client->registration_key, + sizeof(restored.registration_key)); +/* 用应用自己的持久化接口保存以上字段,并在下次启动时读取。 */ +restored.cacert = bootstrap_ca_pem; /* 每次启动配置可信 CA,或 cert_bundle_attach。 */ +iot_client_t *reconnected = iot_client_init(&restored); +``` + +无需持久化 DNS 地址或 Self CA,SDK 在连接时重新查询。旧存储记录若没有 `registration_key`,保持该字段全零并恢复原 `env`,无需清除绑定或重新配网;不要自动补成 `pro`。非空但损坏的 key 应报错,不能清空后静默回退线上。新激活后即使暂时断网,也应保存已返回客户端的绑定信息,再调用 `iot_client_connect()` 重试。 + --- ### `iot_client_reset` {#iot_client_reset} diff --git a/docs-site/docs/tutorials/scan-by-device.md b/docs-site/docs/tutorials/scan-by-device.md index ddd29a8..e0b43c3 100644 --- a/docs-site/docs/tutorials/scan-by-device.md +++ b/docs-site/docs/tutorials/scan-by-device.md @@ -118,7 +118,7 @@ iot_on_boarding_config_t cfg = { | `product_key` | 产品 PID | | `firmware_key` | 固件 Key(可为空) | | `timeout_ms` | 激活超时时间(毫秒) | -| `env` | 环境:`PROD` / `PRE` | +| `env` | 客户端环境枚举。App BLE Token 尾部 4 字节 `secret` 原样作为 IoT DNS 的 `env` 参数;不映射为 `pre`/`pro`/`prod`,也不由此字段决定 App token 的路由 | **返回值:** 成功返回 `iot_client_t *`,其中包含激活后的 `devid`、 `secret_key`、`local_key`,后续可直接用于初始化 `iot_client_init()`;失败返 diff --git a/docs-site/i18n/en/docusaurus-plugin-content-docs/current/reference/iot-client.md b/docs-site/i18n/en/docusaurus-plugin-content-docs/current/reference/iot-client.md index a617232..b75f626 100644 --- a/docs-site/i18n/en/docusaurus-plugin-content-docs/current/reference/iot-client.md +++ b/docs-site/i18n/en/docusaurus-plugin-content-docs/current/reference/iot-client.md @@ -148,6 +148,7 @@ Initialization configuration for an activated device. | `local_key` | `char[32]` | Local encryption key | | `region` | `iot_region_t` | Data-center region | | `env` | `iot_env_t` | Environment | +| `registration_key` | `char[5]` | Raw App registration key: 1-4 printable bytes with zero-filled termination/padding; all zeros retain legacy `env` routing. Persist with credentials and restore on reboot | | `mqtt_disable_tls` | `bool` | `false` (default) uses MQTTS; `true` uses plaintext MQTT | | `mqtt_disable_auto_connect` | `bool` | `false` (default) connects to MQTT automatically after initialization; when `true`, you must call [`iot_client_connect()`](#iot_client_connect) manually | | `skip_version_report` | `bool` | `false` (default) reports the SDK metadata and firmware version during initialization; `true` skips these two reports (set only when the cloud already has the current version) | @@ -177,7 +178,7 @@ Configuration for device provisioning and Activation. | `feature` | `const char *` | Feature information (can be NULL) | | `skill_param` | `const char *` | Skill parameters (can be NULL) | | `timeout_ms` | `int` | Activation timeout in milliseconds | -| `env` | `iot_env_t` | Environment: `PROD` (default) or `PRE` | +| `env` | `iot_env_t` | Bootstrap environment enum for listening to Activation messages (default `PROD`); after receiving the App key, DNS determines Self endpoints without rewriting this enum | | `mqtt_disable_tls` | `bool` | TLS switch | | `mqtt_disable_auto_connect` | `bool` | `false` (default) connects to MQTT automatically after Activation; when `true`, you must call [`iot_client_connect()`](#iot_client_connect) manually | | `skip_version_report` | `bool` | `false` (default) reports the SDK metadata and firmware version after Activation; `true` skips these two reports (set only when the cloud already has the current version) | @@ -201,6 +202,7 @@ The client instance returned by `iot_client_init()` or a provisioning API contai | `local_key` | `char[32]` | Local encryption key | | `region` | `iot_region_t` | Server region | | `env` | `iot_env_t` | Environment | +| `registration_key` | `char[5]` | Raw App registration key; persist with Activation credentials and restore in `iot_client_config_t` on reboot | ## API Functions {#api-函数} @@ -236,6 +238,9 @@ iot_client_t *iot_client_init_on_boarding(const iot_on_boarding_config_t *config ``` Blocks while waiting for App QR-code Activation. Internally, it listens for the Activation event over MQTT and, after successful Activation, returns a client instance containing `devid`, `secret_key`, and `local_key`. +As in TuyaOpen, the MQTT Activation message's `data.env` is saved unchanged as the registration key, defaults to `pro` when absent, and accepts 1-4 printable bytes (such as `pr_0`, `da_0`, `pro`, or `x_ab`). The device does not map it to an environment enum. It uses this key to query IoT DNS for `httpsSelfUrl`, `mqttsSelfUrl`, and CA; Activation and later connections use these Self endpoints, not the message's `httpsUrl`. `client->env` remains `config.env`. + +Both App onboarding paths require `mqtt_disable_tls=false` and a trusted bootstrap CA or certificate bundle. Missing or invalid Self endpoints or CA cause failure, not fallback to production. **Return value:** An `iot_client_t *` on success; `NULL` on timeout or failure. @@ -249,14 +254,35 @@ iot_client_t *iot_client_init_on_boarding_with_token( const char *token); ``` -Starts Activation directly with a known Activation Token, skipping the MQTT wait. The Region is derived automatically from the token's first two characters. +Starts Activation directly with a known Activation Token, skipping the MQTT wait. The Region is derived from the first two characters. Tuya App BLE provisioning tokens have the fixed format `[region:2][activation token:8][secret:4]`, for example `AYH73H8u7Apr_0`. The trailing four-byte `secret` is opaque and is passed unchanged as the IoT DNS request's `env` parameter to obtain Self HTTPS/MQTT endpoints and CA for that environment. The device does not map it to `pre`, `pro`, or `prod`, nor use it to set `client->env`. `config.env` remains the client environment enum and does not determine this App-token route. QR/MQTT Activation also uses a raw registration key, but `data.env` may be the three-byte `pro`; this does not relax BLE's fixed 14-byte format. **Parameters:** - `config` - Provisioning configuration -- `token` - Activation Token (format: `{region}{token}{secret}`, for example `AYH73H8u7Ap4pX`) +- `token` - Tuya App BLE provisioning token in `[region:2][activation token:8][secret:4]` format, for example `AYH73H8u7Apr_0` **Return value:** An `iot_client_t *` on success; `NULL` on failure. +#### Persistence and restart recovery {#registration-key-persistence} + +Both Activation paths return `client->registration_key`. The application must securely persist `devid`, `secret_key`, `local_key`, `region`, `env`, and `registration_key` together. Saving only the three device credentials loses the App-selected DNS route. The SDK does not manage NVS or file storage. + +```c +/* After Activation, prepare the fields to save; do not serialize a config with pointers. */ +iot_client_config_t restored = {0}; +snprintf(restored.devid, sizeof(restored.devid), "%s", client->devid); +snprintf(restored.secret_key, sizeof(restored.secret_key), "%s", client->secret_key); +snprintf(restored.local_key, sizeof(restored.local_key), "%s", client->local_key); +restored.region = client->region; +restored.env = client->env; +memcpy(restored.registration_key, client->registration_key, + sizeof(restored.registration_key)); +/* Save these fields using application storage, then read them on the next boot. */ +restored.cacert = bootstrap_ca_pem; /* Configure trusted CA or cert_bundle_attach each boot. */ +iot_client_t *reconnected = iot_client_init(&restored); +``` + +DNS endpoints and Self CA need not be persisted; the SDK queries them again when connecting. For older records without a `registration_key`, keep that field all-zero and restore the original `env`; do not clear the binding, re-activate, or automatically fill in `pro`. A corrupt nonempty key must be treated as an error, not cleared to silently fall back to production. Even if the network fails after new Activation, persist the returned client's binding information and retry with `iot_client_connect()`. + --- ### `iot_client_reset` {#iot_client_reset} diff --git a/docs-site/i18n/en/docusaurus-plugin-content-docs/current/tutorials/scan-by-device.md b/docs-site/i18n/en/docusaurus-plugin-content-docs/current/tutorials/scan-by-device.md index 01cd5a8..83f7a5c 100644 --- a/docs-site/i18n/en/docusaurus-plugin-content-docs/current/tutorials/scan-by-device.md +++ b/docs-site/i18n/en/docusaurus-plugin-content-docs/current/tutorials/scan-by-device.md @@ -119,7 +119,7 @@ Only if the application explicitly sets `.mqtt_disable_auto_connect = true` must | `product_key` | Product PID | | `firmware_key` | Firmware Key (may be empty) | | `timeout_ms` | Activation timeout (milliseconds) | -| `env` | Environment: `PROD` / `PRE` | +| `env` | Client environment enum. The trailing four-byte `secret` in an App BLE token is passed unchanged as IoT DNS `env`; it is not mapped to `pre`/`pro`/`prod`, and this field does not select the App-token route | **Return value:** On success, returns an `iot_client_t *` that contains the `devid`, `secret_key`, and `local_key` after Activation, which can be used directly to initialize `iot_client_init()` later; on failure, returns `NULL`. diff --git a/modules/iot-client/CONTEXT.md b/modules/iot-client/CONTEXT.md index a18a2b4..ac10f1d 100644 --- a/modules/iot-client/CONTEXT.md +++ b/modules/iot-client/CONTEXT.md @@ -48,6 +48,17 @@ First-time provisioning that authenticates the device and returns its credential (devid / secret_key / local_key) together with its schema and schema id. _Avoid_: pairing, registration, binding (those are app/cloud-side terms). +**Registration key**: +The opaque four-byte secret appended to the App's BLE authToken after the +two-byte region and eight-byte activation token, or the 1-4 byte `data.env` +from QR/MQTT activation (default `pro` when absent). The device passes it unchanged +as IoT DNS `env` to discover the Self HTTPS/MQTT endpoints before activation; +it is not the device credential `secret_key` and is not an `iot_env_t` value. +Applications persist it together with credentials and region, then restore +`iot_client_config_t.registration_key` on reboot. Older records without a key +retain their legacy `env` routing; do not force them to `pro` or re-activate them. +_Avoid_: mapping its spelling to production/pre-production enum values. + **Schema upgrade**: Replacing the device's schema with a newer version for the same Schema ID, fetched by the application polling the cloud (there is no MQTT schema-change notification). diff --git a/modules/iot-client/include/iot_client.h b/modules/iot-client/include/iot_client.h index ef2daa1..a4cffa0 100644 --- a/modules/iot-client/include/iot_client.h +++ b/modules/iot-client/include/iot_client.h @@ -193,6 +193,7 @@ typedef struct { char local_key[32]; // Local key iot_region_t region; // Region iot_env_t env; // Environment + char registration_key[5]; // Opaque App key: 1-4 printable bytes, zero-padded; empty = legacy env bool mqtt_disable_tls; // false = mqtts (TLS, default), true = mqtt (TCP) bool mqtt_disable_auto_connect; // false (default) = connect MQTT after init/activation; true = caller invokes iot_client_connect() manually bool skip_version_report; // false (default) = report both; true = skip both during init (set only when cloud already has current version) @@ -223,7 +224,7 @@ typedef struct { const char *feature; const char *skill_param; int timeout_ms; - iot_env_t env; // PROD (default) or PRE + iot_env_t env; // Bootstrap/legacy enum; raw App key determines Self routing bool mqtt_disable_tls; // false = mqtts (TLS, default), true = mqtt (TCP) bool mqtt_disable_auto_connect; // false (default) = connect MQTT after init/activation; true = caller invokes iot_client_connect() manually bool skip_version_report; // false (default) = report both; true = skip both during init (only when cloud already has current version) @@ -262,10 +263,13 @@ struct iot_dp_context; iot_region_t region; // Server region (AY/AZ/UEAZ/EU/WEAZ/IN/SG) iot_env_t env; // Environment (PROD or PRE) + char registration_key[5]; // Opaque App key; persist with credentials; empty = legacy env bool mqtt_disable_tls; // false = mqtts (TLS), true = mqtt (TCP) const pal_t *pal; // PAL adapter - const char *cacert; // CA certificate for all TLS (MQTT/HTTPS/IoT-DNS) (caller-owned, points to user buffer/flash) + const char *cacert; // Caller-owned CA for IoT-DNS and legacy service TLS + const char *self_cacert; // IoT-DNS CA for App-selected Self endpoints + char *owned_self_cacert; // SDK-owned decoded Self CA certificate tls_cert_bundle_attach_fn cert_bundle_attach; // Platform cert-bundle callback (borrowed, NULL = none) struct mqtt_client *mqtt; // Internal MQTT client handle iot_message_callback_t message_callback; // User callback for incoming messages @@ -282,10 +286,14 @@ struct iot_dp_context; * @brief Initialize IoT client with existing device credentials. * * Resolves MQTT/HTTPS endpoints via IoT DNS and establishes the MQTT - * connection automatically when devid is set. + * connection automatically when devid is set. A client with a registration + * key stays valid (but disconnected) after a transient DNS/MQTT failure, so + * callers can preserve its credentials and retry with iot_client_connect(). + * An empty registration key retains legacy environment routing. * * @param config Client configuration (devid, secret_key, local_key, region, etc.) - * @return Pointer to iot_client_t on success, NULL on error + * @return Client on success or keyed transient network failure; NULL for invalid + * configuration, allocation failure, or legacy fatal connect failure. */ IOT_API iot_client_t *iot_client_init(const iot_client_config_t *config); @@ -293,9 +301,13 @@ IOT_API iot_client_t *iot_client_init(const iot_client_config_t *config); * @brief Initialize IoT client via QR code on-boarding (first-time activation). * * Blocks until a user scans the QR code and the device is activated, or - * until the configured timeout expires. On success the returned client is - * fully connected; persist its devid / secret_key / local_key for future - * calls to iot_client_init(). + * until the configured timeout expires. MQTT activation data.env is an opaque + * 1-4 byte registration key (default "pro" when absent), passed unchanged to + * IoT DNS for Self endpoints and CA. Both onboarding paths require MQTT TLS + * and a trusted bootstrap CA or certificate bundle. The client may remain + * disconnected after a transient post-activation network failure; persist + * devid / secret_key / local_key / region / env / registration_key together + * for future calls to iot_client_init(). Restore runtime TLS trust separately. * * @param config On-boarding configuration (uuid, authkey, product_key, timeout_ms, etc.) * @return Pointer to iot_client_t on success (contains devid, secret_key, local_key, schema_id), NULL on error or timeout @@ -306,13 +318,17 @@ IOT_API iot_client_t *iot_client_init_on_boarding(const iot_on_boarding_config_t * @brief Initialize IoT client via token on-boarding (first-time activation). * * Skips the QR-code MQTT activation wait and directly sends the activation - * request using the provided token. Region is derived from the first two - * characters of the token; env is taken from @p config. - * Does not require DNS/MQTT — calls ATOP directly. + * request using the provided token. The App token contains a two-byte region, + * eight-byte activation token and opaque four-byte registration key. The key + * is passed unchanged to IoT DNS for Self HTTPS/MQTT endpoints; it is not + * mapped to PRE or PROD. Activation requires DNS and uses the Self HTTPS host. + * Persist devid / secret_key / local_key / region / env / registration_key + * together and restore them in iot_client_config_t on subsequent boots. * * @param config On-boarding configuration (uuid, authkey, product_key, etc.) - * @param token Activation token: [region:2][activation_token][secret:4] - * @return Pointer to iot_client_t on success, NULL on error + * @param token App token: [region:2][activation_token:8][registration_key:4] + * @return Activated client, possibly disconnected after a transient post-activation + * DNS/MQTT failure; NULL if activation itself failed. */ IOT_API iot_client_t *iot_client_init_on_boarding_with_token(const iot_on_boarding_config_t *config, const char *token); @@ -402,8 +418,12 @@ IOT_API void iot_client_deinit(iot_client_t *client); * the same doomed handshake forever after a broker cert rotation. See * examples/posix/dp-management/ for the shape of that loop. * - * @param client Pointer to iot_client_t instance (must have mqtt_url and devid set) - * @return OPRT_OK on success, OPRT_INVALID_PARAMETER if client/url/devid is missing + * Keyed clients re-resolve Self endpoints before reconnecting; legacy clients + * use their existing MQTT URL. No production fallback is used for a missing + * keyed endpoint. + * + * @param client Pointer to iot_client_t instance with device credentials + * @return OPRT_OK on success, or a DNS/MQTT/parameter error */ IOT_API int iot_client_connect(iot_client_t *client); diff --git a/modules/iot-client/src/atop.c b/modules/iot-client/src/atop.c index 922d632..ebbb57b 100644 --- a/modules/iot-client/src/atop.c +++ b/modules/iot-client/src/atop.c @@ -213,8 +213,6 @@ int atop_activate_request(const pal_t *pal, const activite_request_t *request, a } offset += (size_t)write_len; - IOT_LOGI("POST JSON:%s", buffer); - /* atop_base_request object construct */ atop_base_request_t atop_request = {.uuid = request->uuid, .key = request->authkey, diff --git a/modules/iot-client/src/iot_atop.c b/modules/iot-client/src/iot_atop.c index cde8d72..794fd69 100644 --- a/modules/iot-client/src/iot_atop.c +++ b/modules/iot-client/src/iot_atop.c @@ -90,7 +90,8 @@ int iot_atop_call(iot_client_t *client, char host[64] = {0}; uint16_t port = IOT_DEFAULT_PORT; - iot_client_resolve_atop_host(client, host, sizeof(host), &port); + rt = iot_client_resolve_atop_host(client, host, sizeof(host), &port); + if (rt != OPRT_OK) return rt; atop_base_request_t atop_request = { .path = "/d.json", @@ -103,8 +104,10 @@ int iot_atop_call(iot_client_t *client, .datalen = strlen(body), .host = host[0] ? host : NULL, .port = port, - .cacert = client->cacert, - .cert_bundle_attach = client->cert_bundle_attach, + .cacert = client->registration_key[0] != '\0' && client->self_cacert + ? client->self_cacert : client->cacert, + .cert_bundle_attach = client->registration_key[0] != '\0' && client->self_cacert + ? NULL : client->cert_bundle_attach, }; atop_base_response_t atop_response = {0}; diff --git a/modules/iot-client/src/iot_client.c b/modules/iot-client/src/iot_client.c index 467974f..bafa85f 100644 --- a/modules/iot-client/src/iot_client.c +++ b/modules/iot-client/src/iot_client.c @@ -36,6 +36,80 @@ static const char *iot_env_to_string(iot_env_t env) return env == PRE ? "pre" : "prod"; } +/* Never read beyond a caller-provided five-byte public field. Empty means the + * legacy routing path; a key is 1-4 printable bytes with zero-filled padding. */ +static bool registration_key_empty(const char key[5]) +{ + for (size_t i = 0; i < 5; i++) if (key[i] != '\0') return false; + return true; +} + +static bool registration_key_valid(const char key[5]) +{ + if (registration_key_empty(key)) return true; + if (key[4] != '\0') return false; + size_t len = 0; + while (len < 4 && key[len] != '\0') { + unsigned char c = (unsigned char)key[len++]; + if (c < 0x21 || c > 0x7e) return false; + } + if (len == 0) return false; + for (size_t i = len; i < 5; i++) { + if (key[i] != '\0') return false; + } + return true; +} + +static bool client_has_https_trust(const iot_client_t *client) +{ + return (client->self_cacert != NULL && client->self_cacert[0] != '\0') || + (client->cacert != NULL && client->cacert[0] != '\0') || + client->cert_bundle_attach != NULL; +} + +/* Self endpoints are authority-only (HTTPS has the exact ATOP path). Reject + * malformed, truncated or insecure routes before they enter the client. */ +static bool valid_self_endpoint(const char *addr, bool https, bool has_tls_trust) +{ + if (!addr) return false; + size_t limit = https ? 64u : 56u; + size_t len = 0; + while (len < limit && addr[len] != '\0') len++; + if (len == 0 || len == limit) return false; + const char *start = addr; + if (https) { + if (strncmp(addr, "https://", 8) != 0) return false; + start += 8; + } + const char *end = addr + len; + const char *authority_end = https ? strchr(start, '/') : end; + if (!authority_end) authority_end = end; + if (https && authority_end != end && strcmp(authority_end, "/d.json") != 0) return false; + const char *colon = memchr(start, ':', (size_t)(authority_end - start)); + const char *host_end = colon ? colon : authority_end; + if (host_end == start || (!https && !colon)) return false; + for (const char *p = start; p < host_end; p++) { + unsigned char c = (unsigned char)*p; + if (!((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || + (c >= '0' && c <= '9') || c == '.' || c == '-')) return false; + } + unsigned int port = https ? 443u : 0u; + if (colon) { + port = 0; + if (colon + 1 == authority_end) return false; + for (const char *p = colon + 1; p < authority_end; p++) { + unsigned char c = (unsigned char)*p; + if (c < '0' || c > '9') return false; + unsigned int digit = (unsigned int)(c - '0'); + if (port > (65535u - digit) / 10u) return false; + port = port * 10u + digit; + } + if (port == 0) return false; + } + if (https && (port == 80u || (port != 443u && !has_tls_trust))) return false; + return true; +} + static int parse_host_port(const char *url, char *host_out, size_t host_len, uint16_t *port_out) { const char *p = url; @@ -75,13 +149,19 @@ static int parse_host_port(const char *url, char *host_out, size_t host_len, uin /* Resolve the ATOP host/port for this client. Shared by iot_client_get_session_token() * and the DP-layer schema-update query (declared in iot_dp_internal.h). */ -void iot_client_resolve_atop_host(iot_client_t *client, char *host_out, size_t host_len, uint16_t *port_out) +int iot_client_resolve_atop_host(iot_client_t *client, char *host_out, size_t host_len, uint16_t *port_out) { + if (!client || !host_out || !port_out || host_len == 0) return OPRT_INVALID_PARAMETER; *port_out = IOT_DEFAULT_PORT; - if (host_len == 0) return; host_out[0] = '\0'; + if (!registration_key_valid(client->registration_key)) return OPRT_INVALID_PARAMETER; + bool keyed = !registration_key_empty(client->registration_key); + if (keyed && !valid_self_endpoint(client->https_url, true, + client_has_https_trust(client))) + return OPRT_UNINITIALIZED; if (client->https_url[0] != '\0') { - parse_host_port(client->https_url, host_out, host_len, port_out); + int ret = parse_host_port(client->https_url, host_out, host_len, port_out); + if (ret != OPRT_OK || (keyed && host_out[0] == '\0')) return OPRT_INVALID_RESULT; } else { const char *h = iot_region_to_host(client->region, client->env); if (h) { @@ -89,23 +169,38 @@ void iot_client_resolve_atop_host(iot_client_t *client, char *host_out, size_t h host_out[host_len - 1] = '\0'; } } + return OPRT_OK; } -static int iot_client_dns_resolve(iot_client_t *client) +int iot_client_dns_resolve(iot_client_t *client, const char *dns_host, uint16_t dns_port) { + if (!client || !registration_key_valid(client->registration_key)) return OPRT_INVALID_PARAMETER; + bool keyed = !registration_key_empty(client->registration_key); + if (keyed) { + client->mqtt_url[0] = '\0'; + client->https_url[0] = '\0'; + /* The App's Self route is currently defined only for mqttsSelfUrl. */ + if (client->mqtt_disable_tls) return OPRT_NOT_SUPPORTED; + if ((!client->cacert || client->cacert[0] == '\0') && + !client->cert_bundle_attach) { + IOT_LOGE("App-selected IoT DNS lookup requires a trusted TLS CA"); + return OPRT_INVALID_PARAMETER; + } + } const char *mqtt_dns_key = client->mqtt_disable_tls ? IOT_DNS_KEY_MQTT - : IOT_DNS_KEY_MQTTS; + : keyed ? "mqttsSelfUrl" : IOT_DNS_KEY_MQTTS; + const char *https_dns_key = keyed ? "httpsSelfUrl" : IOT_DNS_KEY_HTTPS; iot_dns_config_item_t dns_keys[] = { - { .key = mqtt_dns_key }, - { .key = IOT_DNS_KEY_HTTPS }, + { .key = mqtt_dns_key, .need_ca = keyed }, + { .key = https_dns_key, .need_ca = keyed }, }; iot_dns_url_config_request_t dns_req = { .cacert = client->cacert, .cert_bundle_attach = client->cert_bundle_attach, - .host = NULL, - .port = 0, + .host = dns_host, + .port = dns_port, .region = iot_region_to_string(client->region), - .env = iot_env_to_string(client->env), + .env = keyed ? client->registration_key : iot_env_to_string(client->env), .uuid = client->devid, .config = dns_keys, .config_count = 2, @@ -114,9 +209,55 @@ static int iot_client_dns_resolve(iot_client_t *client) int ret = iot_dns_url_config(client->pal, &dns_req, &dns_resp); if (ret != OPRT_OK) { IOT_LOGW("Failed to query IoT DNS for service URLs: %d", ret); + iot_dns_url_config_response_free(client->pal, &dns_resp); return ret; } + if (keyed) { + char *self_cacert = NULL; + ret = iot_dns_url_config_first_ca_pem(client->pal, &dns_resp, &self_cacert); + if (ret != OPRT_OK) { + iot_dns_url_config_response_free(client->pal, &dns_resp); + IOT_LOGW("IoT DNS did not return a usable CA for Self endpoints"); + return ret; + } + const char *mqtt_addr = NULL; + const char *https_addr = NULL; + for (int i = 0; i < dns_resp.endpoint_count; i++) { + if (strcmp(dns_resp.endpoints[i].key, mqtt_dns_key) == 0) + mqtt_addr = dns_resp.endpoints[i].addr; + else if (strcmp(dns_resp.endpoints[i].key, https_dns_key) == 0) + https_addr = dns_resp.endpoints[i].addr; + } + if (!valid_self_endpoint(https_addr, true, true) || + !valid_self_endpoint(mqtt_addr, false, true)) { + client->pal->free(self_cacert); + iot_dns_url_config_response_free(client->pal, &dns_resp); + IOT_LOGW("IoT DNS did not return valid Self endpoints"); + return OPRT_INVALID_RESULT; + } + int mqtt_len = snprintf(client->mqtt_url, sizeof(client->mqtt_url), + "mqtts://%s", mqtt_addr); + int https_len = snprintf(client->https_url, sizeof(client->https_url), + "%s", https_addr); + if (mqtt_len >= 0 && (size_t)mqtt_len < sizeof(client->mqtt_url) && + https_len >= 0 && (size_t)https_len < sizeof(client->https_url)) { + if (client->owned_self_cacert) client->pal->free(client->owned_self_cacert); + client->owned_self_cacert = self_cacert; + client->self_cacert = self_cacert; + } else { + client->pal->free(self_cacert); + } + iot_dns_url_config_response_free(client->pal, &dns_resp); + if (mqtt_len < 0 || (size_t)mqtt_len >= sizeof(client->mqtt_url) || + https_len < 0 || (size_t)https_len >= sizeof(client->https_url)) { + client->mqtt_url[0] = '\0'; + client->https_url[0] = '\0'; + return OPRT_INVALID_RESULT; + } + return OPRT_OK; + } + for (int i = 0; i < dns_resp.endpoint_count; i++) { if (strcmp(dns_resp.endpoints[i].key, mqtt_dns_key) == 0) { const char *scheme = client->mqtt_disable_tls ? "mqtt" : "mqtts"; @@ -203,21 +344,19 @@ int iot_client_report_init_versions(iot_client_t *client, { char meta_host[64] = {0}; uint16_t meta_port = IOT_DEFAULT_PORT; - const char *host; - if (client->https_url[0] != '\0') { - parse_host_port(client->https_url, meta_host, sizeof(meta_host), &meta_port); - host = meta_host; - } else { - host = iot_region_to_host(client->region, client->env); - } + int host_ret = iot_client_resolve_atop_host(client, meta_host, + sizeof(meta_host), &meta_port); + if (host_ret != OPRT_OK) return host_ret; device_meta_save_request_t meta_req = { .devid = client->devid, .key = client->secret_key, .sdk_version = SDK_VERSION, - .host = host, + .host = meta_host[0] ? meta_host : NULL, .port = meta_port, - .cacert = client->cacert, - .cert_bundle_attach = client->cert_bundle_attach, + .cacert = client->registration_key[0] != '\0' && client->self_cacert + ? client->self_cacert : client->cacert, + .cert_bundle_attach = client->registration_key[0] != '\0' && client->self_cacert + ? NULL : client->cert_bundle_attach, }; device_meta_save_response_t meta_resp = {0}; int meta_ret = atop_device_meta_save(client->pal, &meta_req, &meta_resp); @@ -250,6 +389,14 @@ IOT_API iot_client_t *iot_client_init(const iot_client_config_t *config) IOT_LOGE("Invalid config for iot_client_init"); return NULL; } + if (!registration_key_valid(config->registration_key)) { + IOT_LOGE("iot_client_init: invalid registration key"); + return NULL; + } + if (!registration_key_empty(config->registration_key) && config->mqtt_disable_tls) { + IOT_LOGE("iot_client_init: Self routing requires MQTT over TLS"); + return NULL; + } const pal_t *pal = get_pal(); if (!pal) { @@ -272,6 +419,8 @@ IOT_API iot_client_t *iot_client_init(const iot_client_config_t *config) client->local_key[sizeof(client->local_key) - 1] = '\0'; client->region = config->region; client->env = config->env; + memcpy(client->registration_key, config->registration_key, + sizeof(client->registration_key)); client->mqtt_disable_tls = config->mqtt_disable_tls; client->cacert = config->cacert; client->cert_bundle_attach = config->cert_bundle_attach; @@ -292,15 +441,17 @@ IOT_API iot_client_t *iot_client_init(const iot_client_config_t *config) } if (client->devid[0] != '\0') { - iot_client_dns_resolve(client); + iot_client_dns_resolve(client, NULL, 0); } if (client->mqtt_url[0] != '\0' && !config->mqtt_disable_auto_connect) { int ret = iot_client_message_connect(client); if (ret != OPRT_OK) { IOT_LOGE("MQTT connect failed: %d", ret); - iot_client_deinit(client); - return NULL; + if (registration_key_empty(client->registration_key)) { + iot_client_deinit(client); + return NULL; + } } } @@ -330,6 +481,9 @@ IOT_API void iot_client_deinit(iot_client_t *client) client->pal->free(client->schema); } const pal_t *pal = client->pal; + if (client->owned_self_cacert) { + pal->free(client->owned_self_cacert); + } /* Wipe before freeing: devid, secret_key and local_key are plaintext in this * struct, and on an embedded allocator the next malloc of a similar size * hands the block -- keys included -- to unrelated code. Matters most on the @@ -418,6 +572,11 @@ IOT_API iot_client_t *iot_client_init_on_boarding(const iot_on_boarding_config_t IOT_LOGE("Invalid config for iot_client_init_on_boarding"); return NULL; } + /* QR activation also returns an App key and will use Self MQTT routing. */ + if (config->mqtt_disable_tls) { + IOT_LOGE("QR on-boarding requires MQTT over TLS"); + return NULL; + } const pal_t *pal = get_pal(); if (!pal) { @@ -481,6 +640,8 @@ IOT_API iot_client_t *iot_client_init_on_boarding(const iot_on_boarding_config_t strncpy(client_config.local_key, ob_resp.local_key, sizeof(client_config.local_key) - 1); client_config.region = ob_resp.region; client_config.env = ob_resp.env; + memcpy(client_config.registration_key, ob_resp.registration_key, + sizeof(client_config.registration_key)); client_config.mqtt_disable_tls = config->mqtt_disable_tls; client_config.mqtt_disable_auto_connect = config->mqtt_disable_auto_connect; client_config.skip_version_report = config->skip_version_report; @@ -536,6 +697,13 @@ IOT_API iot_client_t *iot_client_init_on_boarding_with_token(const iot_on_boardi return NULL; } + /* Token on-boarding always carries an App registration key. Reject a + * plaintext MQTT configuration before activation can issue credentials. */ + if (config->mqtt_disable_tls) { + IOT_LOGE("token on-boarding requires MQTT over TLS"); + return NULL; + } + IOT_LOGI("iot_client_init_on_boarding_with_token: uuid=%s", config->uuid); on_boarding_config_t ob_cfg = {0}; @@ -581,6 +749,8 @@ IOT_API iot_client_t *iot_client_init_on_boarding_with_token(const iot_on_boardi strncpy(client_config.local_key, ob_resp.local_key, sizeof(client_config.local_key) - 1); client_config.region = ob_resp.region; client_config.env = ob_resp.env; + memcpy(client_config.registration_key, ob_resp.registration_key, + sizeof(client_config.registration_key)); client_config.mqtt_disable_tls = config->mqtt_disable_tls; client_config.mqtt_disable_auto_connect = config->mqtt_disable_auto_connect; client_config.skip_version_report = config->skip_version_report; @@ -633,7 +803,9 @@ IOT_API int iot_client_get_session_token_ex(iot_client_t *client, const char *ag char parsed_host[64] = {0}; uint16_t parsed_port = IOT_DEFAULT_PORT; - iot_client_resolve_atop_host(client, parsed_host, sizeof(parsed_host), &parsed_port); + int host_ret = iot_client_resolve_atop_host(client, parsed_host, + sizeof(parsed_host), &parsed_port); + if (host_ret != OPRT_OK) return host_ret; const char *host = parsed_host[0] ? parsed_host : NULL; ai_token_request_t req = { .devid = client->devid, @@ -641,8 +813,10 @@ IOT_API int iot_client_get_session_token_ex(iot_client_t *client, const char *ag .agent_code = agent_code, .host = host, .port = parsed_port, - .cacert = client->cacert, - .cert_bundle_attach = client->cert_bundle_attach, + .cacert = client->registration_key[0] != '\0' && client->self_cacert + ? client->self_cacert : client->cacert, + .cert_bundle_attach = client->registration_key[0] != '\0' && client->self_cacert + ? NULL : client->cert_bundle_attach, }; ai_token_response_t resp = {0}; @@ -668,9 +842,12 @@ IOT_API int iot_client_get_session_token_ex(iot_client_t *client, const char *ag IOT_API int iot_client_connect(iot_client_t *client) { - /* No NULL guard, like its neighbour below: iot_client_message_connect() - * already rejects NULL (along with a missing url/devid) with the same - * OPRT_INVALID_PARAMETER, so a guard here could only duplicate it. */ + if (!client) return OPRT_INVALID_PARAMETER; + if (!registration_key_valid(client->registration_key)) return OPRT_INVALID_PARAMETER; + if (!registration_key_empty(client->registration_key) && !client->mqtt) { + int ret = iot_client_dns_resolve(client, NULL, 0); + if (ret != OPRT_OK) return ret; + } return iot_client_message_connect(client); } diff --git a/modules/iot-client/src/iot_client_message.c b/modules/iot-client/src/iot_client_message.c index 453040a..cdfd318 100644 --- a/modules/iot-client/src/iot_client_message.c +++ b/modules/iot-client/src/iot_client_message.c @@ -161,8 +161,14 @@ static int iot_client_message_try_connect(iot_client_t *client) return OPRT_COMMUNICATION_ERROR; } - mqtt_tls_config_t tls_cfg = { .cacert = client->cacert, - .cert_bundle_attach = client->cert_bundle_attach }; + bool has_self_route = client->registration_key[0] != '\0'; + const char *mqtt_cacert = has_self_route && client->self_cacert + ? client->self_cacert : client->cacert; + mqtt_tls_config_t tls_cfg = { + .cacert = mqtt_cacert, + .cert_bundle_attach = has_self_route && client->self_cacert + ? NULL : client->cert_bundle_attach, + }; mqtt_client_config_t mqtt_cfg = { .broker_url = client->mqtt_url, .client_id = client->devid, diff --git a/modules/iot-client/src/iot_dns.c b/modules/iot-client/src/iot_dns.c index ca43fd8..ca30c99 100644 --- a/modules/iot-client/src/iot_dns.c +++ b/modules/iot-client/src/iot_dns.c @@ -7,6 +7,8 @@ #include #include +#include +#include static void parse_ip_array(cJSON *arr, char out[][64], int *count, int max) { *count = 0; @@ -304,6 +306,63 @@ void iot_dns_url_config_response_free(const pal_t *pal, iot_dns_url_config_respo response->ttl = 0; } +int iot_dns_url_config_first_ca_pem(const pal_t *pal, + const iot_dns_url_config_response_t *response, + char **pem_out) +{ + if (!pal || !response || !pem_out) return OPRT_INVALID_PARAMETER; + *pem_out = NULL; + if (!response->ca_arr || response->ca_count < 1 || !response->ca_arr[0]) { + IOT_LOGE("iot_dns: url_config response did not include a CA certificate"); + return OPRT_INVALID_RESULT; + } + + const char *base64 = response->ca_arr[0]; + size_t base64_len = strlen(base64); + if (base64_len == 0 || base64_len > 16384) { + IOT_LOGE("iot_dns: invalid CA certificate length (%zu)", base64_len); + return OPRT_INVALID_RESULT; + } + + size_t der_len = 0; + int ret = mbedtls_base64_decode(NULL, 0, &der_len, + (const unsigned char *)base64, base64_len); + if (ret != MBEDTLS_ERR_BASE64_BUFFER_TOO_SMALL || der_len == 0 || der_len > 12288) { + IOT_LOGE("iot_dns: invalid base64 CA certificate (ret=%d)", ret); + return OPRT_INVALID_RESULT; + } + + unsigned char *der = pal->malloc(der_len); + if (!der) return OPRT_MALLOC_FAILED; + ret = mbedtls_base64_decode(der, der_len, &der_len, + (const unsigned char *)base64, base64_len); + if (ret != 0 || der_len == 0) { + pal->free(der); + IOT_LOGE("iot_dns: failed to decode CA certificate (ret=%d)", ret); + return OPRT_INVALID_RESULT; + } + + size_t pem_capacity = der_len * 2 + 256; + char *pem = pal->malloc(pem_capacity); + if (!pem) { + pal->free(der); + return OPRT_MALLOC_FAILED; + } + size_t pem_len = 0; + ret = mbedtls_pem_write_buffer("-----BEGIN CERTIFICATE-----\n", + "-----END CERTIFICATE-----\n", + der, der_len, (unsigned char *)pem, + pem_capacity, &pem_len); + pal->free(der); + if (ret != 0 || pem_len == 0 || pem_len > pem_capacity) { + pal->free(pem); + IOT_LOGE("iot_dns: failed to encode CA certificate as PEM (ret=%d)", ret); + return OPRT_INVALID_RESULT; + } + *pem_out = pem; + return OPRT_OK; +} + /* ============================================================================ * GET /api/v1/ca-certificate * ============================================================================ */ diff --git a/modules/iot-client/src/iot_dns.h b/modules/iot-client/src/iot_dns.h index 449811c..d19ecfc 100644 --- a/modules/iot-client/src/iot_dns.h +++ b/modules/iot-client/src/iot_dns.h @@ -125,6 +125,13 @@ int iot_dns_url_config(const pal_t *pal, const iot_dns_url_config_request_t *req void iot_dns_url_config_response_free(const pal_t *pal, iot_dns_url_config_response_t *response); +/** Decode the first CA in a url_config response's base64 DER caArr to PEM. + * The returned buffer is allocated with pal->malloc and belongs to the caller. + */ +int iot_dns_url_config_first_ca_pem(const pal_t *pal, + const iot_dns_url_config_response_t *response, + char **pem_out); + /* ============================================================================ * GET /api/v1/ca-certificate * ============================================================================ */ diff --git a/modules/iot-client/src/iot_dp.c b/modules/iot-client/src/iot_dp.c index d1014a1..82418e2 100644 --- a/modules/iot-client/src/iot_dp.c +++ b/modules/iot-client/src/iot_dp.c @@ -896,7 +896,8 @@ int iot_dp_schema_check_update(iot_client_t *client) char host[64] = {0}; uint16_t port = IOT_DEFAULT_PORT; - iot_client_resolve_atop_host(client, host, sizeof(host), &port); + int host_ret = iot_client_resolve_atop_host(client, host, sizeof(host), &port); + if (host_ret != OPRT_OK) return host_ret; schema_newest_request_t req = { .devid = client->devid, @@ -906,8 +907,10 @@ int iot_dp_schema_check_update(iot_client_t *client) .node_id = NULL, .host = host[0] ? host : NULL, .port = port, - .cacert = client->cacert, - .cert_bundle_attach = client->cert_bundle_attach, + .cacert = client->registration_key[0] != '\0' && client->self_cacert + ? client->self_cacert : client->cacert, + .cert_bundle_attach = client->registration_key[0] != '\0' && client->self_cacert + ? NULL : client->cert_bundle_attach, }; schema_newest_response_t resp = {0}; int rt = atop_schema_newest_get(pal, &req, &resp); diff --git a/modules/iot-client/src/iot_dp_internal.h b/modules/iot-client/src/iot_dp_internal.h index d4d78c8..ecb8527 100644 --- a/modules/iot-client/src/iot_dp_internal.h +++ b/modules/iot-client/src/iot_dp_internal.h @@ -47,12 +47,15 @@ void iot_dp_deinit(iot_client_t *client); /** * @brief Resolve the ATOP host/port for this client (implemented in iot_client.c). * - * Mirrors the logic in iot_client_get_session_token(): prefer parsing - * client->https_url, else fall back to iot_region_to_host(region, env). + * Legacy clients may fall back to the region host. Keyed clients require a + * validated Self HTTPS URL and return an error when unresolved. * @p host_out is filled with a NUL-terminated host (empty string if none) and * *port_out is set (defaults to IOT_DEFAULT_PORT). Shared so iot_dp.c does not * duplicate parse_host_port(). */ -void iot_client_resolve_atop_host(iot_client_t *client, char *host_out, size_t host_len, uint16_t *port_out); +int iot_client_resolve_atop_host(iot_client_t *client, char *host_out, size_t host_len, uint16_t *port_out); + +/* Shared by init/reconnect; explicit host/port permit isolated DNS regression tests. */ +int iot_client_dns_resolve(iot_client_t *client, const char *dns_host, uint16_t dns_port); #endif /* __IOT_DP_INTERNAL_H__ */ diff --git a/modules/iot-client/src/iot_on_boarding.c b/modules/iot-client/src/iot_on_boarding.c index 9b77f6e..136e7e1 100644 --- a/modules/iot-client/src/iot_on_boarding.c +++ b/modules/iot-client/src/iot_on_boarding.c @@ -17,7 +17,7 @@ typedef struct { iot_region_t region; iot_env_t env; char token[64]; - char secret[5]; + char registration_key[5]; } activation_message_t; // Internal activation context @@ -34,8 +34,8 @@ static volatile bool g_on_boarding_in_progress = false; static void activate_context_destroy(void); -// Parse activation response JSON and extract URLs -static void parse_activation_message(const pal_t *pal, const char *json_str, activation_message_t *message) { +// Parse the App-selected region, activation token and opaque registration key. +static void parse_activation_message(const char *json_str, activation_message_t *message) { cJSON *root = cJSON_Parse(json_str); if (!root) { IOT_LOGE("Failed to parse activation response JSON"); @@ -44,20 +44,37 @@ static void parse_activation_message(const pal_t *pal, const char *json_str, act // Get data object cJSON *data = cJSON_GetObjectItem(root, "data"); - if (!data) { + if (!cJSON_IsObject(data)) { IOT_LOGE("Activation response missing 'data' field"); cJSON_Delete(root); return; } - // Extract httpsUrl - cJSON *https_url = cJSON_GetObjectItem(data, "httpsUrl"); - if (https_url && cJSON_IsString(https_url)) { - message->https_url = pal_strdup(pal, https_url->valuestring); - if (message->https_url) { - IOT_LOGI("Activation httpsUrl: %s", message->https_url); + cJSON *env = cJSON_GetObjectItemCaseSensitive(data, "env"); + const char *registration_key = "pro"; /* Same missing-field default as TuyaOpen. */ + if (env) { + if (!cJSON_IsString(env)) { + IOT_LOGE("Activation message contains an invalid registration key"); + cJSON_Delete(root); + return; } + registration_key = env->valuestring; + } + size_t key_len = strlen(registration_key); + if (key_len == 0 || key_len >= sizeof(message->registration_key)) { + IOT_LOGE("Activation message registration key must be 1 to 4 bytes"); + cJSON_Delete(root); + return; } + for (size_t i = 0; i < key_len; i++) { + unsigned char c = (unsigned char)registration_key[i]; + if (c < 0x21 || c > 0x7e) { + IOT_LOGE("Activation message registration key contains an invalid byte"); + cJSON_Delete(root); + return; + } + } + memcpy(message->registration_key, registration_key, key_len + 1); // Extract region cJSON *region = cJSON_GetObjectItem(data, "region"); @@ -172,13 +189,13 @@ static void internal_message_callback(const char *topic, size_t topic_len, } activation_message_t message = {0}; - // Step 3: Parse JSON and extract activation URLs + // Step 3: Parse JSON; activation endpoints come from trusted IoT DNS. if (final_len > 0) { char *json_str = (char *)pal->malloc(final_len + 1); if (json_str) { memcpy(json_str, final_payload, final_len); json_str[final_len] = '\0'; - parse_activation_message(pal, json_str, &message); + parse_activation_message(json_str, &message); pal->free(json_str); } } @@ -189,20 +206,10 @@ static void internal_message_callback(const char *topic, size_t topic_len, if (g_activate_ctx->message) { memcpy(g_activate_ctx->message, &message, sizeof(activation_message_t)); g_activate_ctx->received_activation_message = true; - if (message.https_url) { - g_activate_ctx->message->https_url = pal_strdup(pal, message.https_url); - } else { - g_activate_ctx->message->https_url = NULL; - } IOT_LOGI("Activation message stored in context"); } } - if (message.https_url) { - pal->free(message.https_url); - message.https_url = NULL; - } - if (decrypted) { pal->free(decrypted); } @@ -242,7 +249,8 @@ static int activate_device(const pal_t *pal, on_boarding_config_t *on_boarding, request.user_data = NULL; char *parsed_host = NULL; - request.host = IOT_DEFAULT_HOST; + request.host = act_msg->registration_key[0] ? NULL + : iot_region_to_host(act_msg->region, act_msg->env); request.port = IOT_DEFAULT_PORT; if (act_msg->https_url && act_msg->https_url[0] != '\0') { @@ -276,13 +284,15 @@ static int activate_device(const pal_t *pal, on_boarding_config_t *on_boarding, request.port = IOT_DEFAULT_PORT; } } + if (act_msg->registration_key[0] && !parsed_host) { + IOT_LOGE("Unable to use App-selected activation endpoint"); + return OPRT_MALLOC_FAILED; + } request.cacert = on_boarding->cacert; request.cert_bundle_attach = on_boarding->cert_bundle_attach; IOT_LOGI("Sending activation request with:"); - IOT_LOGI(" - Token: [%zu chars, prefix=%.4s...]", - request.token ? strlen(request.token) : 0, - (request.token && strlen(request.token) >= 4) ? request.token : "----"); + IOT_LOGI(" - Token: [%zu chars]", request.token ? strlen(request.token) : 0); IOT_LOGI(" - Software Version: %s", request.sw_ver); IOT_LOGI(" - Product Key: %s", request.product_key); IOT_LOGI(" - Protocol Version: %s", request.pv); @@ -364,6 +374,8 @@ static int activate_device(const pal_t *pal, on_boarding_config_t *on_boarding, response->schema = pal_strdup(pal, activate_response.schema); response->region = act_msg->region; response->env = act_msg->env; + memcpy(response->registration_key, act_msg->registration_key, + sizeof(response->registration_key)); } atop_activate_response_free(pal, &activate_response); @@ -406,6 +418,121 @@ static int __token_to_region(const char *token, iot_region_t *region) return OPRT_OK; } +/* IoT DNS Self URLs must fit the IoT client's 64-byte endpoint buffers. + * Validate the authority before handing the HTTPS URL to activate_device, + * whose legacy QR path still supports the old endpoint parsing rules. */ +static bool valid_self_authority(const char *addr, bool https, bool has_tls_trust) +{ + if (!addr) return false; + size_t len = strlen(addr); + if (len == 0 || len >= (https ? 64u : 56u)) return false; + + const char *start = addr; + if (https) { + if (strncmp(addr, "https://", 8) != 0) return false; + start += 8; + } + const char *authority_end = https ? strchr(start, '/') : NULL; + if (!authority_end) authority_end = addr + len; + /* ATOP always sends to /d.json. A DNS path cannot be silently ignored. */ + if (https && *authority_end != '\0' && strcmp(authority_end, "/d.json") != 0) + return false; + const char *colon = memchr(start, ':', (size_t)(authority_end - start)); + const char *host_end = colon ? colon : authority_end; + if (host_end == start || (!https && !colon)) return false; + for (const char *p = start; p < host_end; p++) { + unsigned char c = (unsigned char)*p; + if (!((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || + (c >= '0' && c <= '9') || c == '.' || c == '-')) return false; + } + unsigned int port = https ? 443u : 0u; + if (colon) { + port = 0; + if (colon + 1 == authority_end) return false; + for (const char *p = colon + 1; p < authority_end; p++) { + unsigned char c = (unsigned char)*p; + if (c < '0' || c > '9') return false; + port = port * 10u + (unsigned int)(c - '0'); + if (port > 65535u) return false; + } + if (port == 0) return false; + } + /* The HTTP transport selects plaintext for port 80, and for other + * non-443 ports when no CA/certificate bundle is configured. */ + if (https && (port == 80u || (port != 443u && !has_tls_trust))) return false; + return true; +} + +/* Both App onboarding transports use the opaque registration key for Self + * endpoint discovery. Never let the payload httpsUrl select a different route. */ +static int activate_with_registration_key(const pal_t *pal, on_boarding_config_t *on_boarding, + const activation_message_t *message, + on_boarding_response_t *response) +{ + if ((!on_boarding->cacert || on_boarding->cacert[0] == '\0') && + !on_boarding->cert_bundle_attach) { + IOT_LOGE("App-selected IoT DNS lookup requires a trusted TLS CA"); + return OPRT_INVALID_PARAMETER; + } + + const iot_dns_config_item_t keys[] = { + { .key = "httpsSelfUrl", .need_ca = true }, + { .key = "mqttsSelfUrl", .need_ca = true }, + }; + iot_dns_url_config_request_t dns_req = { + .host = on_boarding->dns_host, + .port = on_boarding->dns_port, + .cacert = on_boarding->cacert, + .cert_bundle_attach = on_boarding->cert_bundle_attach, + .region = iot_region_to_string(message->region), + .env = message->registration_key, + .uuid = on_boarding->uuid, + .config = keys, + .config_count = 2, + }; + iot_dns_url_config_response_t dns_resp = {0}; + int ret = iot_dns_url_config(pal, &dns_req, &dns_resp); + if (ret != OPRT_OK) { + IOT_LOGE("App-selected IoT DNS lookup failed: %d", ret); + return ret; + } + + const char *https_addr = NULL; + const char *mqtts_addr = NULL; + for (int i = 0; i < dns_resp.endpoint_count; i++) { + if (strcmp(dns_resp.endpoints[i].key, "httpsSelfUrl") == 0) + https_addr = dns_resp.endpoints[i].addr; + else if (strcmp(dns_resp.endpoints[i].key, "mqttsSelfUrl") == 0) + mqtts_addr = dns_resp.endpoints[i].addr; + } + bool has_tls_trust = (on_boarding->cacert && on_boarding->cacert[0]) || + on_boarding->cert_bundle_attach; + if (!valid_self_authority(https_addr, true, has_tls_trust) || + !valid_self_authority(mqtts_addr, false, has_tls_trust)) { + IOT_LOGE("IoT DNS did not return valid App-selected Self endpoints"); + iot_dns_url_config_response_free(pal, &dns_resp); + return OPRT_INVALID_RESULT; + } + + char *self_cacert = NULL; + ret = iot_dns_url_config_first_ca_pem(pal, &dns_resp, &self_cacert); + if (ret != OPRT_OK) { + IOT_LOGE("IoT DNS did not provide a usable CA for App-selected Self endpoints"); + iot_dns_url_config_response_free(pal, &dns_resp); + return ret; + } + + activation_message_t act_msg = *message; + act_msg.https_url = (char *)https_addr; /* borrowed until DNS response is freed */ + on_boarding_config_t activation_config = *on_boarding; + activation_config.cacert = self_cacert; + activation_config.cert_bundle_attach = NULL; + ret = activate_device(pal, &activation_config, &act_msg, response); + pal->free(self_cacert); + iot_dns_url_config_response_free(pal, &dns_resp); + return ret; +} + int on_boarding_with_token(const pal_t *pal, on_boarding_config_t *on_boarding, const char *token, on_boarding_response_t *response) { @@ -414,11 +541,25 @@ int on_boarding_with_token(const pal_t *pal, on_boarding_config_t *on_boarding, return OPRT_INVALID_PARAMETER; } + /* App authToken: region(2) + activation token(8) + opaque key(4). */ + enum { REGION_LEN = 2, ACTIVATION_TOKEN_LEN = 8, REGISTRATION_KEY_LEN = 4 }; size_t token_len = strlen(token); - if (token_len < 7) { - IOT_LOGE("Token too short: need at least 7 chars (2 region + token + 4 secret)"); + if (token_len != REGION_LEN + ACTIVATION_TOKEN_LEN + REGISTRATION_KEY_LEN) { + IOT_LOGE("Invalid App activation token length: %zu", token_len); return OPRT_INVALID_PARAMETER; } + for (size_t i = 0; i < token_len; i++) { + unsigned char c = (unsigned char)token[i]; + if (c < 0x21 || c > 0x7e) { + IOT_LOGE("App activation token contains an invalid byte"); + return OPRT_INVALID_PARAMETER; + } + if (i >= REGION_LEN && i < REGION_LEN + ACTIVATION_TOKEN_LEN && + (c == '"' || c == '\\')) { + IOT_LOGE("App activation token contains an unsafe JSON byte"); + return OPRT_INVALID_PARAMETER; + } + } iot_region_t region; int ret = __token_to_region(token, ®ion); @@ -426,24 +567,13 @@ int on_boarding_with_token(const pal_t *pal, on_boarding_config_t *on_boarding, IOT_LOGE("Failed to parse region from token prefix"); return ret; } - activation_message_t act_msg = {0}; - size_t act_token_len = token_len - 2 - 4; - memcpy(act_msg.secret, token + token_len - 4, 4); - act_msg.secret[4] = '\0'; - if (act_token_len >= sizeof(act_msg.token)) { - act_token_len = sizeof(act_msg.token) - 1; - } - memcpy(act_msg.token, token + 2, act_token_len); - act_msg.token[act_token_len] = '\0'; + memcpy(act_msg.token, token + REGION_LEN, ACTIVATION_TOKEN_LEN); + memcpy(act_msg.registration_key, token + REGION_LEN + ACTIVATION_TOKEN_LEN, + REGISTRATION_KEY_LEN); act_msg.region = region; - act_msg.env = on_boarding->env; - act_msg.https_url = iot_region_to_host(region, on_boarding->env); - - IOT_LOGI("on_boarding_with_token: region=%d env=%d", - region, on_boarding->env); - - return activate_device(pal, on_boarding, &act_msg, response); + act_msg.env = on_boarding->env; /* Retain the legacy enum without rewriting it. */ + return activate_with_registration_key(pal, on_boarding, &act_msg, response); } static int activate_context_init(const pal_t *pal) { @@ -481,6 +611,12 @@ int on_boarding_with_qrcode(const pal_t *pal, on_boarding_config_t *on_boarding, IOT_LOGE("Invalid parameters for on boarding"); return OPRT_INVALID_PARAMETER; } + if (on_boarding->mqtt_disable_tls || + ((!on_boarding->cacert || on_boarding->cacert[0] == '\0') && + !on_boarding->cert_bundle_attach)) { + IOT_LOGE("QR on-boarding requires MQTT TLS and trusted bootstrap CA/bundle"); + return OPRT_INVALID_PARAMETER; + } if (g_on_boarding_in_progress) { IOT_LOGE("on_boarding_with_qrcode: already in progress"); @@ -679,7 +815,7 @@ int on_boarding_with_qrcode(const pal_t *pal, on_boarding_config_t *on_boarding, } g_activate_ctx->message->env = on_boarding->env; - ret = activate_device(pal, on_boarding, g_activate_ctx->message, response); + ret = activate_with_registration_key(pal, on_boarding, g_activate_ctx->message, response); if (ret != OPRT_OK) { IOT_LOGE("Failed to activate device: %d", ret); } diff --git a/modules/iot-client/src/iot_on_boarding.h b/modules/iot-client/src/iot_on_boarding.h index 4029b0a..86695d8 100644 --- a/modules/iot-client/src/iot_on_boarding.h +++ b/modules/iot-client/src/iot_on_boarding.h @@ -39,6 +39,7 @@ typedef struct { char *schema; // Device schema JSON (caller must free via pal->free) iot_region_t region; iot_env_t env; + char registration_key[5]; // Opaque App key: BLE four bytes, QR data.env 1-4 bytes (default pro) } on_boarding_response_t; /** @@ -60,15 +61,14 @@ int on_boarding_with_qrcode(const pal_t *pal, on_boarding_config_t *on_boarding, /** * @brief Activate device via token on-boarding (direct ATOP call). * - * Parses region from the first 2 characters and secret from the last 4 - * characters of @p token, then sends the ATOP activation request directly. - * Does not use MQTT or DNS. + * Parses the App's fixed-width authToken and resolves activation endpoints + * from IoT DNS using its opaque registration key before contacting ATOP. * * @param pal PAL adapter * @param on_boarding On-boarding configuration (uuid, authkey, product_key, etc.) - * @param token Activation token: [region:2][activation_token][secret:4] (min 7 chars) + * @param token App token: [region:2][activation_token:8][registration_key:4] * @param response Output: activated device credentials - * @return OPRT_OK on success, OPRT_INVALID_PARAMETER if any required param is NULL/empty or token too short + * @return OPRT_OK on success, OPRT_INVALID_PARAMETER for malformed tokens */ int on_boarding_with_token(const pal_t *pal, on_boarding_config_t *on_boarding, const char *token, on_boarding_response_t *response); diff --git a/modules/iot-client/src/iot_ota.c b/modules/iot-client/src/iot_ota.c index 1b29906..aea6a74 100644 --- a/modules/iot-client/src/iot_ota.c +++ b/modules/iot-client/src/iot_ota.c @@ -23,7 +23,9 @@ int iot_ota_report_version(iot_client_t *client, const char *sw_ver) char host[64] = {0}; uint16_t port = IOT_DEFAULT_PORT; - iot_client_resolve_atop_host(client, host, sizeof(host), &port); + int host_ret = iot_client_resolve_atop_host(client, host, sizeof(host), &port); + if (host_ret != OPRT_OK) return host_ret; + bool has_self_ca = client->registration_key[0] != '\0' && client->self_cacert; ota_version_update_request_t req = { .devid = client->devid, @@ -34,8 +36,8 @@ int iot_ota_report_version(iot_client_t *client, const char *sw_ver) .channel = 0, .host = host[0] ? host : NULL, .port = port, - .cacert = client->cacert, - .cert_bundle_attach = client->cert_bundle_attach, + .cacert = has_self_ca ? client->self_cacert : client->cacert, + .cert_bundle_attach = has_self_ca ? NULL : client->cert_bundle_attach, }; return atop_version_update(client->pal, &req); @@ -52,7 +54,9 @@ int iot_ota_check_upgrade(iot_client_t *client, int channel, char host[64] = {0}; uint16_t port = IOT_DEFAULT_PORT; - iot_client_resolve_atop_host(client, host, sizeof(host), &port); + int host_ret = iot_client_resolve_atop_host(client, host, sizeof(host), &port); + if (host_ret != OPRT_OK) return host_ret; + bool has_self_ca = client->registration_key[0] != '\0' && client->self_cacert; ota_upgrade_request_t req = { .devid = client->devid, @@ -60,8 +64,8 @@ int iot_ota_check_upgrade(iot_client_t *client, int channel, .channel = channel, .host = host[0] ? host : NULL, .port = port, - .cacert = client->cacert, - .cert_bundle_attach = client->cert_bundle_attach, + .cacert = has_self_ca ? client->self_cacert : client->cacert, + .cert_bundle_attach = has_self_ca ? NULL : client->cert_bundle_attach, }; ota_upgrade_response_t resp = {0}; @@ -91,7 +95,9 @@ int iot_ota_report_status(iot_client_t *client, int channel, iot_ota_status_t st char host[64] = {0}; uint16_t port = IOT_DEFAULT_PORT; - iot_client_resolve_atop_host(client, host, sizeof(host), &port); + int host_ret = iot_client_resolve_atop_host(client, host, sizeof(host), &port); + if (host_ret != OPRT_OK) return host_ret; + bool has_self_ca = client->registration_key[0] != '\0' && client->self_cacert; ota_status_update_request_t req = { .devid = client->devid, @@ -100,8 +106,8 @@ int iot_ota_report_status(iot_client_t *client, int channel, iot_ota_status_t st .status = status, .host = host[0] ? host : NULL, .port = port, - .cacert = client->cacert, - .cert_bundle_attach = client->cert_bundle_attach, + .cacert = has_self_ca ? client->self_cacert : client->cacert, + .cert_bundle_attach = has_self_ca ? NULL : client->cert_bundle_attach, }; return atop_upgrade_status_update(client->pal, &req); diff --git a/modules/iot-client/test/dns_test.c b/modules/iot-client/test/dns_test.c index e3fc1bc..484cd4b 100644 --- a/modules/iot-client/test/dns_test.c +++ b/modules/iot-client/test/dns_test.c @@ -13,14 +13,284 @@ #include "iot_dns.h" #include "iot_client.h" +#include "iot_client_message.h" #include "iot_internal.h" +#include "iot_dp_internal.h" #define MOCK_HOST "127.0.0.1" #define MOCK_PORT 8198 static pid_t mock_pid = -1; +static pid_t tls_mock_pid = -1; static int tests_run = 0; static int tests_passed = 0; +static char expected_env_file[] = "/tmp/agentic-dns-env-XXXXXX"; +static pal_t fail_dns_pal; +static int failed_dns_connects = 0; +static char *test_cacert = NULL; +static int wait_for_port(uint16_t port, int timeout_ms); + +static char *load_test_cacert(void) +{ + FILE *file = fopen(TEST_CONFIG_DIR "/root_cert.pem", "rb"); + if (!file) return NULL; + if (fseek(file, 0, SEEK_END) != 0) { fclose(file); return NULL; } + long size = ftell(file); + if (size <= 0 || fseek(file, 0, SEEK_SET) != 0) { fclose(file); return NULL; } + char *pem = malloc((size_t)size + 1); + if (!pem) { fclose(file); return NULL; } + size_t read_size = fread(pem, 1, (size_t)size, file); + fclose(file); + if (read_size != (size_t)size) { free(pem); return NULL; } + pem[size] = '\0'; + return pem; +} + +static void *always_fail_tcp_connect(const char *host, uint16_t port, uint32_t timeout_ms) +{ + (void)host; + (void)port; + (void)timeout_ms; + failed_dns_connects++; + return NULL; +} + +static int expect_dns_env(const char *env) +{ + int fd = open(expected_env_file, O_WRONLY | O_TRUNC); + if (fd < 0) return -1; + size_t len = strlen(env); + int ok = write(fd, env, len) == (ssize_t)len; + close(fd); + return ok ? 0 : -1; +} + +static void init_keyed_client(iot_client_t *client, const char *key) +{ + memset(client, 0, sizeof(*client)); + client->pal = get_default_pal(); + client->region = AY; + snprintf(client->devid, sizeof(client->devid), "ci_device_test_001"); + client->cacert = test_cacert; + memcpy(client->registration_key, key, 5); +} + +static int test_keyed_dns_requires_a_trusted_bootstrap_ca(void) +{ + iot_client_t client; + init_keyed_client(&client, "pr_0"); + client.cacert = NULL; + int ret = iot_client_dns_resolve(&client, MOCK_HOST, 8199); + if (ret != OPRT_INVALID_PARAMETER) { + printf(" keyed DNS accepted missing bootstrap CA/bundle: %d\n", ret); + return -1; + } + return 0; +} + +static int test_keyed_dns_routes_raw_env_and_self_endpoints(void) +{ + iot_client_t client; + init_keyed_client(&client, "pr_0"); + if (expect_dns_env("pr_0") != 0) return -1; + int ret = iot_client_dns_resolve(&client, MOCK_HOST, 8199); + if (ret != OPRT_OK || strcmp(client.https_url, "https://127.0.0.1:443/d.json") != 0 || + strcmp(client.mqtt_url, "mqtts://127.0.0.1:11884") != 0) { + printf(" keyed DNS did not return Self endpoints: %d\n", ret); + return -1; + } + char host[64] = {0}; + uint16_t port = 0; + if (iot_client_resolve_atop_host(&client, host, sizeof(host), &port) != OPRT_OK || + strcmp(host, "127.0.0.1") != 0 || port != 443) return -1; + return 0; +} + +static int test_keyed_dns_missing_endpoint_clears_both(void) +{ + iot_client_t client; + init_keyed_client(&client, "MSMQ"); + if (expect_dns_env("MSMQ") != 0) return -1; + snprintf(client.https_url, sizeof(client.https_url), "https://old.example.com/d.json"); + snprintf(client.mqtt_url, sizeof(client.mqtt_url), "mqtts://old.example.com:8883"); + int ret = iot_client_dns_resolve(&client, MOCK_HOST, 8199); + if (ret == OPRT_OK || client.https_url[0] || client.mqtt_url[0]) { + printf(" missing Self endpoint reused old route: %d\n", ret); + return -1; + } + return 0; +} + +static int test_keyed_dns_oversize_clears_both(void) +{ + iot_client_t client; + init_keyed_client(&client, "LONG"); + if (expect_dns_env("LONG") != 0) return -1; + int ret = iot_client_dns_resolve(&client, MOCK_HOST, 8199); + if (ret == OPRT_OK || client.https_url[0] || client.mqtt_url[0]) return -1; + return 0; +} + +static int test_keyed_dns_missing_https_clears_both(void) +{ + iot_client_t client; + init_keyed_client(&client, "MSHT"); + if (expect_dns_env("MSHT") != 0) return -1; + int ret = iot_client_dns_resolve(&client, MOCK_HOST, 8199); + if (ret == OPRT_OK || client.https_url[0] || client.mqtt_url[0]) return -1; + return 0; +} + +static int test_keyed_dns_overflowed_port_is_rejected(void) +{ + iot_client_t client; + init_keyed_client(&client, "OVFL"); + if (expect_dns_env("OVFL") != 0) return -1; + int ret = iot_client_dns_resolve(&client, MOCK_HOST, 8199); + if (ret == OPRT_OK || client.https_url[0] || client.mqtt_url[0]) { + printf(" overflowing port was accepted: %d\n", ret); + return -1; + } + return 0; +} + +static int test_registration_key_is_bounded_printable_string(void) +{ + iot_client_config_t cfg = {0}; + cfg.skip_version_report = true; + const char bad[][5] = { + {'a', 'b', 'c', '\0', 'x'}, + {'a', 'b', 'c', 'd', 'e'}, + {'a', '\0', 'c', 'd', '\0'}, + {'a', 'b', '\n', 'd', '\0'}, + {'\0', 'b', 'c', 'd', '\0'}, + }; + for (size_t i = 0; i < sizeof(bad) / sizeof(bad[0]); i++) { + memcpy(cfg.registration_key, bad[i], 5); + iot_client_t *client = iot_client_init(&cfg); + if (client) { + iot_client_deinit(client); + printf(" accepted malformed key shape %zu\n", i); + return -1; + } + } + memset(cfg.registration_key, 0, sizeof(cfg.registration_key)); + iot_client_t *legacy = iot_client_init(&cfg); + if (!legacy) return -1; + iot_client_deinit(legacy); + return 0; +} + +static int test_keyed_dns_accepts_qr_registration_keys(void) +{ + const char keys[][5] = {"pro", "pre", "prod", "a", "ab", "abc"}; + for (size_t i = 0; i < sizeof(keys) / sizeof(keys[0]); i++) { + iot_client_t client; + init_keyed_client(&client, keys[i]); + if (expect_dns_env(keys[i]) != 0) return -1; + int ret = iot_client_dns_resolve(&client, MOCK_HOST, 8199); + int ok = ret == OPRT_OK && client.https_url[0] && client.mqtt_url[0]; + client.pal->free(client.owned_self_cacert); + if (!ok) { + printf(" Raw QR key %s was rejected/rewritten: %d\n", keys[i], ret); + return -1; + } + } + return 0; +} + +static int test_keyed_client_rejects_plaintext_mqtt_configuration(void) +{ + iot_client_config_t cfg = {0}; + cfg.skip_version_report = true; + cfg.mqtt_disable_tls = true; + memcpy(cfg.registration_key, "pr_0", 5); + iot_client_t *client = iot_client_init(&cfg); + if (client) { + iot_client_deinit(client); + printf(" keyed client accepted unsupported plaintext MQTT route\n"); + return -1; + } + return 0; +} + +static int test_keyed_init_retains_credentials_and_retries_dns(void) +{ + fail_dns_pal = *get_default_pal(); + fail_dns_pal.tcp_connect = always_fail_tcp_connect; + failed_dns_connects = 0; + if (iot_init(&fail_dns_pal) != OPRT_OK) return -1; + + iot_client_config_t cfg = {0}; + snprintf(cfg.devid, sizeof(cfg.devid), "device-with-credentials"); + snprintf(cfg.secret_key, sizeof(cfg.secret_key), "secret-for-test"); + snprintf(cfg.local_key, sizeof(cfg.local_key), "local-key-for-test"); + cfg.cacert = test_cacert; + memcpy(cfg.registration_key, "pr_0", 5); + iot_client_t *client = iot_client_init(&cfg); + int first_attempts = failed_dns_connects; + int reconnect_ret = client ? iot_client_connect(client) : OPRT_OK; + int all_attempts = failed_dns_connects; + int ok = client && !client->mqtt && client->https_url[0] == '\0' && + client->mqtt_url[0] == '\0' && + strcmp(client->registration_key, "pr_0") == 0 && + strcmp(client->devid, cfg.devid) == 0 && + strcmp(client->secret_key, cfg.secret_key) == 0 && + first_attempts > 0 && all_attempts > first_attempts && + reconnect_ret != OPRT_OK; + if (client) iot_client_deinit(client); + iot_init(get_default_pal()); + if (!ok) { + printf(" keyed init/reconnect lost credentials or skipped DNS retry\n"); + return -1; + } + return 0; +} + +static int test_keyed_init_retains_credentials_after_mqtt_failure(void) +{ + fail_dns_pal = *get_default_pal(); + fail_dns_pal.tcp_connect = always_fail_tcp_connect; + if (iot_init(&fail_dns_pal) != OPRT_OK) return -1; + iot_client_config_t cfg = {0}; + snprintf(cfg.secret_key, sizeof(cfg.secret_key), "1234567890abcdef"); + snprintf(cfg.local_key, sizeof(cfg.local_key), "0123456789abcdef"); + cfg.region = AY; + cfg.skip_version_report = true; + cfg.cacert = test_cacert; + cfg.mqtt_disable_auto_connect = true; + + iot_client_t *client = iot_client_init(&cfg); + if (client) { + snprintf(client->devid, sizeof(client->devid), "ci_device_test_001"); + memcpy(client->registration_key, "pr_0", 5); + snprintf(client->https_url, sizeof(client->https_url), "https://127.0.0.1:443/d.json"); + snprintf(client->mqtt_url, sizeof(client->mqtt_url), "mqtts://127.0.0.1:19998"); + client->self_cacert = test_cacert; + } + int connect_ret = client ? iot_client_message_connect(client) : OPRT_INVALID_PARAMETER; + int credentials_ok = client && + strcmp(client->devid, "ci_device_test_001") == 0 && + strcmp(client->secret_key, cfg.secret_key) == 0 && + strcmp(client->local_key, cfg.local_key) == 0; + int key_ok = client && strcmp(client->registration_key, "pr_0") == 0; + int routes_ok = client && + strcmp(client->mqtt_url, "mqtts://127.0.0.1:19998") == 0 && + strcmp(client->https_url, "https://127.0.0.1:443/d.json") == 0; + int ok = client && client->mqtt == NULL && connect_ret != OPRT_OK && + credentials_ok && key_ok && routes_ok; + if (!ok) { + printf(" keyed MQTT failure state: client=%d mqtt=%d ret=%d credentials=%d key=%d routes=%d\n", + client != NULL, client && client->mqtt != NULL, connect_ret, + credentials_ok, key_ok, routes_ok); + } + if (client) iot_client_deinit(client); + iot_init(get_default_pal()); + if (!ok) { + return -1; + } + return 0; +} #define RUN_TEST(fn) \ do { \ @@ -93,6 +363,28 @@ static int start_mock(void) return OPRT_OK; } +static int start_tls_mock(void) +{ + tls_mock_pid = fork(); + if (tls_mock_pid == 0) { + setenv("DNS_MOCK_USE_SSL", "1", 1); + setenv("DNS_MOCK_PORT", "8199", 1); + execlp(PYTHON3_EXEC, PYTHON3_EXEC, DNS_MOCK_PATH, NULL); + _exit(1); + } + if (tls_mock_pid < 0) return -1; + return wait_for_port(8199, 15000); +} + +static void stop_tls_mock(void) +{ + if (tls_mock_pid > 0) { + kill(tls_mock_pid, SIGTERM); + waitpid(tls_mock_pid, NULL, 0); + tls_mock_pid = -1; + } +} + static void stop_mock(void) { if (mock_pid > 0) { @@ -780,6 +1072,14 @@ int main(void) printf("========== IoT DNS Test Suite ==========\n"); iot_init(get_default_pal()); + test_cacert = load_test_cacert(); + if (!test_cacert) return 1; + + int env_fd = mkstemp(expected_env_file); + if (env_fd < 0) return 1; + close(env_fd); + setenv("DNS_MOCK_EXPECTED_ENV_FILE", expected_env_file, 1); + setenv("ATOP_MOCK_PORT", "443", 1); if (start_mock() != 0) { fprintf(stderr, "Failed to start DNS mock server\n"); @@ -819,12 +1119,27 @@ int main(void) RUN_TEST(test_url_config_sdk_keys_resolve); RUN_TEST(test_url_config_basic); RUN_TEST(test_url_config_with_region); + if (start_tls_mock() != 0) return 1; + RUN_TEST(test_keyed_dns_requires_a_trusted_bootstrap_ca); + RUN_TEST(test_keyed_dns_routes_raw_env_and_self_endpoints); + RUN_TEST(test_keyed_dns_missing_endpoint_clears_both); + RUN_TEST(test_keyed_dns_oversize_clears_both); + RUN_TEST(test_keyed_dns_missing_https_clears_both); + RUN_TEST(test_keyed_dns_overflowed_port_is_rejected); + RUN_TEST(test_keyed_dns_accepts_qr_registration_keys); + stop_tls_mock(); + RUN_TEST(test_registration_key_is_bounded_printable_string); + RUN_TEST(test_keyed_client_rejects_plaintext_mqtt_configuration); + RUN_TEST(test_keyed_init_retains_credentials_and_retries_dns); + RUN_TEST(test_keyed_init_retains_credentials_after_mqtt_failure); /* GET /api/v1/ca-certificate */ RUN_TEST(test_ca_cert_rsa); RUN_TEST(test_ca_cert_ecdsa); stop_mock(); + free(test_cacert); + unlink(expected_env_file); printf("\n========== Results: %d/%d passed ==========\n", tests_passed, tests_run); diff --git a/modules/iot-client/test/iot_atop_call_test.c b/modules/iot-client/test/iot_atop_call_test.c index 424184b..64c713c 100644 --- a/modules/iot-client/test/iot_atop_call_test.c +++ b/modules/iot-client/test/iot_atop_call_test.c @@ -205,6 +205,21 @@ static int test_requires_device_credentials(void) return 0; } +static int test_keyed_generic_atop_requires_self_https(void) +{ + iot_client_t client = g_client; + memcpy(client.registration_key, "pr_0", 5); + client.https_url[0] = '\0'; + iot_atop_request_t req = { .api = "tuya.device.meta.save", .version = "1.0" }; + iot_atop_response_t resp = {0}; + int ret = iot_atop_call(&client, &req, &resp); + if (ret != OPRT_UNINITIALIZED || resp.result != NULL) { + iot_atop_response_free(&client, &resp); + return -1; + } + return 0; +} + /* A malformed body is caught locally, before spending an HTTPS round trip. */ static int test_body_must_be_json_object(void) { @@ -575,6 +590,7 @@ int main(void) RUN_TEST(test_null_params); RUN_TEST(test_api_and_version_required); RUN_TEST(test_requires_device_credentials); + RUN_TEST(test_keyed_generic_atop_requires_self_https); RUN_TEST(test_body_must_be_json_object); /* Round trips */ diff --git a/modules/iot-client/test/iot_ota_test.c b/modules/iot-client/test/iot_ota_test.c index a09fa6d..c4882b3 100644 --- a/modules/iot-client/test/iot_ota_test.c +++ b/modules/iot-client/test/iot_ota_test.c @@ -13,6 +13,7 @@ #include "atop.h" #include "iot_client.h" +#include "iot_ota.h" #include "iot_internal.h" #define MOCK_HOST "127.0.0.1" @@ -348,6 +349,58 @@ static int test_upgrade_status_update_null_params(void) return OPRT_OK; } +static int test_keyed_ota_and_version_init_require_self_https(void) +{ + iot_client_t client = {0}; + client.pal = get_default_pal(); + client.region = AY; + memcpy(client.registration_key, "pr_0", 5); + snprintf(client.devid, sizeof(client.devid), "%s", TEST_DEVID); + snprintf(client.secret_key, sizeof(client.secret_key), "%s", TEST_SEC_KEY); + iot_ota_upgrade_info_t info = {0}; + iot_client_config_t config = {0}; + if (iot_ota_report_version(&client, "1.0.0") != OPRT_UNINITIALIZED || + iot_ota_check_upgrade(&client, 0, &info) != OPRT_UNINITIALIZED || + iot_ota_report_status(&client, 0, OTA_STATUS_READY) != OPRT_UNINITIALIZED || + iot_client_report_init_versions(&client, &config) != OPRT_UNINITIALIZED) { + printf(" keyed ATOP/OTA request escaped without Self HTTPS\n"); + return -1; + } + return 0; +} + +static int test_keyed_ota_uses_dns_self_ca(void) +{ + iot_client_t client = {0}; + client.pal = get_default_pal(); + client.region = AY; + memcpy(client.registration_key, "pr_0", 5); + snprintf(client.devid, sizeof(client.devid), "%s", TEST_DEVID); + snprintf(client.secret_key, sizeof(client.secret_key), "%s", TEST_SEC_KEY); + snprintf(client.https_url, sizeof(client.https_url), + "https://127.0.0.1:%u/d.json", MOCK_PORT); + client.cacert = "not a valid CA"; + client.self_cacert = g_cacert; + + if (iot_ota_report_version(&client, "1.0.0") != OPRT_OK) { + printf(" OTA version report did not use Self CA\n"); + return -1; + } + + iot_ota_upgrade_info_t info = {0}; + if (iot_ota_check_upgrade(&client, 0, &info) != OPRT_OK) { + printf(" OTA upgrade query did not use Self CA\n"); + return -1; + } + iot_ota_upgrade_info_free(&client, &info); + + if (iot_ota_report_status(&client, 0, OTA_STATUS_READY) != OPRT_OK) { + printf(" OTA status report did not use Self CA\n"); + return -1; + } + return 0; +} + /* ---------- main ---------- */ int main(void) @@ -375,6 +428,8 @@ int main(void) RUN_TEST(test_upgrade_get_null_params); RUN_TEST(test_upgrade_get_missing_devid); RUN_TEST(test_upgrade_status_update_null_params); + RUN_TEST(test_keyed_ota_and_version_init_require_self_https); + RUN_TEST(test_keyed_ota_uses_dns_self_ca); /* Success tests */ RUN_TEST(test_version_update); diff --git a/modules/iot-client/test/iot_session_token_test.c b/modules/iot-client/test/iot_session_token_test.c index 6e44ee5..ad64a70 100644 --- a/modules/iot-client/test/iot_session_token_test.c +++ b/modules/iot-client/test/iot_session_token_test.c @@ -30,6 +30,7 @@ #include "iot_client.h" #include "iot_internal.h" +#include "iot_dp_internal.h" #include "log.h" #define MOCK_HOST "127.0.0.1" @@ -158,6 +159,90 @@ static int test_null_params(void) return 0; } +static int test_keyed_client_without_https_never_uses_default_host(void) +{ + iot_client_t client = g_client; + memcpy(client.registration_key, "pr_0", sizeof(client.registration_key)); + client.https_url[0] = '\0'; + char host[64] = {0}; + uint16_t port = 0; + if (iot_client_resolve_atop_host(&client, host, sizeof(host), &port) == OPRT_OK || host[0]) { + printf(" keyed client resolved a missing HTTPS endpoint\n"); + return -1; + } + char token[128] = {0}; + if (iot_client_get_session_token(&client, "agent", token, sizeof(token)) == OPRT_OK) { + printf(" keyed client made ATOP request without HTTPS endpoint\n"); + return -1; + } + return 0; +} + +static int test_keyed_client_uses_self_https_for_session_token(void) +{ + iot_client_t client = g_client; + memcpy(client.registration_key, "pr_0", sizeof(client.registration_key)); + char token[1024] = {0}; + int ret = iot_client_get_session_token(&client, "agent_alpha", token, sizeof(token)); + if (ret != OPRT_OK || token[0] == '\0') { + printf(" keyed session token did not use Self HTTPS: %d\n", ret); + return -1; + } + return 0; +} + +static int test_legacy_client_keeps_region_fallback(void) +{ + iot_client_t client = g_client; + client.https_url[0] = '\0'; + client.region = AY; + client.env = PROD; + char host[64] = {0}; + uint16_t port = 0; + int ret = iot_client_resolve_atop_host(&client, host, sizeof(host), &port); + if (ret != OPRT_OK || strcmp(host, IOT_CN_HOST) != 0 || port != IOT_DEFAULT_PORT) + return -1; + return 0; +} + +static int test_invalid_keyed_https_does_not_fall_back(void) +{ + iot_client_t client = g_client; + memcpy(client.registration_key, "pr_0", sizeof(client.registration_key)); + snprintf(client.https_url, sizeof(client.https_url), "http://127.0.0.1:8443/d.json"); + char host[64] = {0}; + uint16_t port = 0; + if (iot_client_resolve_atop_host(&client, host, sizeof(host), &port) == OPRT_OK || host[0]) + return -1; + return 0; +} + +static int test_empty_ca_does_not_enable_non443_https(void) +{ + iot_client_t client = g_client; + memcpy(client.registration_key, "pr_0", sizeof(client.registration_key)); + client.cacert = ""; + client.cert_bundle_attach = NULL; + char host[64] = {0}; + uint16_t port = 0; + int ret = iot_client_resolve_atop_host(&client, host, sizeof(host), &port); + if (ret == OPRT_OK || host[0]) { + printf(" empty CA incorrectly enabled non-443 HTTPS\n"); + return -1; + } + return 0; +} + +static int test_unterminated_keyed_https_is_rejected(void) +{ + iot_client_t client = g_client; + memcpy(client.registration_key, "pr_0", sizeof(client.registration_key)); + memset(client.https_url, 'a', sizeof(client.https_url)); + char host[64] = {0}; + uint16_t port = 0; + return iot_client_resolve_atop_host(&client, host, sizeof(host), &port) == OPRT_OK ? -1 : 0; +} + /* The _ex variant guards the same way, and a rejection buffer must not be a * way to sneak past them. */ static int test_ex_null_params(void) @@ -328,6 +413,12 @@ int main(void) /* Guards — no network needed */ RUN_TEST(test_null_params); + RUN_TEST(test_keyed_client_without_https_never_uses_default_host); + RUN_TEST(test_keyed_client_uses_self_https_for_session_token); + RUN_TEST(test_legacy_client_keeps_region_fallback); + RUN_TEST(test_invalid_keyed_https_does_not_fall_back); + RUN_TEST(test_empty_ca_does_not_enable_non443_https); + RUN_TEST(test_unterminated_keyed_https_is_rejected); RUN_TEST(test_ex_null_params); /* Round trips */ diff --git a/modules/iot-client/test/mock/atop_mock.py b/modules/iot-client/test/mock/atop_mock.py index 8181b94..744526a 100755 --- a/modules/iot-client/test/mock/atop_mock.py +++ b/modules/iot-client/test/mock/atop_mock.py @@ -254,7 +254,9 @@ def handle_activate_request(request_data, config): "name": "Mock Device", "productKey": product_key or "mock_product_key", "localKey": "1234567890abcdef", - "schemaId": "mock_schema_id", + # Echo the fixture token so the on-boarding test verifies the + # complete eight-byte activation token reaches ATOP. + "schemaId": token if len(token) == 8 else "mock_schema_id", "schema": [], "timezoneId": "Asia/Shanghai", "ownerId": "mock_owner_id", @@ -906,4 +908,3 @@ def main(): if __name__ == '__main__': main() - diff --git a/modules/iot-client/test/mock/dns_mock.py b/modules/iot-client/test/mock/dns_mock.py index 352aea3..3d66af5 100644 --- a/modules/iot-client/test/mock/dns_mock.py +++ b/modules/iot-client/test/mock/dns_mock.py @@ -11,6 +11,7 @@ """ import json +import base64 import os import ssl import sys @@ -29,12 +30,10 @@ class ReusableHTTPServer(HTTPServer): MOCK_HOST = "127.0.0.1" MOCK_PORT = 8198 -FAKE_CA_CERT = ( - "MIIDjjCCAnagAwIBAgIQAzrx5qcRqaC7KGSxHQn65TANBgkqhkiG9w0BAQsFADBh" - "MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3" - "d3cuZGlnaWNlcnQuY29tMSAwHgYDVQQDExdEaWdpQ2VydCBHbG9iYWwgUm9vdCBH" - "MjAeFw0xMzA4MDExMjAwMDBaFw0zODAxMTUxMjAwMDBa" -) +_root_ca_pem_path = os.path.join(os.path.dirname(__file__), "..", "config", "root_cert.pem") +with open(_root_ca_pem_path, encoding="ascii") as _root_ca_file: + _root_ca_pem = _root_ca_file.read() +FAKE_CA_CERT = base64.b64encode(ssl.PEM_cert_to_DER_cert(_root_ca_pem)).decode("ascii") DNS_DB = { "a1.tuyacn.com": { @@ -85,6 +84,14 @@ class ReusableHTTPServer(HTTPServer): "addr": "https://a1-test.example.com/d.json", "ips": ["10.0.0.1", "10.0.0.2"], }, + "httpsSelfUrl": { + "addr": f"https://127.0.0.1:{os.getenv('ATOP_MOCK_PORT', '8443')}/d.json", + "ips": ["127.0.0.1"], + }, + "mqttsSelfUrl": { + "addr": f"127.0.0.1:{os.getenv('ONBOARDING_MQTT_MOCK_PORT', '11884')}", + "ips": ["127.0.0.1"], + }, } @@ -148,6 +155,26 @@ def _handle_url_config(self, raw): return config = req.get("config", []) + requested = {item.get("key") for item in config} + self_keys = {"httpsSelfUrl", "mqttsSelfUrl"} + if requested & self_keys: + # The C test updates this file before each activation, so the mock + # rejects a wrong on-wire env even if it is another valid key. + expected_file = os.getenv("DNS_MOCK_EXPECTED_ENV_FILE", "") + try: + with open(expected_file, encoding="ascii") as stream: + expected_env = stream.read().strip() + except OSError: + expected_env = "" + need_ca_keys = { + item.get("key") + for item in config + if item.get("need_ca") is True + } + if (req.get("env") != expected_env or not self_keys <= requested or + not self_keys <= need_ca_keys): + self._send_json({"ttl": 600}) + return result = { "ttl": 600, "psk_key": "", @@ -164,8 +191,32 @@ def _handle_url_config(self, raw): for item in config: key = item.get("key", "") ep = URL_CONFIG_ENDPOINTS.get(key) + if req.get("env") == "MSHT" and key == "httpsSelfUrl": + continue + if req.get("env") == "MSMQ" and key == "mqttsSelfUrl": + continue if ep: entry = {"addr": ep["addr"], "ips": ep["ips"]} + if req.get("env") == "MALF" and key == "httpsSelfUrl": + entry["addr"] = "not-a-https-url" + if req.get("env") == "LONG" and key == "httpsSelfUrl": + entry["addr"] = "https://" + "x" * 80 + "/d.json" + if req.get("env") == "BADP" and key == "httpsSelfUrl": + entry["addr"] = "https://127.0.0.1:8443/other" + if req.get("env") == "WSPC" and key == "httpsSelfUrl": + entry["addr"] = "https://127.0.0.1:8443/d.json " + if req.get("env") == "QURY" and key == "httpsSelfUrl": + entry["addr"] = "https://127.0.0.1:8443/d.json?env=prod" + if req.get("env") == "FRAG" and key == "httpsSelfUrl": + entry["addr"] = "https://127.0.0.1:8443/d.json#other" + if req.get("env") == "WHAU" and key == "httpsSelfUrl": + entry["addr"] = "https://127.0.0.1 :8443/d.json" + if req.get("env") == "NOPA" and key == "httpsSelfUrl": + entry["addr"] = "https://127.0.0.1:8443" + if req.get("env") == "P080" and key == "httpsSelfUrl": + entry["addr"] = "https://127.0.0.1:80/d.json" + if req.get("env") == "OVFL" and key == "httpsSelfUrl": + entry["addr"] = "https://127.0.0.1:4294967739/d.json" if item.get("need_ip6"): entry["ip6s"] = ["fe80::1"] result[key] = entry diff --git a/modules/iot-client/test/mock/on_boarding_mock.py b/modules/iot-client/test/mock/on_boarding_mock.py index 3a26dc5..f426db0 100644 --- a/modules/iot-client/test/mock/on_boarding_mock.py +++ b/modules/iot-client/test/mock/on_boarding_mock.py @@ -30,7 +30,7 @@ EXPECTED_AUTHKEY = "ci_authkey_1234567890abcdef" -ACTIVATION_JSON = '{"data":{"httpsUrl":"https://127.0.0.1:%s","region":"AY","token":"mock_activation_token_12345"}}' % os.getenv("ATOP_MOCK_PORT", "8443") +ACTIVATION_JSON = '{"data":{"httpsUrl":"https://127.0.0.1:%s","region":"AY","token":"mock_activation_token_12345","env":"pre"}}' % os.getenv("ATOP_MOCK_PORT", "8443") def compute_mqtt_password(authkey): @@ -162,7 +162,12 @@ def handle_client(client_sock, addr): # MQTT 3.1.1 SUBACK: pkt_id(2) + return_code(1) suback = bytes([MQTT_SUBACK, 3]) + struct.pack(">H", pkt_id) + bytes([0]) - activation_pkt = build_publish_packet(subscribe_topic, ACTIVATION_JSON) + message_file = os.getenv("ONBOARDING_MESSAGE_FILE", "") + activation_json = ACTIVATION_JSON + if message_file: + with open(message_file, encoding="utf-8") as stream: + activation_json = stream.read() or ACTIVATION_JSON + activation_pkt = build_publish_packet(subscribe_topic, activation_json) # Send SUBACK + activation message together client_sock.sendall(suback + activation_pkt) diff --git a/modules/iot-client/test/on_boarding_test.c b/modules/iot-client/test/on_boarding_test.c index 6867a4c..38dcd1b 100644 --- a/modules/iot-client/test/on_boarding_test.c +++ b/modules/iot-client/test/on_boarding_test.c @@ -14,10 +14,14 @@ #include "iot_on_boarding.h" #include "iot_client.h" #include "iot_internal.h" +#include "iot_dns.h" +#include "iot_dp_internal.h" +#include "test_log.h" #define MOCK_DNS_HOST "127.0.0.1" #define MOCK_DNS_PORT 8198 +#define MOCK_DNS_PLAIN_PORT 8199 #define MOCK_MQTT_PORT 11884 #define MOCK_ATOP_PORT 8443 @@ -29,11 +33,27 @@ #define TEST_BV "1.0" static pid_t dns_mock_pid = -1; +static pid_t dns_plain_mock_pid = -1; static pid_t mqtt_mock_pid = -1; static pid_t atop_mock_pid = -1; static int tests_run = 0; static int tests_passed = 0; static char *g_cacert = NULL; +static char g_expected_env_file[] = "/tmp/agentic-kit-dns-env-XXXXXX"; +static char g_activation_record_file[] = "/tmp/agentic-kit-atop-record-XXXXXX"; +static char g_qr_message_file[] = "/tmp/agentic-kit-qr-message-XXXXXX"; +static pal_t g_no_network_pal; +static int g_network_attempts; + +static void *count_and_reject_tcp_connect(const char *host, uint16_t port, + uint32_t timeout_ms) +{ + (void)host; + (void)port; + (void)timeout_ms; + g_network_attempts++; + return NULL; +} #define RUN_TEST(fn) \ do { \ @@ -70,6 +90,53 @@ static char *load_file(const pal_t *pal, const char *path) return buf; } +static void remove_test_files(void) +{ + unlink(g_expected_env_file); + unlink(g_activation_record_file); + unlink(g_qr_message_file); +} + +static int create_test_files(void) +{ + int env_fd = mkstemp(g_expected_env_file); + if (env_fd < 0) return -1; + close(env_fd); + int record_fd = mkstemp(g_activation_record_file); + if (record_fd < 0) { + unlink(g_expected_env_file); + return -1; + } + close(record_fd); + int message_fd = mkstemp(g_qr_message_file); + if (message_fd < 0) { + remove_test_files(); + return -1; + } + close(message_fd); + atexit(remove_test_files); + return 0; +} + +static int write_test_file(const char *path, const char *content) +{ + FILE *f = fopen(path, "w"); + if (!f) return -1; + int ok = fputs(content, f) >= 0; + if (fclose(f) != 0) ok = 0; + return ok ? 0 : -1; +} + +static int no_activation_recorded(void) +{ + FILE *f = fopen(g_activation_record_file, "r"); + if (!f) return 0; + int first = fgetc(f); + int ok = first == EOF && !ferror(f); + fclose(f); + return ok; +} + /* ---------- Mock server lifecycle ---------- */ static int start_dns_mock(void) @@ -77,6 +144,7 @@ static int start_dns_mock(void) dns_mock_pid = fork(); if (dns_mock_pid == 0) { setenv("DNS_MOCK_USE_SSL", "1", 1); + setenv("DNS_MOCK_EXPECTED_ENV_FILE", g_expected_env_file, 1); execlp(PYTHON3_EXEC, PYTHON3_EXEC, DNS_MOCK_PATH, NULL); perror("execlp dns mock failed"); _exit(1); @@ -89,10 +157,26 @@ static int start_dns_mock(void) return OPRT_OK; } +static int start_plain_dns_mock(void) +{ + dns_plain_mock_pid = fork(); + if (dns_plain_mock_pid == 0) { + setenv("DNS_MOCK_USE_SSL", "0", 1); + setenv("DNS_MOCK_PORT", "8199", 1); + setenv("DNS_MOCK_EXPECTED_ENV_FILE", g_expected_env_file, 1); + execlp(PYTHON3_EXEC, PYTHON3_EXEC, DNS_MOCK_PATH, NULL); + perror("execlp plain dns mock failed"); + _exit(1); + } + if (dns_plain_mock_pid < 0) return -1; + return 0; +} + static int start_mqtt_mock(void) { mqtt_mock_pid = fork(); if (mqtt_mock_pid == 0) { + setenv("ONBOARDING_MESSAGE_FILE", g_qr_message_file, 1); execlp(PYTHON3_EXEC, PYTHON3_EXEC, ONBOARDING_MQTT_MOCK_PATH, NULL); perror("execlp mqtt mock failed"); _exit(1); @@ -111,6 +195,7 @@ static int start_atop_mock(void) if (atop_mock_pid == 0) { setenv("ATOP_MOCK_USE_SSL", "1", 1); setenv("ATOP_MOCK_PORT", "8443", 1); + setenv("ATOP_MOCK_RECORD_FILE", g_activation_record_file, 1); execlp(PYTHON3_EXEC, PYTHON3_EXEC, ATOP_MOCK_PATH, NULL); perror("execlp atop mock failed"); _exit(1); @@ -177,6 +262,10 @@ static int wait_for_mocks(void) fprintf(stderr, "DNS mock (%u) never became connectable\n", MOCK_DNS_PORT); return -1; } + if (wait_for_port(MOCK_DNS_PLAIN_PORT, 15000) != 0) { + fprintf(stderr, "Plain DNS mock (%u) never became connectable\n", MOCK_DNS_PLAIN_PORT); + return -1; + } if (wait_for_port(MOCK_MQTT_PORT, 15000) != 0) { fprintf(stderr, "MQTT mock (%u) never became connectable\n", MOCK_MQTT_PORT); return -1; @@ -210,7 +299,7 @@ static int test_on_boarding_null_params(void) /* ---------- Test: full on_boarding_with_qrcode flow ---------- */ -static int test_on_boarding_qrcode_flow(void) +static int run_qrcode_case(const char *env_field, const char *expected_key) { const pal_t *pal = get_default_pal(); on_boarding_config_t cfg = {0}; @@ -220,29 +309,106 @@ static int test_on_boarding_qrcode_flow(void) strncpy(cfg.product_key, TEST_PK, sizeof(cfg.product_key) - 1); strncpy(cfg.pv, TEST_PV, sizeof(cfg.pv) - 1); strncpy(cfg.bv, TEST_BV, sizeof(cfg.bv) - 1); - cfg.timeout_ms = 10000; + cfg.timeout_ms = 300; + cfg.env = TEST; /* A raw App key must not rewrite the legacy enum. */ cfg.dns_host = MOCK_DNS_HOST; cfg.dns_port = MOCK_DNS_PORT; cfg.cacert = g_cacert; + char message[256]; + snprintf(message, sizeof(message), + "{\"data\":{\"region\":\"AY\",\"token\":\"H73H8u7A\"," + "\"httpsUrl\":\"https://127.0.0.1:1/d.json\"%s}}", env_field); + if (write_test_file(g_qr_message_file, message) != 0 || + write_test_file(g_expected_env_file, expected_key ? expected_key : "pro") != 0 || + write_test_file(g_activation_record_file, "") != 0) return -1; on_boarding_response_t resp = {0}; int ret = on_boarding_with_qrcode(pal, &cfg, &resp); - - if (ret != OPRT_OK) { - printf(" on_boarding_with_qrcode failed: %d\n", ret); - return -1; + int ok; + if (!expected_key) { + ok = ret != OPRT_OK && resp.devid[0] == '\0' && no_activation_recorded(); + } else { + ok = ret == OPRT_OK && resp.devid[0] != '\0' && resp.env == cfg.env && + strcmp(resp.registration_key, expected_key) == 0 && + strcmp(resp.schema_id, "H73H8u7A") == 0; } + pal->free(resp.schema); + if (!ok) { + printf(" QR App key %s: ret=%d key=%s env=%d schema=%s\n", + expected_key ? expected_key : "(invalid)", ret, + resp.registration_key, resp.env, resp.schema_id); + } + return ok ? 0 : -1; +} - if (resp.devid[0] == '\0') { - printf(" response missing devid\n"); - return -1; +static int test_on_boarding_qrcode_flow(void) +{ + const char *keys[] = {"pr_0", "da_0", "pro", "TlAB", "x_ab", "pre", "prod"}; + for (size_t i = 0; i < sizeof(keys) / sizeof(keys[0]); i++) { + char field[32]; + snprintf(field, sizeof(field), ",\"env\":\"%s\"", keys[i]); + if (run_qrcode_case(field, keys[i]) != 0) return -1; } + return 0; +} - printf(" devid : %s\n", resp.devid); - printf(" secret_key : %s\n", resp.secret_key); - printf(" local_key : %s\n", resp.local_key); - printf(" region : %d\n", resp.region); - return OPRT_OK; +static int test_on_boarding_qrcode_missing_env_defaults_to_pro(void) +{ + return run_qrcode_case("", "pro"); +} + +static int test_on_boarding_qrcode_invalid_env_never_activates(void) +{ + const char *fields[] = {",\"env\":null", ",\"env\":5", ",\"env\":\"\"", ",\"env\":\"abcde\""}; + for (size_t i = 0; i < sizeof(fields) / sizeof(fields[0]); i++) + if (run_qrcode_case(fields[i], NULL) != 0) return -1; + return 0; +} + +static int test_token_activation_restart_keeps_registration_route(void) +{ + const pal_t *pal = get_default_pal(); + on_boarding_config_t cfg = {0}; + snprintf(cfg.uuid, sizeof(cfg.uuid), "%s", TEST_UUID); + snprintf(cfg.authkey, sizeof(cfg.authkey), "%s", TEST_AUTHKEY); + snprintf(cfg.product_key, sizeof(cfg.product_key), "%s", TEST_PK); + snprintf(cfg.sw_ver, sizeof(cfg.sw_ver), "1.0.0"); + snprintf(cfg.pv, sizeof(cfg.pv), "%s", TEST_PV); + snprintf(cfg.bv, sizeof(cfg.bv), "%s", TEST_BV); + cfg.cacert = g_cacert; + cfg.dns_host = MOCK_DNS_HOST; + cfg.dns_port = MOCK_DNS_PORT; + on_boarding_response_t resp = {0}; + if (write_test_file(g_expected_env_file, "pr_0") != 0 || + on_boarding_with_token(pal, &cfg, "AYH73H8u7Apr_0", &resp) != OPRT_OK) return -1; + + /* Emulate the application's persisted activation record, then boot with + * unavailable DNS before resolving against the local TLS mock. */ + iot_client_config_t saved = {0}; + snprintf(saved.devid, sizeof(saved.devid), "%s", resp.devid); + snprintf(saved.secret_key, sizeof(saved.secret_key), "%s", resp.secret_key); + snprintf(saved.local_key, sizeof(saved.local_key), "%s", resp.local_key); + memcpy(saved.registration_key, resp.registration_key, sizeof(saved.registration_key)); + saved.region = resp.region; + saved.env = resp.env; + saved.cacert = g_cacert; + saved.skip_version_report = true; + saved.mqtt_disable_auto_connect = true; + pal->free(resp.schema); + pal_t restart_pal = *pal; + restart_pal.tcp_connect = count_and_reject_tcp_connect; + iot_init(&restart_pal); + iot_client_t *client = iot_client_init(&saved); + restart_pal.tcp_connect = pal->tcp_connect; + int ret = client ? iot_client_dns_resolve(client, MOCK_DNS_HOST, MOCK_DNS_PORT) : -1; + int ok = client && ret == OPRT_OK && client->env == PROD && + strcmp(client->registration_key, "pr_0") == 0 && + strcmp(client->https_url, "https://127.0.0.1:8443/d.json") == 0 && + strcmp(client->mqtt_url, "mqtts://127.0.0.1:11884") == 0 && client->self_cacert; + if (client) iot_client_deinit(client); + iot_init(pal); + if (!ok) printf(" Restart lost raw key/Self route: ret=%d\n", ret); + return ok ? 0 : -1; } /* ---------- Test: on_boarding_with_token NULL/empty parameter validation ---------- */ @@ -297,9 +463,40 @@ static int test_on_boarding_with_token_null_response(void) return OPRT_OK; } +static int test_public_token_onboarding_rejects_plaintext_before_network(void) +{ + iot_on_boarding_config_t cfg = {0}; + strncpy(cfg.uuid, TEST_UUID, sizeof(cfg.uuid) - 1); + strncpy(cfg.authkey, TEST_AUTHKEY, sizeof(cfg.authkey) - 1); + strncpy(cfg.product_key, TEST_PK, sizeof(cfg.product_key) - 1); + cfg.mqtt_disable_tls = true; + + if (write_test_file(g_activation_record_file, "") != 0) return -1; + g_no_network_pal = *get_default_pal(); + g_no_network_pal.tcp_connect = count_and_reject_tcp_connect; + g_network_attempts = 0; + if (iot_init(&g_no_network_pal) != OPRT_OK) return -1; + + iot_client_t *client = iot_client_init_on_boarding_with_token( + &cfg, "AY12345678pr_0"); + iot_client_t *qr_client = iot_client_init_on_boarding(&cfg); + int attempts = g_network_attempts; + iot_init(get_default_pal()); + if (client) iot_client_deinit(client); + if (qr_client) iot_client_deinit(qr_client); + + if (client || qr_client || attempts != 0 || !no_activation_recorded()) { + printf(" unsupported plaintext config reached network before rejection (%d attempts)\n", + attempts); + return -1; + } + return 0; +} + /* ---------- Test: full on_boarding_with_token flow ---------- */ -static int test_on_boarding_with_token_flow(void) +static int run_on_boarding_token_case(const char *app_token, const char *expected_key, + const char *expected_schema_id) { const pal_t *pal = get_default_pal(); on_boarding_config_t cfg = {0}; @@ -311,9 +508,12 @@ static int test_on_boarding_with_token_flow(void) strncpy(cfg.bv, TEST_BV, sizeof(cfg.bv) - 1); cfg.env = TEST; cfg.cacert = g_cacert; + cfg.dns_host = MOCK_DNS_HOST; + cfg.dns_port = MOCK_DNS_PORT; + if (write_test_file(g_expected_env_file, expected_key) != 0) return -1; on_boarding_response_t resp = {0}; - int ret = on_boarding_with_token(pal, &cfg, "AYci_test_token0000", &resp); + int ret = on_boarding_with_token(pal, &cfg, app_token, &resp); if (ret != OPRT_OK) { printf(" on_boarding_with_token failed: %d\n", ret); @@ -323,6 +523,18 @@ static int test_on_boarding_with_token_flow(void) printf(" response missing devid\n"); return -1; } + if (strcmp(resp.registration_key, expected_key) != 0) { + printf(" raw registration key was not retained\n"); + return -1; + } + if (resp.env != cfg.env) { + printf(" opaque token secret changed config.env\n"); + return -1; + } + if (strcmp(resp.schema_id, expected_schema_id) != 0) { + printf(" activation token was truncated: schema_id=%s\n", resp.schema_id); + return -1; + } printf(" devid : %s\n", resp.devid); printf(" secret_key : %s\n", resp.secret_key); printf(" local_key : %s\n", resp.local_key); @@ -330,6 +542,254 @@ static int test_on_boarding_with_token_flow(void) return OPRT_OK; } +static int test_on_boarding_token_secret_pr_0(void) +{ + return run_on_boarding_token_case("AYH73H8u7Apr_0", "pr_0", "H73H8u7A"); +} + +static int test_on_boarding_token_uses_dns_ca_for_activation(void) +{ + const pal_t *pal = get_default_pal(); + on_boarding_config_t cfg = {0}; + strncpy(cfg.uuid, TEST_UUID, sizeof(cfg.uuid) - 1); + strncpy(cfg.authkey, TEST_AUTHKEY, sizeof(cfg.authkey) - 1); + strncpy(cfg.sw_ver, TEST_SW_VER, sizeof(cfg.sw_ver) - 1); + strncpy(cfg.product_key, TEST_PK, sizeof(cfg.product_key) - 1); + strncpy(cfg.pv, TEST_PV, sizeof(cfg.pv) - 1); + strncpy(cfg.bv, TEST_BV, sizeof(cfg.bv) - 1); + cfg.env = TEST; + cfg.cacert = g_cacert; + cfg.dns_host = MOCK_DNS_HOST; + cfg.dns_port = MOCK_DNS_PORT; + + if (write_test_file(g_expected_env_file, "pr_0") != 0 || + write_test_file(g_activation_record_file, "") != 0) return -1; + on_boarding_response_t resp = {0}; + int ret = on_boarding_with_token(pal, &cfg, "AY12345678pr_0", &resp); + if (ret != OPRT_OK) { + printf(" activation did not use the CA returned by IoT DNS: ret=%d\n", ret); + return -1; + } + pal->free(resp.schema); + return 0; +} + +static int test_on_boarding_token_exact_activation_token(void) +{ + return run_on_boarding_token_case("AY12345678pr_0", "pr_0", "12345678"); +} + +static int test_on_boarding_token_requires_dns_before_activation(void) +{ + const pal_t *pal = get_default_pal(); + on_boarding_config_t cfg = {0}; + strncpy(cfg.uuid, TEST_UUID, sizeof(cfg.uuid) - 1); + strncpy(cfg.authkey, TEST_AUTHKEY, sizeof(cfg.authkey) - 1); + strncpy(cfg.product_key, TEST_PK, sizeof(cfg.product_key) - 1); + strncpy(cfg.sw_ver, TEST_SW_VER, sizeof(cfg.sw_ver) - 1); + strncpy(cfg.pv, TEST_PV, sizeof(cfg.pv) - 1); + strncpy(cfg.bv, TEST_BV, sizeof(cfg.bv) - 1); + cfg.env = TEST; + cfg.cacert = g_cacert; + cfg.dns_host = MOCK_DNS_HOST; + cfg.dns_port = 19998; /* no DNS service */ + + if (write_test_file(g_activation_record_file, "") != 0) return -1; + on_boarding_response_t resp = {0}; + int ret = on_boarding_with_token(pal, &cfg, "AY12345678pr_0", &resp); + if (ret == OPRT_OK || resp.devid[0] != '\0' || !no_activation_recorded()) { + printf(" activation proceeded without App-selected DNS endpoints: %d\n", ret); + return -1; + } + return OPRT_OK; +} + +static int test_on_boarding_token_secret_da_0(void) +{ + return run_on_boarding_token_case("AY12345678da_0", "da_0", "12345678"); +} + +static int test_on_boarding_token_dns_rejects_wrong_env(void) +{ + const pal_t *pal = get_default_pal(); + on_boarding_config_t cfg = {0}; + strncpy(cfg.uuid, TEST_UUID, sizeof(cfg.uuid) - 1); + strncpy(cfg.authkey, TEST_AUTHKEY, sizeof(cfg.authkey) - 1); + strncpy(cfg.sw_ver, TEST_SW_VER, sizeof(cfg.sw_ver) - 1); + strncpy(cfg.product_key, TEST_PK, sizeof(cfg.product_key) - 1); + strncpy(cfg.pv, TEST_PV, sizeof(cfg.pv) - 1); + strncpy(cfg.bv, TEST_BV, sizeof(cfg.bv) - 1); + cfg.env = TEST; + cfg.cacert = g_cacert; + cfg.dns_host = MOCK_DNS_HOST; + cfg.dns_port = MOCK_DNS_PORT; + + /* The wire token says pr_0, while this mock invocation expects da_0. */ + if (write_test_file(g_expected_env_file, "da_0") != 0 || + write_test_file(g_activation_record_file, "") != 0) return -1; + on_boarding_response_t resp = {0}; + int ret = on_boarding_with_token(pal, &cfg, "AY12345678pr_0", &resp); + if (ret == OPRT_OK || resp.devid[0] != '\0' || !no_activation_recorded()) { + printf(" DNS accepted an env different from its test-controlled expectation\n"); + return -1; + } + return OPRT_OK; +} + +static int test_on_boarding_token_arbitrary_secret(void) +{ + return run_on_boarding_token_case("AY12345678Q7xZ", "Q7xZ", "12345678"); +} + +static int test_on_boarding_token_private_cloud_key(void) +{ + return run_on_boarding_token_case("AY12345678x_ab", "x_ab", "12345678"); +} + +static int test_on_boarding_token_opaque_punctuation_key(void) +{ + return run_on_boarding_token_case("AY12345678a+%_", "a+%_", "12345678"); +} + +static int test_on_boarding_activation_does_not_log_post_json(void) +{ + const pal_t *pal = get_default_pal(); + on_boarding_config_t cfg = {0}; + strncpy(cfg.uuid, TEST_UUID, sizeof(cfg.uuid) - 1); + strncpy(cfg.authkey, TEST_AUTHKEY, sizeof(cfg.authkey) - 1); + strncpy(cfg.sw_ver, TEST_SW_VER, sizeof(cfg.sw_ver) - 1); + strncpy(cfg.product_key, TEST_PK, sizeof(cfg.product_key) - 1); + strncpy(cfg.pv, TEST_PV, sizeof(cfg.pv) - 1); + strncpy(cfg.bv, TEST_BV, sizeof(cfg.bv) - 1); + cfg.env = TEST; + cfg.cacert = g_cacert; + cfg.dns_host = MOCK_DNS_HOST; + cfg.dns_port = MOCK_DNS_PORT; + if (write_test_file(g_expected_env_file, "pr_0") != 0) return -1; + + static char captured[16384]; + on_boarding_response_t resp = {0}; + test_log_capture_begin(captured, sizeof(captured)); + int ret = on_boarding_with_token(pal, &cfg, "AYH73H8u7Apr_0", &resp); + test_log_capture_end(); + if (ret != OPRT_OK || strstr(captured, "POST JSON:") != NULL) { + printf(" activation logged an unredacted POST JSON or failed: %d\n", ret); + return -1; + } + return OPRT_OK; +} + +static int test_on_boarding_token_rejects_non443_without_trust(void) +{ + const pal_t *pal = get_default_pal(); + on_boarding_config_t cfg = {0}; + strncpy(cfg.uuid, TEST_UUID, sizeof(cfg.uuid) - 1); + strncpy(cfg.authkey, TEST_AUTHKEY, sizeof(cfg.authkey) - 1); + strncpy(cfg.sw_ver, TEST_SW_VER, sizeof(cfg.sw_ver) - 1); + strncpy(cfg.product_key, TEST_PK, sizeof(cfg.product_key) - 1); + strncpy(cfg.pv, TEST_PV, sizeof(cfg.pv) - 1); + strncpy(cfg.bv, TEST_BV, sizeof(cfg.bv) - 1); + cfg.env = TEST; + cfg.dns_host = MOCK_DNS_HOST; + cfg.dns_port = MOCK_DNS_PLAIN_PORT; + if (write_test_file(g_expected_env_file, "pr_0") != 0 || + write_test_file(g_activation_record_file, "") != 0) return -1; + + on_boarding_response_t resp = {0}; + int ret = on_boarding_with_token(pal, &cfg, "AY12345678pr_0", &resp); + if (ret != OPRT_INVALID_PARAMETER || !no_activation_recorded()) { + printf(" App-selected DNS proceeded without bootstrap TLS trust: %d\n", ret); + return -1; + } + return OPRT_OK; +} + +static int test_on_boarding_token_https_url_without_path(void) +{ + return run_on_boarding_token_case("AY12345678NOPA", "NOPA", "12345678"); +} + +static int test_on_boarding_token_rejects_invalid_input(void) +{ + const pal_t *pal = get_default_pal(); + on_boarding_config_t cfg = {0}; + strncpy(cfg.uuid, TEST_UUID, sizeof(cfg.uuid) - 1); + strncpy(cfg.authkey, TEST_AUTHKEY, sizeof(cfg.authkey) - 1); + strncpy(cfg.sw_ver, TEST_SW_VER, sizeof(cfg.sw_ver) - 1); + strncpy(cfg.product_key, TEST_PK, sizeof(cfg.product_key) - 1); + strncpy(cfg.pv, TEST_PV, sizeof(cfg.pv) - 1); + strncpy(cfg.bv, TEST_BV, sizeof(cfg.bv) - 1); + cfg.env = TEST; + cfg.cacert = g_cacert; + cfg.dns_host = MOCK_DNS_HOST; + cfg.dns_port = MOCK_DNS_PORT; + + if (write_test_file(g_expected_env_file, "pr_0") != 0) return -1; + + on_boarding_response_t resp = {0}; + const char *invalid[] = { + "AY12345678pre", /* short */ + "AY12345678pr_0x", /* long */ + "AYci_test_token0000", /* legacy variable-width token */ + "ZZ12345678pr_0", /* unsupported region */ + "AY1234\x01" "678pr_0", /* control byte in activation token */ + "AY12345678p\x01_0", /* control byte in registration key */ + "AY12345678p\xC3_0", /* non-ASCII byte in registration key */ + "AY1234\"678pr_0", /* unescaped JSON quote in activation token */ + "AY1234\\678pr_0", /* unescaped JSON backslash in activation token */ + }; + for (size_t i = 0; i < sizeof(invalid) / sizeof(invalid[0]); i++) { + memset(&resp, 0, sizeof(resp)); + int ret = on_boarding_with_token(pal, &cfg, invalid[i], &resp); + if (ret != OPRT_INVALID_PARAMETER || resp.devid[0] != '\0') { + printf(" malformed token case %zu not rejected: %d\n", i, ret); + return -1; + } + } + return OPRT_OK; +} + +static int test_on_boarding_token_rejects_incomplete_dns(void) +{ + const pal_t *pal = get_default_pal(); + on_boarding_config_t cfg = {0}; + strncpy(cfg.uuid, TEST_UUID, sizeof(cfg.uuid) - 1); + strncpy(cfg.authkey, TEST_AUTHKEY, sizeof(cfg.authkey) - 1); + strncpy(cfg.sw_ver, TEST_SW_VER, sizeof(cfg.sw_ver) - 1); + strncpy(cfg.product_key, TEST_PK, sizeof(cfg.product_key) - 1); + strncpy(cfg.pv, TEST_PV, sizeof(cfg.pv) - 1); + strncpy(cfg.bv, TEST_BV, sizeof(cfg.bv) - 1); + cfg.env = TEST; + cfg.cacert = g_cacert; + cfg.dns_host = MOCK_DNS_HOST; + cfg.dns_port = MOCK_DNS_PORT; + + const char *tokens[] = { + "AY12345678MSHT", /* no HTTPS Self endpoint */ + "AY12345678MSMQ", /* no MQTT Self endpoint */ + "AY12345678MALF", /* malformed HTTPS endpoint */ + "AY12345678LONG", /* oversized HTTPS endpoint */ + "AY12345678BADP", /* HTTPS path is not /d.json */ + "AY12345678WSPC", /* whitespace after HTTPS path */ + "AY12345678QURY", /* query string cannot be silently discarded */ + "AY12345678FRAG", /* fragment cannot be silently discarded */ + "AY12345678WHAU", /* whitespace in HTTPS authority */ + "AY12345678P080", /* port 80 uses plain TCP despite https URL */ + }; + for (size_t i = 0; i < sizeof(tokens) / sizeof(tokens[0]); i++) { + if (write_test_file(g_expected_env_file, tokens[i] + 10) != 0 || + write_test_file(g_activation_record_file, "") != 0) return -1; + on_boarding_response_t resp = {0}; + int ret = on_boarding_with_token(pal, &cfg, tokens[i], &resp); + if (ret != OPRT_INVALID_RESULT || resp.devid[0] != '\0' || + !no_activation_recorded()) { + printf(" bad DNS endpoint case %zu activated: %d\n", i, ret); + return -1; + } + } + return OPRT_OK; +} + /* ---------- Test: timeout when no activation message ---------- */ static int test_on_boarding_timeout(void) @@ -375,9 +835,17 @@ int main(void) fprintf(stderr, "Warning: CA cert not loaded, TLS tests may skip verification\n"); } - if (start_dns_mock() != 0 || start_mqtt_mock() != 0 || start_atop_mock() != 0) { + if (create_test_files() != 0) { + fprintf(stderr, "Failed to create isolated mock control files\n"); + pal->free(g_cacert); + return 1; + } + + if (start_dns_mock() != 0 || start_plain_dns_mock() != 0 || + start_mqtt_mock() != 0 || start_atop_mock() != 0) { fprintf(stderr, "Failed to start mock servers\n"); stop_mock(&dns_mock_pid, "DNS mock"); + stop_mock(&dns_plain_mock_pid, "plain DNS mock"); stop_mock(&mqtt_mock_pid, "MQTT mock"); stop_mock(&atop_mock_pid, "ATOP mock"); pal->free(g_cacert); @@ -387,6 +855,7 @@ int main(void) * (replaces a blind sleep that raced mock startup on a loaded CI box). */ if (wait_for_mocks() != 0) { stop_mock(&dns_mock_pid, "DNS mock"); + stop_mock(&dns_plain_mock_pid, "plain DNS mock"); stop_mock(&mqtt_mock_pid, "MQTT mock"); stop_mock(&atop_mock_pid, "ATOP mock"); pal->free(g_cacert); @@ -398,11 +867,29 @@ int main(void) RUN_TEST(test_on_boarding_with_token_null_token); RUN_TEST(test_on_boarding_with_token_empty_token); RUN_TEST(test_on_boarding_with_token_null_response); + RUN_TEST(test_public_token_onboarding_rejects_plaintext_before_network); RUN_TEST(test_on_boarding_timeout); - RUN_TEST(test_on_boarding_with_token_flow); + RUN_TEST(test_on_boarding_token_secret_pr_0); + RUN_TEST(test_on_boarding_token_uses_dns_ca_for_activation); + RUN_TEST(test_on_boarding_token_exact_activation_token); + RUN_TEST(test_on_boarding_token_requires_dns_before_activation); + RUN_TEST(test_on_boarding_token_secret_da_0); + RUN_TEST(test_on_boarding_token_dns_rejects_wrong_env); + RUN_TEST(test_on_boarding_token_arbitrary_secret); + RUN_TEST(test_on_boarding_token_private_cloud_key); + RUN_TEST(test_on_boarding_token_opaque_punctuation_key); + RUN_TEST(test_on_boarding_activation_does_not_log_post_json); + RUN_TEST(test_on_boarding_token_rejects_non443_without_trust); + RUN_TEST(test_on_boarding_token_https_url_without_path); + RUN_TEST(test_on_boarding_token_rejects_invalid_input); + RUN_TEST(test_on_boarding_token_rejects_incomplete_dns); RUN_TEST(test_on_boarding_qrcode_flow); + RUN_TEST(test_on_boarding_qrcode_missing_env_defaults_to_pro); + RUN_TEST(test_on_boarding_qrcode_invalid_env_never_activates); + RUN_TEST(test_token_activation_restart_keeps_registration_route); stop_mock(&dns_mock_pid, "DNS mock"); + stop_mock(&dns_plain_mock_pid, "plain DNS mock"); stop_mock(&mqtt_mock_pid, "MQTT mock"); stop_mock(&atop_mock_pid, "ATOP mock");