From cd97804bf1bfa6f5be7dda7422f559b7f581775e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BD=95=E5=B0=91=E7=90=BC?= Date: Mon, 7 Sep 2026 18:59:37 +0800 Subject: [PATCH] feat(common): opt-in TLS key log, for decrypting captures in Wireshark MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Export TLS session secrets in NSS SSLKEYLOGFILE format so a packet capture of the SDK's cloud traffic can be decrypted in Wireshark. Opt-in and off by default; enabling it covers every channel (MQTT, ATOP, IoT-DNS, RTC/TAI) at once, since the facility lives in common/tls.c. - tls_keylog_open_file() / tls_keylog_close_file() write lines to a file (compiled in on POSIX and ESP-IDF, or with TLS_KEYLOG_FILE_SINK=1); tls_set_keylog_handler() routes them to a custom sink. - Process-wide, snapshotted per connection: tls_connect() reads the sink on the connecting thread, so the export callback is lock-free and every call site is inside the handshake. - The line bound is derived and self-checked; over-long lines are dropped, never truncated into an undecryptable log. - The file is the secret: 0600 from creation, O_NOFOLLOW|O_CLOEXEC, unbuffered and fsync'ed per line, stack copy zeroized, write failure logged once. - Runtime debug switch only; enabling it logs a LOG_WARN. Export needs mbedTLS 3.x. pal_t has no file interface, so the file sink uses libc I/O — a deliberate gap recorded in AGENTS.md rule 5. Docs: docs-site/docs/guides/tls-keylog.md covers the Wireshark setup. Tests: mqtt_test.c gains handler-capture and file-sink cases (skipped without a CA cert). Co-Authored-By: Claude Opus 4.8 --- AGENTS.md | 11 +- CHANGELOG.md | 11 + CMakeLists.txt | 5 + common/tls.c | 265 ++++++++++++++++++ common/tls.h | 70 +++++ .../docs/guides/porting-to-new-platform.md | 1 + .../docs/guides/tls-cert-verification.md | 7 + docs-site/docs/guides/tls-keylog.md | 161 +++++++++++ .../current/guides/tls-keylog.md | 146 ++++++++++ docs-site/sidebars.ts | 1 + modules/iot-client/test/mqtt_test.c | 160 +++++++++++ 11 files changed, 837 insertions(+), 1 deletion(-) create mode 100644 docs-site/docs/guides/tls-keylog.md create mode 100644 docs-site/i18n/en/docusaurus-plugin-content-docs/current/guides/tls-keylog.md diff --git a/AGENTS.md b/AGENTS.md index a561623..7d9d541 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -93,14 +93,23 @@ ctest --test-dir build --output-on-failure --no-tests=error --timeout 180 # ne omission is invisible until someone flashes a board. They diverge on purpose — `pal_posix.c` and `iot_pal_defaults.c` host-only (each IDF app defines its own `get_default_pal()`), `pal_freertos.c` IDF-only — so never blind-sync them. +<<<<<<< HEAD 5. **Module code goes through the PAL**: `pal->malloc`/`pal->free` for memory, the `log_tag_*` macros in `common/log.h` for output (via each module's own family: `IOT_LOG*`, `TAI_LOG*`, `TUYA_BLE_HAL_LOG*`). A direct `malloc` or `printf` is a porting bug even where it links on the host -- and the `test` CI job greps for raw `log_emit(`/`printf(` call sites outside `common/log.{h,c}` and the test trees, so one fails the pipeline. The one deliberate gap: `pal_t` has +======= +5. **Module code goes through the PAL**: `pal->malloc`/`pal->free` for memory, `log_emit` for + output (via each module's prefixed `log_info`/`log_warn`/`log_error`). A direct `malloc` or + `printf` is a porting bug even where it links on the host. Two deliberate gaps: `pal_t` has +>>>>>>> 2629217 (feat(common): opt-in TLS key log, for decrypting captures in Wireshark) only a monotonic `time_ms`, so ATOP signing reads libc `time(NULL)` — a port needs a real-time - clock the C library can see, or every signed request carries a `t` the cloud rejects. + clock the C library can see, or every signed request carries a `t` the cloud rejects; and + `pal_t` has no file interface, so the TLS key-log file sink in `common/tls.c` uses libc + `fopen`/`fputs`, compiled in only where `TLS_KEYLOG_FILE_SINK` is 1 (POSIX and ESP-IDF by + default) so a bare newlib port without `_open`/`_close` stubs still links `tls.o`. 6. **CHANGELOG entries are terse, and carry a PR number.** One line per change — `- — (#).` — with indented sub-bullets only for specifics a reader acts on (a new symbol, a changed default, a migration step). `## [0.3.0]` is the reference for diff --git a/CHANGELOG.md b/CHANGELOG.md index 43924bb..7524634 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - A scan provider enables WiFi-list/status capability discovery, not a complete PSK3.0 activation exchange. - docs-site — new bilingual guide "DP (Data Point): Definition, Creation, and Usage" under User Guides → Cloud Configuration (#41). - Covers the official three-level DP definition, the four elements (DPID/DPCode/type/constraints), the six data types, rw/ro/wr transfer modes, `dps` and cloud command-set message formats, cloud rate limits, platform-side DP creation, and DP usage in agentic-kit (schema intake, type mapping, core `iot_dp_*` APIs, a typical code flow), with official and on-site references. +- common — opt-in TLS key log, for decrypting a capture of the SDK's cloud + traffic in Wireshark(#34). + - `tls_keylog_open_file()` / `tls_keylog_close_file()` write NSS + `SSLKEYLOGFILE` lines to a file (compiled in on POSIX and ESP-IDF, or with + `TLS_KEYLOG_FILE_SINK=1`); `tls_set_keylog_handler()` routes them to your + own sink. + - Process-wide and off by default: enable it once before the first TLS + connection and every channel (MQTT, ATOP, IoT-DNS, RTC/TAI) exports. + - A runtime switch for debugging only; enabling it logs a `LOG_WARN`. + Needs mbedTLS 3.x. + - `docs-site/docs/guides/tls-keylog.md` covers the Wireshark setup. ### Changed diff --git a/CMakeLists.txt b/CMakeLists.txt index fc56e36..ea49bd3 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -392,6 +392,11 @@ if(AGENTIC_KIT_BUILD_TESTS AND AGENTIC_KIT_ENABLE_PROJECT_TESTS) "${coremqtt_source_dir}/source/core_mqtt.c" "${coremqtt_source_dir}/source/core_mqtt_serializer.c" "${coremqtt_source_dir}/source/core_mqtt_state.c" + # Same remap story as coreMQTT/coreHTTP above: tls.c lives in the + # remap-free agentic_kit_common, but its keylog warning is asserted + # by tests -- compiling it here puts its lines behind the sink while + # agentic_kit_common's copy stays unreferenced on the link line. + "${COMMON_DIR}/tls.c" "${IOT_CLIENT_TESTS_DIR}/test_log.c" ) target_include_directories(tuya_iot_client_testlog PUBLIC diff --git a/common/tls.c b/common/tls.c index 7e2acd1..f725af0 100644 --- a/common/tls.c +++ b/common/tls.c @@ -11,12 +11,37 @@ #include #include +#include #include "mbedtls/ssl.h" #include "mbedtls/x509_crt.h" #include "mbedtls/error.h" #include "mbedtls/net_sockets.h" #include "mbedtls/version.h" +#include "mbedtls/platform_util.h" /* mbedtls_platform_zeroize for the key log */ + +/* The key-log file sink is the only libc file I/O in library code. It is + * compiled in where a writable filesystem is the norm (hosts, ESP-IDF's VFS) + * and out elsewhere, so a bare newlib port without _open/_close/_lseek stubs + * still links tls.o. Override with -DTLS_KEYLOG_FILE_SINK=0/1. */ +#ifndef TLS_KEYLOG_FILE_SINK +# if defined(__unix__) || defined(__APPLE__) || defined(ESP_PLATFORM) +# define TLS_KEYLOG_FILE_SINK 1 +# else +# define TLS_KEYLOG_FILE_SINK 0 +# endif +#endif + +/* On POSIX the file is created with open(2) -- mode from creation, no symlink + * following, no inheritance across exec -- and fsync'ed per line. */ +#if TLS_KEYLOG_FILE_SINK && (defined(__unix__) || defined(__APPLE__)) +# define TLS_KEYLOG_POSIX_FILE 1 +# include +# include +# include +#else +# define TLS_KEYLOG_POSIX_FILE 0 +#endif /* ========================================================================= * tls_t -- per-connection state @@ -28,6 +53,8 @@ struct tls_conn { void *tcp_handle; const pal_t *pal; void *yield_mutex; /* serialises ssl_read/ssl_write */ + tls_keylog_fn keylog_fn; /* key-log sink, snapshotted by tls_connect */ + void *keylog_ctx; }; /* ========================================================================= @@ -63,6 +90,229 @@ static int tls_map_verify(tls_verify_t v) } } +/* ========================================================================= + * TLS key log (NSS SSLKEYLOGFILE format) -- opt-in, off by default. + * + * Process-wide like the DRBG above: one sink covers every connection. The app + * installs it once at startup, single-threaded, before the first tls_connect() + * (tls.h); tls_connect() then snapshots the pair into the tls_t on the + * connecting thread, and mbedTLS's export callback reads only its own + * connection's copy. Thread creation orders that install before any later + * handshake, so the globals need neither volatile nor a lock -- and neither + * would make a *swap* during a handshake safe, which is why tls.h forbids one. + * ========================================================================= */ + +static tls_keylog_fn g_keylog_fn = NULL; +static void *g_keylog_ctx = NULL; + +#if TLS_KEYLOG_FILE_SINK +/* Set once a write has failed, so the failure is logged once rather than on + * every handshake. Reset when the file sink is (re)installed. */ +static bool g_keylog_file_failed = false; + +static void tls_keylog_file_write(void *ctx, const char *line) +{ + FILE *f = (FILE *)ctx; + /* Unbuffered stream (see open_file): fputs() is one write(2). A failed write + * -- ENOSPC, a VFS gone read-only -- would otherwise drop the line silently + * and leave a capture that will not decrypt, with nothing in the log to say + * why. */ + if (fputs(line, f) == EOF) { + if (!g_keylog_file_failed) { + g_keylog_file_failed = true; + log_tag_error("tls", "key log write failed (%s); later lines are lost", + strerror(errno)); + } + return; + } +#if TLS_KEYLOG_POSIX_FILE + /* write(2) alone leaves the line in the page cache; tls.h promises it + * survives a reset mid-session, so push it through. */ + (void)fsync(fileno(f)); +#endif +} +#endif /* TLS_KEYLOG_FILE_SINK */ + +/* The one place the (fn, ctx) pair changes. Detaches the outgoing sink before + * releasing anything it owns, publishes the new pair, and emits the single + * "key logging ENABLED" line the docs tell operators to grep production logs + * for. `where` names the sink for that line. */ +static void keylog_install(tls_keylog_fn fn, void *ctx, const char *where) +{ + tls_keylog_fn old_fn = g_keylog_fn; + void *old_ctx = g_keylog_ctx; + g_keylog_fn = NULL; + g_keylog_ctx = NULL; +#if TLS_KEYLOG_FILE_SINK + if (old_fn == tls_keylog_file_write) { + fclose((FILE *)old_ctx); + g_keylog_file_failed = false; + } +#else + (void)old_fn; + (void)old_ctx; +#endif + g_keylog_ctx = ctx; + g_keylog_fn = fn; + if (fn) + log_tag_warn("tls", "key logging ENABLED -- session secrets are " + "being exported to %s; do not use in production", where); +} + +void tls_set_keylog_handler(tls_keylog_fn fn, void *ctx) +{ + keylog_install(fn, ctx, "a custom sink"); +} + +int tls_keylog_open_file(const char *path) +{ + if (!path || path[0] == '\0') return TLS_ERR_ARGS; +#if TLS_KEYLOG_FILE_SINK + if (g_keylog_fn == tls_keylog_file_write) { + log_tag_error("tls", "key log file already open"); + return TLS_ERR_ARGS; + } + FILE *f = NULL; +#if TLS_KEYLOG_POSIX_FILE + /* The file IS the secret: 0600 from the moment it exists (fopen("a") + + * fchmod would leave it at 0666 & ~umask in between), never through a + * symlink someone planted at the documented path, never inherited by a + * child the app execs. */ + int fd = open(path, O_WRONLY | O_CREAT | O_APPEND | O_NOFOLLOW | O_CLOEXEC, + S_IRUSR | S_IWUSR); + if (fd >= 0) { + f = fdopen(fd, "a"); + if (!f) close(fd); + } +#else + f = fopen(path, "a"); +#endif + if (!f) { + log_tag_error("tls", "cannot open key log file '%s': %s", + path, strerror(errno)); + return TLS_ERR_ARGS; + } + /* No stdio buffering: a buffer holding secrets cannot be wiped, and an + * unflushed line is a capture that will not decrypt. A stream that cannot be + * made unbuffered is refused rather than silently buffered. */ + if (setvbuf(f, NULL, _IONBF, 0) != 0) { + log_tag_error("tls", "cannot make key log file '%s' unbuffered", path); + fclose(f); + return TLS_ERR_ARGS; + } + keylog_install(tls_keylog_file_write, f, path); + return TLS_OK; +#else + log_tag_error("tls", "key log file sink not compiled in " + "(TLS_KEYLOG_FILE_SINK=0); use tls_set_keylog_handler()"); + return TLS_ERR_ARGS; +#endif +} + +void tls_keylog_close_file(void) +{ +#if TLS_KEYLOG_FILE_SINK + if (g_keylog_fn == tls_keylog_file_write) + keylog_install(NULL, NULL, NULL); +#endif +} + +/* The export side needs mbedTLS 3.x (mbedtls_ssl_set_export_keys_cb and the + * mbedtls_ssl_key_export_type enum replaced the 2.x conf_export_keys_cb API); + * against 2.x the sink can be installed but nothing is exported, and + * tls_connect() says so. */ +#if MBEDTLS_VERSION_MAJOR >= 3 + +/* NSS label for one mbedTLS export type, or NULL for a type Wireshark has no + * label for (nothing is written then, rather than a line it would ignore). */ +static const char *tls_keylog_label(mbedtls_ssl_key_export_type type) +{ + switch (type) { + case MBEDTLS_SSL_KEY_EXPORT_TLS12_MASTER_SECRET: + return "CLIENT_RANDOM"; +#if defined(MBEDTLS_SSL_PROTO_TLS1_3) + case MBEDTLS_SSL_KEY_EXPORT_TLS1_3_CLIENT_EARLY_SECRET: + return "CLIENT_EARLY_TRAFFIC_SECRET"; + case MBEDTLS_SSL_KEY_EXPORT_TLS1_3_EARLY_EXPORTER_SECRET: + return "EARLY_EXPORTER_SECRET"; + case MBEDTLS_SSL_KEY_EXPORT_TLS1_3_CLIENT_HANDSHAKE_TRAFFIC_SECRET: + return "CLIENT_HANDSHAKE_TRAFFIC_SECRET"; + case MBEDTLS_SSL_KEY_EXPORT_TLS1_3_SERVER_HANDSHAKE_TRAFFIC_SECRET: + return "SERVER_HANDSHAKE_TRAFFIC_SECRET"; + case MBEDTLS_SSL_KEY_EXPORT_TLS1_3_CLIENT_APPLICATION_TRAFFIC_SECRET: + return "CLIENT_TRAFFIC_SECRET_0"; + case MBEDTLS_SSL_KEY_EXPORT_TLS1_3_SERVER_APPLICATION_TRAFFIC_SECRET: + return "SERVER_TRAFFIC_SECRET_0"; +#endif + default: + return NULL; + } +} + +/* Longest secret any export type carries: the TLS 1.2 master secret is 48 + * bytes; a TLS 1.3 traffic secret is one hash, 64 bytes with SHA-512 enabled -- + * the same bound mbedTLS uses (MBEDTLS_TLS1_3_MD_MAX_SIZE). Anything longer is + * not a line Wireshark expects and is dropped rather than truncated into a + * silently undecryptable log. */ +#define TLS_KEYLOG_MAX_SECRET 64 +#define TLS_KEYLOG_MAX_LABEL 40 +_Static_assert(sizeof("CLIENT_HANDSHAKE_TRAFFIC_SECRET") - 1 <= TLS_KEYLOG_MAX_LABEL, + "longest NSS key-log label must fit TLS_KEYLOG_MAX_LABEL"); +/* label + ' ' + 32-byte client random in hex + ' ' + secret in hex + "\n\0" */ +#define TLS_KEYLOG_LINE_SIZE (TLS_KEYLOG_MAX_LABEL + 1 + 64 + 1 + \ + TLS_KEYLOG_MAX_SECRET * 2 + 2) + +/* Same loop as iot_ota_verify.c's bytes_to_hex_lower; duplicated on purpose, + * common/ has no hex helper and a module's static is not reachable from here. */ +static void tls_hex(char *out, const unsigned char *in, size_t len) +{ + static const char hexdig[] = "0123456789abcdef"; + for (size_t i = 0; i < len; i++) { + out[i * 2] = hexdig[in[i] >> 4]; + out[i * 2 + 1] = hexdig[in[i] & 0x0F]; + } +} + +/* mbedTLS key-export callback: format one NSS key-log line and hand it to this + * connection's sink. Runs on the handshaking thread; registered by tls_connect() + * only when a sink was installed, so p_expkey is always a tls_t with one. */ +static void tls_keylog_export(void *p_expkey, + mbedtls_ssl_key_export_type type, + const unsigned char *secret, + size_t secret_len, + const unsigned char client_random[32], + const unsigned char server_random[32], + mbedtls_tls_prf_types tls_prf_type) +{ + tls_t *t = (tls_t *)p_expkey; + (void)server_random; + (void)tls_prf_type; + + const char *label = tls_keylog_label(type); + if (!label || secret_len == 0) return; + + char line[TLS_KEYLOG_LINE_SIZE]; + size_t label_len = strlen(label); + /* One bound, checked against the real buffer so it cannot drift from it. */ + if (label_len + 1 + 64 + 1 + secret_len * 2 + 2 > sizeof(line)) return; + size_t n = 0; + + memcpy(line + n, label, label_len); n += label_len; + line[n++] = ' '; + tls_hex(line + n, client_random, 32); n += 64; + line[n++] = ' '; + tls_hex(line + n, secret, secret_len); n += secret_len * 2; + line[n++] = '\n'; + line[n] = '\0'; + + t->keylog_fn(t->keylog_ctx, line); + + /* The line is a copy of the secret; do not leave it on the stack. */ + mbedtls_platform_zeroize(line, sizeof(line)); +} + +#endif /* MBEDTLS_VERSION_MAJOR >= 3 */ + /* ========================================================================= * BIO callbacks -- adapt PAL tcp_send/recv to mbedTLS f_send/f_recv. * @@ -207,6 +457,21 @@ tls_t *tls_connect(const tls_config_t *cfg) if (mbedtls_ssl_setup(&t->ssl, &t->conf) != 0) goto fail; + /* Snapshot the key-log sink on the connecting thread and register the export + * callback only when one is installed: a connection with key logging off + * carries no callback and pays nothing at the bottom of the handshake. Every + * export call site is inside the handshake and tls.c never enables + * renegotiation, so this is the "read at handshake time" tls.h documents. */ + t->keylog_fn = g_keylog_fn; + t->keylog_ctx = g_keylog_ctx; + if (t->keylog_fn) { +#if MBEDTLS_VERSION_MAJOR >= 3 + mbedtls_ssl_set_export_keys_cb(&t->ssl, tls_keylog_export, t); +#else + log_emit(LOG_WARN, "[tls] key logging needs mbedTLS 3.x; nothing is exported"); +#endif + } + if (mbedtls_ssl_set_hostname(&t->ssl, cfg->sni ? cfg->sni : cfg->host) != 0) goto fail; diff --git a/common/tls.h b/common/tls.h index ec18b22..3d5ebc1 100644 --- a/common/tls.h +++ b/common/tls.h @@ -17,6 +17,10 @@ * sender thread may interleave freely (the rtc model). The handshake DRBG is a * process-wide, lazily-seeded singleton shared by all connections. * + * Diagnostics. TLS key logging (NSS SSLKEYLOGFILE format, for Wireshark) is + * available but OFF unless the application turns it on -- see + * tls_set_keylog_handler / tls_keylog_open_file at the bottom of this header. + * * mbedTLS configuration is the integrator's responsibility. This SDK does NOT * touch mbedTLS's process-global configuration -- the allocator * (mbedtls_platform_set_calloc_free), threading callbacks @@ -130,6 +134,72 @@ void *tls_get_tcp_handle(tls_t *t); /* Send close_notify, close the socket, and free all resources. NULL-safe. */ void tls_close(tls_t *t); +/* ========================================================================= + * TLS key log (NSS "SSLKEYLOGFILE" format) -- OPT-IN, OFF BY DEFAULT. + * + * When enabled, every TLS connection opened afterwards exports its handshake + * secrets as NSS key-log lines, which Wireshark reads (Preferences -> Protocols + * -> TLS -> "(Pre)-Master-Secret log filename") to decrypt the captured + * session. This is the only way to inspect the SDK's cloud traffic without + * terminating TLS at a proxy. + * + * THE EXPORTED LINES ARE THE SESSION KEYS. Anyone holding them can decrypt + * that device's traffic, including its MQTT password and session tokens. Turn + * this on for debugging only, against a test account; write the file somewhere + * that is not shipped or uploaded, and delete it afterwards. Nothing is + * exported unless the application calls one of the two functions below -- but + * note this is a runtime switch, not a build-time one: the functions are linked + * into every build, and the only sign that a debug switch shipped is the + * "[tls] key logging ENABLED" LOG_WARN they emit. + * + * Scope is process-wide (like the handshake DRBG), so one call covers every + * connection -- MQTT, ATOP/HTTPS and the RTC/TAI channel alike. Install or + * change the sink only while no tls_connect() is in flight -- in practice once + * at startup, single-threaded, before the first one. tls_connect() snapshots + * the sink; a connection uses whatever was installed when it was opened, and + * connections already open are unaffected. There is no lock: a swap racing a + * handshake on another thread is undefined behaviour, not a late line. + * + * Which lines appear depends on the negotiated version: TLS 1.2 (what + * iot-client pins) emits one `CLIENT_RANDOM` line per connection, TLS 1.3 (what + * the RTC/TAI server may negotiate) emits the handshake and application traffic + * secrets. Exporting needs mbedTLS 3.x; on 2.x the sink installs but nothing is + * written. + * ========================================================================= */ + +/* Sink for one complete key-log line: NUL-terminated, newline included, safe to + * pass straight to fputs/write. Called during the handshake, on whichever + * thread called tls_connect() -- the thread running iot_client_init() / + * iot_client_process() for iot-client, the thread calling tai_connect() for + * RTC/TAI. If two such threads may handshake at the same time the sink must be + * thread-safe (write each line atomically, e.g. under a mutex), or the two + * lines interleave and Wireshark discards both. */ +typedef void (*tls_keylog_fn)(void *ctx, const char *line); + +/* Route key-log lines to your own sink (a UART, a log server, a ring buffer). + * fn == NULL disables key logging. Replaces any previous sink; if the file sink + * from tls_keylog_open_file() is active it is closed first. Like every change + * of sink, call it while no tls_connect() is in flight. */ +void tls_set_keylog_handler(tls_keylog_fn fn, void *ctx); + +/* Convenience sink: append key-log lines to `path`, creating it if needed, and + * install it as the sink. Compiled in only where TLS_KEYLOG_FILE_SINK is 1 -- + * by default on POSIX hosts and ESP-IDF (its VFS); elsewhere it returns + * TLS_ERR_ARGS and tls_set_keylog_handler() is the way in. Define + * TLS_KEYLOG_FILE_SINK=1 to opt a port with a writable filesystem in. + * On POSIX the file is created 0600, symlinks are not followed, and every line + * is written and fsync'ed before the handshake continues, so a capture stays + * decryptable if the device resets mid-session; elsewhere the stream is + * unbuffered but durability is whatever the VFS gives an unflushed write. + * A failed write is logged once (LOG_ERROR) and later lines are lost. + * Returns TLS_OK, or TLS_ERR_ARGS if `path` is empty, cannot be opened, or a + * key-log file is already open (close it first). */ +int tls_keylog_open_file(const char *path); + +/* Uninstall the file sink (if it is the active sink) and close the file. + * Idempotent. Call it while no tls_connect() is in flight. */ +void tls_keylog_close_file(void); + #ifdef __cplusplus } #endif diff --git a/docs-site/docs/guides/porting-to-new-platform.md b/docs-site/docs/guides/porting-to-new-platform.md index 6ba7f25..24c4cde 100644 --- a/docs-site/docs/guides/porting-to-new-platform.md +++ b/docs-site/docs/guides/porting-to-new-platform.md @@ -152,6 +152,7 @@ CONFIG_FREERTOS_HZ=1000 - `tcp_recv` 应支持阻塞/超时语义(后台线程会循环调用) - `tcp_poll` 用于检查套接字的可读/可写状态,需正确实现 events 位掩码 - 如使用 Opus 编码,需额外集成 Opus 库 +- SDK 库代码只有两处直接依赖 libc 而不经过 PAL:ATOP 签名读 `time(NULL)`(需要 C 库能看到实时时钟),以及 TLS key log 的文件 sink 用 `fopen`/`fputs`(仅在 POSIX 与 ESP-IDF 上默认编入,其他平台需显式 `-DTLS_KEYLOG_FILE_SINK=1`,否则不引入任何文件 I/O 符号,见 [TLS 抓包解密](./tls-keylog.md)) ### PAL I/O 返回值契约 {#pal-io-返回值契约} diff --git a/docs-site/docs/guides/tls-cert-verification.md b/docs-site/docs/guides/tls-cert-verification.md index 6b629ff..a49dd3c 100644 --- a/docs-site/docs/guides/tls-cert-verification.md +++ b/docs-site/docs/guides/tls-cert-verification.md @@ -220,3 +220,10 @@ SDK 在 TLS 握手时会输出日志。启用证书验证时: 3. **POSIX 平台使用 `.cacert`。** 从 IoT-DNS 动态获取或硬编码根 CA PEM。 4. **不要在生产环境留空两个字段。** 连接虽加密但不校验,存在 MITM 风险。 5. **确保系统时间正确。** 证书有有效期,时间偏差过大会导致验证失败。MCU 平台应在联网后通过 NTP 同步时间。 + +--- + +## 相关 + +- [TLS 抓包解密](./tls-keylog.md)——调试期把会话密钥导出成 Wireshark 能读的 key log 文件,用来看 + 加密流量里的应用层数据。默认关闭,只用于 debug 固件。 diff --git a/docs-site/docs/guides/tls-keylog.md b/docs-site/docs/guides/tls-keylog.md new file mode 100644 index 0000000..ac267e1 --- /dev/null +++ b/docs-site/docs/guides/tls-keylog.md @@ -0,0 +1,161 @@ +--- +title: 用 Wireshark 解密 TLS 抓包(TLS key log) +sidebar_label: TLS 抓包解密 +sidebar_position: 9 +--- + +# 用 Wireshark 解密 TLS 抓包 + +SDK 与云端的所有通信(IoT-DNS、ATOP HTTPS、MQTT、RTC/TAI)都走 TLS,抓包只能看到密文。排查 +"云端说我发的字段不对"、"下行帧解析不出来"这类问题时,需要看到明文的应用层数据。 + +SDK 提供 TLS key log:握手时把会话密钥以 NSS `SSLKEYLOGFILE` 格式导出,Wireshark 读取这个文件 +后即可解密同一份抓包。相比在中间架一个 TLS 代理,这种方式不改变设备的连接目标,也不需要给设备 +换证书。 + +:::danger 导出的就是会话密钥本身 + +key log 文件的每一行都是一次连接的会话密钥。拿到它的人可以解密该设备这段时间的全部流量,其中包括 +MQTT 密码、ATOP 签名和 AI session token。 + +- 只在排查问题时开启,且用测试账号 / 测试设备; +- 文件写到不会被打包进固件、不会随日志上传的位置; +- 排查结束后删除文件,并关掉这个开关。 + +默认是关闭的:应用不调用下面两个函数,SDK 不会导出任何东西。但这是**运行时**开关,不是编译期 +开关——两个函数在所有构建里都存在。生产固件里唯一能发现"调试开关带上线了"的迹象,是下文那条 +`[tls] key logging ENABLED` 警告日志。 +::: + +## 开启 {#开启} + +两个函数都声明在 `common/tls.h`: + +```c +#include "tls.h" + +/* 方式一:直接写文件(POSIX 与 ESP-IDF 默认编入;其他目标需 -DTLS_KEYLOG_FILE_SINK=1) */ +int tls_keylog_open_file(const char *path); +void tls_keylog_close_file(void); + +/* 方式二:自己接收每一行(串口、日志服务、环形缓冲……) */ +typedef void (*tls_keylog_fn)(void *ctx, const char *line); +void tls_set_keylog_handler(tls_keylog_fn fn, void *ctx); +``` + +### 方式一:写文件 {#方式一写文件} + +```c +#include "tls.h" + +int main(void) +{ + /* 在第一次 TLS 连接之前调用——iot_client_init() 内部就会建连, + 所以要放在它前面。检查返回值:打不开文件时不会有任何一行被导出。 */ + if (tls_keylog_open_file("/var/tmp/tuya-debug/keylog.txt") != TLS_OK) { + fprintf(stderr, "key log disabled\n"); /* 原因见 [tls] 日志 */ + } + + iot_client_t *client = iot_client_init(&cfg); + /* ... 正常跑业务,同时用 tcpdump / Wireshark 抓包 ... */ + + iot_client_deinit(client); + tls_keylog_close_file(); + return 0; +} +``` + +路径不要放在 `/tmp` 这类多人可写的目录下的固定文件名:文件里是会话密钥,别人可以预先放一个同名文件 +或符号链接等着。用自己目录下的路径。 + +在 POSIX 上文件以 `0600` 权限创建、不跟随符号链接,每行写入后立刻 `fsync`,所以设备中途重启也不会让 +已抓到的包变成无法解密。其他平台(如 ESP-IDF 的 VFS)只保证不经过 stdio 缓冲,落盘时机取决于该 +文件系统对未 flush 写入的处理。写入失败会打一条 `LOG_ERROR`(只打一次),之后的行都会丢。 + +### 方式二:自定义 sink {#方式二自定义-sink} + +没有文件系统的目标(例如只有串口的 MCU)用这个:把每一行打到串口,在 PC 侧存成文件。 + +```c +static void keylog_to_uart(void *ctx, const char *line) +{ + (void)ctx; + /* line 以 '\0' 结尾、自带换行,可直接输出 */ + uart_write_string(line); +} + +tls_set_keylog_handler(keylog_to_uart, NULL); +``` + +`tls_set_keylog_handler(NULL, NULL)` 关闭导出。如果当时文件 sink 处于打开状态,会先把文件关掉。 +和所有换 sink 的操作一样,要在没有 `tls_connect()` 正在进行时调用。 + +## 作用范围与时机 {#作用范围与时机} + +- **进程级,一次开启覆盖所有连接**:MQTT、ATOP HTTPS、IoT-DNS 与 RTC/TAI 通道都会导出,不需要 + 逐个连接配置。 +- **在第一次 `tls_connect()` 之前、单线程环境下开启**。`tls_connect()` 在建连时读取一次开关: + 已经建立的连接不受影响,之后新建的连接才会导出。SDK 内部没有锁——在另一个线程正在握手时换 + sink 是未定义行为,不是"晚一行"。 +- 回调在**调用 `tls_connect()` 的那个线程**上触发:iot-client 是调用 `iot_client_init()` / + `iot_client_process()` 的线程,RTC/TAI 是调用 `tai_connect()` 的线程(`tai_connect()` 在调用方 + 线程上同步完成握手,之后才拉起接收线程)。如果两者可能同时建连,自定义 sink 必须**线程安全**—— + 整行原子写出(例如加互斥锁),否则两行会交错,Wireshark 两行都认不出。 +- 导出需要 mbedTLS 3.x(仓库自带 3.6)。在 2.x 上 sink 可以装上,但不会有任何输出,日志里会有提示。 +- 开启时会打一条 `LOG_WARN`: + + ``` + [tls] key logging ENABLED -- session secrets are being exported to /var/tmp/tuya-debug/keylog.txt; do not use in production + ``` + + 在生产日志里看到这一行,说明有固件把调试开关带上线了。 + +## 导出的内容 {#导出的内容} + +行的格式取决于协商出的 TLS 版本: + +| 通道 | TLS 版本 | 导出的行 | +|------|---------|---------| +| iot-client(MQTT / ATOP / DNS) | 固定 TLS 1.2 | 每条连接一行 `CLIENT_RANDOM` | +| RTC/TAI | 由服务端协商,可能是 TLS 1.3 | `CLIENT_HANDSHAKE_TRAFFIC_SECRET` / `SERVER_HANDSHAKE_TRAFFIC_SECRET` / `CLIENT_TRAFFIC_SECRET_0` / `SERVER_TRAFFIC_SECRET_0` 等多行 | + +示例(TLS 1.2,密钥已改写): + +``` +CLIENT_RANDOM 5f2e...(64 个十六进制字符) 9a41...(96 个十六进制字符) +``` + +## 在 Wireshark 中使用 {#在-wireshark-中使用} + +1. 抓包。设备与 PC 同一台机器时: + + ```sh + sudo tcpdump -i any -w tuya.pcap 'tcp port 8883 or tcp port 443' + ``` + + 设备是独立硬件时,在设备与路由器之间的镜像口 / 旁路抓。 + +2. Wireshark 里指定 key log 文件:**Preferences → Protocols → TLS → + `(Pre)-Master-Secret log filename`**,选刚才写出的文件。命令行等价写法: + + ```sh + tshark -r tuya.pcap -o tls.keylog_file:/var/tmp/tuya-debug/keylog.txt -Y mqtt + ``` + +3. 之前显示为 `Application Data` 的包会解出 MQTT / HTTP 明文。过滤器直接用 `mqtt`、`http` 即可。 + +抓包与 key log 必须来自**同一次运行**:密钥是每条连接一套的,换一次运行就对不上了。 + +## 解不出来时 {#解不出来时} + +| 现象 | 原因 | +|------|------| +| key log 文件不存在或是空的 | `tls_keylog_open_file()` 返回了错误(路径不可写、文件系统未挂载、该平台未编入文件 sink),看 `[tls] cannot open key log file` 日志里的原因;或开关设在第一次连接之后了(`iot_client_init()` 内部已经建连);或者根本没有建成 TLS 连接,看 [TLS 证书验证](./tls-cert-verification.md#查看日志) 里的握手日志说明 | +| 文件有内容但中途断了 | 写入失败,日志里有一条 `[tls] key log write failed`(磁盘满、文件系统变只读) | +| 文件有内容但 Wireshark 仍显示密文 | 抓包和 key log 不是同一次运行;或抓包漏掉了握手(Client Hello 必须在包里,Wireshark 靠 client random 对应密钥) | +| 只解出一部分连接 | 该连接的握手发生在开启之前,或抓包中途才开始 | +| MQTT 明文解出来了,但 payload 还是乱码 | 正常:MQTT payload 之上还有一层 AES-GCM(用 `local_key` 前 16 字节加密),那是 SDK 的应用层加密,不是 TLS | + +## 相关 {#相关} + +- [TLS 证书验证](./tls-cert-verification.md)——生产环境该配的东西,与本页的调试开关无关。 diff --git a/docs-site/i18n/en/docusaurus-plugin-content-docs/current/guides/tls-keylog.md b/docs-site/i18n/en/docusaurus-plugin-content-docs/current/guides/tls-keylog.md new file mode 100644 index 0000000..5347842 --- /dev/null +++ b/docs-site/i18n/en/docusaurus-plugin-content-docs/current/guides/tls-keylog.md @@ -0,0 +1,146 @@ +--- +title: Decrypting TLS Captures with Wireshark (TLS key log) +sidebar_label: TLS Capture Decryption +sidebar_position: 9 +--- + +# Decrypting TLS Captures with Wireshark + +All traffic between the SDK and the cloud (IoT-DNS, ATOP HTTPS, MQTT, RTC/TAI) runs over TLS, so a packet capture shows only ciphertext. Troubleshooting problems like "the cloud says my field is wrong" or "this downlink frame won't parse" requires seeing the plaintext application-layer data. + +The SDK provides a TLS key log: during the handshake it exports the session secrets in the NSS `SSLKEYLOGFILE` format; once Wireshark reads that file, it can decrypt the same capture. Compared with placing a TLS proxy in the middle, this changes neither the device's connection target nor its certificates. + +:::danger What gets exported IS the session key itself + +Every line in the key log file is one connection's session secret. Anyone who obtains it can decrypt all of that device's traffic for the period, including the MQTT password, ATOP signatures, and AI session tokens. + +- Enable it only while troubleshooting, and only with a test account / test device; +- write the file somewhere that is neither packaged into firmware nor uploaded with logs; +- delete the file and turn the switch off once you are done. + +It is off by default: if the application never calls the two functions below, the SDK exports nothing. But it is a **runtime** switch, not a compile-time one — both functions exist in every build. In production firmware the only visible sign that "the debug switch shipped enabled" is the `[tls] key logging ENABLED` warning log below. +::: + +## Enabling It {#开启} + +Both functions are declared in `common/tls.h`: + +```c +#include "tls.h" + +/* Option 1: write to a file directly (compiled in on POSIX and ESP-IDF by + default; other targets need -DTLS_KEYLOG_FILE_SINK=1) */ +int tls_keylog_open_file(const char *path); +void tls_keylog_close_file(void); + +/* Option 2: receive each line yourself (UART, log service, ring buffer, ...) */ +typedef void (*tls_keylog_fn)(void *ctx, const char *line); +void tls_set_keylog_handler(tls_keylog_fn fn, void *ctx); +``` + +### Option 1: Write to a File {#方式一写文件} + +```c +#include "tls.h" + +int main(void) +{ + /* Call before the first TLS connection — iot_client_init() connects + internally, so this must come before it. Check the return value: if the + file cannot be opened, not a single line gets exported. */ + if (tls_keylog_open_file("/var/tmp/tuya-debug/keylog.txt") != TLS_OK) { + fprintf(stderr, "key log disabled\n"); /* reason is in the [tls] log */ + } + + iot_client_t *client = iot_client_init(&cfg); + /* ... run normally while capturing with tcpdump / Wireshark ... */ + + iot_client_deinit(client); + tls_keylog_close_file(); + return 0; +} +``` + +Do not use a fixed file name under a world-writable directory such as `/tmp`: the file holds session secrets, and someone can pre-place a same-named file or a symlink and wait. Use a path in your own directory. + +On POSIX the file is created with `0600` permissions, symlinks are not followed, and each line is `fsync`ed as soon as it is written, so a device reboot mid-capture does not make the already-captured packets undecryptable. Other platforms (e.g. the ESP-IDF VFS) only guarantee the write bypasses stdio buffering; when it actually lands depends on how that filesystem treats unflushed writes. A failed write logs one `LOG_ERROR` (only once); subsequent lines are dropped. + +### Option 2: Custom Sink {#方式二自定义-sink} + +Targets without a filesystem (e.g. a UART-only MCU) use this: print each line to the serial console and save it into a file on the PC side. + +```c +static void keylog_to_uart(void *ctx, const char *line) +{ + (void)ctx; + /* line is NUL-terminated and carries its own newline; print as-is */ + uart_write_string(line); +} + +tls_set_keylog_handler(keylog_to_uart, NULL); +``` + +`tls_set_keylog_handler(NULL, NULL)` turns export off. If the file sink is open at that moment, it is closed first. As with every sink change, call it while no `tls_connect()` is in progress. + +## Scope and Timing {#作用范围与时机} + +- **Process-wide; enabling once covers every connection**: MQTT, ATOP HTTPS, IoT-DNS, and the RTC/TAI channel all export — no per-connection configuration is needed. +- **Enable before the first `tls_connect()`, from a single thread.** `tls_connect()` reads the switch once while connecting: already-established connections are unaffected; only connections created afterwards export. The SDK has no internal lock — swapping the sink while another thread is mid-handshake is undefined behavior, not "one line late". +- The callback fires **on the thread that calls `tls_connect()`**: for iot-client that is the thread calling `iot_client_init()` / `iot_client_process()`; for RTC/TAI it is the thread calling `tai_connect()` (`tai_connect()` completes the handshake synchronously on the caller's thread before starting the receive thread). If both may connect at the same time, a custom sink must be **thread-safe** — write each whole line atomically (e.g. under a mutex), otherwise two lines interleave and Wireshark recognizes neither. +- Exporting requires mbedTLS 3.x (the repo ships 3.6). On 2.x the sink installs but produces no output; the log says so. +- Enabling logs one `LOG_WARN`: + + ``` + [tls] key logging ENABLED -- session secrets are being exported to /var/tmp/tuya-debug/keylog.txt; do not use in production + ``` + + Seeing this line in production logs means a firmware shipped with the debug switch enabled. + +## What Gets Exported {#导出的内容} + +The line format depends on the negotiated TLS version: + +| Channel | TLS version | Exported lines | +|---------|-------------|----------------| +| iot-client (MQTT / ATOP / DNS) | Fixed TLS 1.2 | One `CLIENT_RANDOM` line per connection | +| RTC/TAI | Server-negotiated, possibly TLS 1.3 | Multiple lines such as `CLIENT_HANDSHAKE_TRAFFIC_SECRET` / `SERVER_HANDSHAKE_TRAFFIC_SECRET` / `CLIENT_TRAFFIC_SECRET_0` / `SERVER_TRAFFIC_SECRET_0` | + +Example (TLS 1.2, keys rewritten): + +``` +CLIENT_RANDOM 5f2e... (64 hex characters) 9a41... (96 hex characters) +``` + +## Using It in Wireshark {#在-wireshark-中使用} + +1. Capture. When the device and the PC are the same machine: + + ```sh + sudo tcpdump -i any -w tuya.pcap 'tcp port 8883 or tcp port 443' + ``` + + With standalone hardware, capture at a mirror port / tap between the device and the router. + +2. Point Wireshark at the key log file: **Preferences → Protocols → TLS → `(Pre)-Master-Secret log filename`**, and select the file just written. Command-line equivalent: + + ```sh + tshark -r tuya.pcap -o tls.keylog_file:/var/tmp/tuya-debug/keylog.txt -Y mqtt + ``` + +3. Packets that previously showed as `Application Data` now decode to MQTT / HTTP plaintext. Filter directly on `mqtt` or `http`. + +The capture and the key log must come from **the same run**: secrets are per-connection, and a different run will not match. + +## When It Won't Decrypt {#解不出来时} + +| Symptom | Cause | +|---------|-------| +| Key log file missing or empty | `tls_keylog_open_file()` returned an error (path not writable, filesystem not mounted, file sink not compiled in on this platform) — see the reason in the `[tls] cannot open key log file` log; or the switch was set after the first connection (`iot_client_init()` already connected inside); or no TLS connection was ever established — see the handshake-log notes in [TLS Certificate Verification](./tls-cert-verification.md#查看日志) | +| File has content but stops partway | A write failed; the log has one `[tls] key log write failed` line (disk full, filesystem turned read-only) | +| File has content but Wireshark still shows ciphertext | The capture and the key log are from different runs; or the capture missed the handshake (the Client Hello must be in the capture — Wireshark matches keys by client random) | +| Only some connections decrypt | That connection's handshake happened before enabling, or the capture started mid-way | +| MQTT decodes to plaintext but the payload is still gibberish | Normal: above the MQTT payload sits another AES-GCM layer (encrypted with the first 16 bytes of `local_key`) — that is the SDK's application-layer encryption, not TLS | + +## See Also {#相关} + +- [TLS Certificate Verification](./tls-cert-verification.md) — what to configure for production; unrelated to this page's debug switch. diff --git a/docs-site/sidebars.ts b/docs-site/sidebars.ts index b9bccc4..51d352b 100644 --- a/docs-site/sidebars.ts +++ b/docs-site/sidebars.ts @@ -58,6 +58,7 @@ const sidebars: SidebarsConfig = { 'guides/ota-upgrade', 'guides/atop-generic-call', 'guides/tls-cert-verification', + 'guides/tls-keylog', 'guides/porting-to-new-platform', 'guides/compile-time-knobs', ], diff --git a/modules/iot-client/test/mqtt_test.c b/modules/iot-client/test/mqtt_test.c index 10fc64a..41fceae 100644 --- a/modules/iot-client/test/mqtt_test.c +++ b/modules/iot-client/test/mqtt_test.c @@ -4,6 +4,7 @@ #include #include #include +#include #include #include "mqtt.h" @@ -11,6 +12,7 @@ #include "iot_internal.h" #include "log.h" #include "test_log.h" +#include "tls.h" /* TLS key log */ #define TEST_CLIENT_ID "mqtt_test_client" #define TEST_USERNAME "test_user" @@ -720,6 +722,158 @@ static int test_connect_tls_unreachable(void) return OPRT_OK; } +/* ---------- Tests: TLS key log ---------- */ + +/* One TLS handshake against the TLS mock, straight through tls_connect() with + * the same profile mqtt.c uses (TLS 1.2, Tuya suites): every key-log line is + * produced inside the handshake, before any MQTT byte, so the MQTT layer adds + * nothing here. The mock tolerates a peer that closes right after the + * handshake. Returns 0 if the handshake completed. */ +static int keylog_do_one_tls_handshake(void) +{ + tls_config_t cfg = { + .host = "127.0.0.1", + .port = 18883, + .sni = "127.0.0.1", + .cacert = g_cacert, + .force_tls12 = true, + .ciphersuites = tls_ciphersuites_tuya_default(), + .pal = get_default_pal(), + }; + tls_t *t = tls_connect(&cfg); + if (!t) return -1; + tls_close(t); + return 0; +} + +static int g_keylog_lines = 0; +static char g_keylog_last[512]; + +static void keylog_capture(void *ctx, const char *line) +{ + (void)ctx; + g_keylog_lines++; + snprintf(g_keylog_last, sizeof(g_keylog_last), "%s", line); +} + +/* `label` ' ' 64 lowercase hex (client random) ' ' secret_hex_len lowercase hex + * '\n' -- and nothing after it: Wireshark wants exactly one line. */ +static bool keylog_line_is_well_formed(const char *line, const char *label, + size_t secret_hex_len) +{ + static const char hex[] = "0123456789abcdef"; + size_t label_len = strlen(label); + if (strncmp(line, label, label_len) != 0 || line[label_len] != ' ') return false; + const char *p = line + label_len + 1; + if (strspn(p, hex) != 64 || p[64] != ' ') return false; + p += 65; + if (strspn(p, hex) != secret_hex_len) return false; + return p[secret_hex_len] == '\n' && p[secret_hex_len + 1] == '\0'; +} + +/* iot-client pins TLS 1.2, so one handshake yields exactly one CLIENT_RANDOM + * line carrying the 48-byte master secret; clearing the sink must stop them. + * Also pins the "key logging ENABLED" warning the docs tell operators to grep + * production logs for. */ +static int test_keylog_handler_captures_client_random(void) +{ + g_keylog_lines = 0; + g_keylog_last[0] = '\0'; + + test_log_capture_begin(log_capture, sizeof log_capture); + tls_set_keylog_handler(keylog_capture, NULL); + test_log_capture_end(); + if (strstr(log_capture, "key logging ENABLED") == NULL) { + printf(" enabling the sink did not log the documented warning\n"); + tls_set_keylog_handler(NULL, NULL); + return -1; + } + + int ret = keylog_do_one_tls_handshake(); + tls_set_keylog_handler(NULL, NULL); + + if (ret != 0) { + printf(" TLS connect failed\n"); + return -1; + } + if (g_keylog_lines != 1) { + printf(" expected 1 key-log line, got %d\n", g_keylog_lines); + return -1; + } + if (!keylog_line_is_well_formed(g_keylog_last, "CLIENT_RANDOM", 96)) { + printf(" malformed key-log line: %s", g_keylog_last); + return -1; + } + + /* Disabling must actually disable: no further lines after the handler is + * cleared, or a debug session would keep leaking secrets. */ + g_keylog_lines = 0; + if (keylog_do_one_tls_handshake() != 0) { + printf(" second TLS connect failed\n"); + return -1; + } + if (g_keylog_lines != 0) { + printf(" handler still called after being cleared (%d lines)\n", g_keylog_lines); + return -1; + } + return OPRT_OK; +} + +static int test_keylog_file_sink(void) +{ + const pal_t *pal = get_default_pal(); + char path[256]; + snprintf(path, sizeof(path), "/tmp/ak_keylog_test_%d.log", (int)getpid()); + unlink(path); + int rc = -1; + char *content = NULL; + + if (tls_keylog_open_file(path) != TLS_OK) { + printf(" tls_keylog_open_file failed\n"); + goto out; + } + /* A second open while one is active must be refused rather than silently + * leaking the first FILE*. */ + if (tls_keylog_open_file(path) == TLS_OK) { + printf(" expected second tls_keylog_open_file to fail\n"); + goto out; + } + /* Replacing the file sink with a custom one must not leave the file open: + * the file must be complete (and closable twice) after the swap. */ + if (keylog_do_one_tls_handshake() != 0) { + printf(" TLS connect failed\n"); + goto out; + } + tls_keylog_close_file(); + tls_keylog_close_file(); /* idempotent */ + + /* The whole file, not the first line: the validator wants "\n\0" right after + * the secret, so this also proves exactly one line was written. */ + content = load_file(pal, path); + if (!content || content[0] == '\0') { + printf(" key log file missing or empty\n"); + goto out; + } + if (!keylog_line_is_well_formed(content, "CLIENT_RANDOM", 96)) { + printf(" key log file does not hold exactly one well-formed line: %s", content); + goto out; + } +#if defined(__unix__) || defined(__APPLE__) + struct stat st; + if (stat(path, &st) != 0 || (st.st_mode & 0777) != 0600) { + printf(" key log file mode is %o, expected 0600\n", + (unsigned)(st.st_mode & 0777)); + goto out; + } +#endif + rc = OPRT_OK; +out: + tls_keylog_close_file(); + unlink(path); /* never leave session keys behind, pass or fail */ + if (content) pal->free(content); + return rc; +} + /* ---------- Test: connect failure (bad URL) ---------- */ static int test_connect_bad_url(void) @@ -821,6 +975,12 @@ int main(void) RUN_TEST(test_connect_tls_auth_fail); RUN_TEST(test_connect_tls_handshake_fail); RUN_TEST(test_connect_tls_unreachable); + if (g_cacert) { + RUN_TEST(test_keylog_handler_captures_client_random); + RUN_TEST(test_keylog_file_sink); + } else { + printf("\n key-log tests skipped (no CA certificate loaded)\n"); + } RUN_TEST(test_connect_bad_url); stop_mock_tls_server();