From 8ced238f7704df1c8b53fd1bcec3597bc96b828b Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Mon, 28 Sep 2026 19:47:48 +0000 Subject: [PATCH 01/17] [C] Store hub-issued certificates and report store results - Add opts.use_issued_certificates (default true via options_default()): on AZ_IOT_CSR_ISSUED the client stores the chain in the certificate provider before the CSR callback, reports it in evt->store_status, and keeps the live session. A failed store keeps the previous credential. - Operational-cert callback (DPS) now always fires with the chain plus the store result, including when the store fails. - Managed provider stores atomically (temp file, validate, replace), so a failed store keeps the previous certificate. - Provider contract: store_issued_certificate() is all-or-nothing. - Update samples, e2e test, docs; add unit tests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../az_iot_certificate_provider_managed.c | 59 ++- .../az_iot_certificate_provider_managed.h | 6 +- c/docs/connection.md | 13 +- c/docs/eng/certificate-management.md | 10 +- c/docs/eng/connection-c.md | 13 +- c/docs/test-coverage.md | 21 +- c/inc/azure/iot/az_iot_certificate_provider.h | 6 +- c/inc/azure/iot/az_iot_connection_client.h | 58 ++- c/samples/authentication/README.md | 3 +- .../custom_certificate_provider/main.c | 10 +- .../authentication/dps_csr_managed/main.c | 10 +- c/samples/authentication/hub_renew/main.c | 28 +- c/src/core/connection_client.c | 56 ++- c/tests/e2e/tests/e2e_csr_test.c | 8 +- .../unit/certificate_provider_managed_test.c | 140 ++++-- c/tests/unit/connection_client_test.c | 417 +++++++++++++++++- 16 files changed, 740 insertions(+), 118 deletions(-) diff --git a/c/adapters/cert_openssl/az_iot_certificate_provider_managed.c b/c/adapters/cert_openssl/az_iot_certificate_provider_managed.c index 835f7d1f4..6d8217445 100644 --- a/c/adapters/cert_openssl/az_iot_certificate_provider_managed.c +++ b/c/adapters/cert_openssl/az_iot_certificate_provider_managed.c @@ -10,9 +10,18 @@ * no CRT fopen and stays clean under MSVC /W4 /WX). */ #include "az_iot_certificate_provider_managed.h" +#include #include #include +#ifdef _WIN32 +/* Lean: keeps wincrypt.h out, whose X509_* macros clash with OpenSSL. */ +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#endif +#include +#endif + #include #include #include @@ -47,6 +56,9 @@ /* Encoded length (excluding NUL) of base64 over `binary_len` bytes. */ #define BASE64_ENCODED_LEN(binary_len) ((((binary_len) + 2) / 3) * 4) +/* Suffix of the temporary file a new operational cert is staged in. */ +#define MANAGED_TMP_SUFFIX ".tmp" + /* -------------------------------------------------------------------------- * Small helpers * ------------------------------------------------------------------------ */ @@ -124,6 +136,19 @@ static bool operational_cert_is_valid(const char* path) return true; } +/** + * @brief Replace @p to with @p from in one step, overwriting @p to if present. + * @return true on success. + */ +static bool replace_file(const char* from, const char* to) +{ +#ifdef _WIN32 + return MoveFileExA(from, to, MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH) != 0; +#else + return rename(from, to) == 0; +#endif +} + /* -------------------------------------------------------------------------- * vtable hooks * ------------------------------------------------------------------------ */ @@ -391,13 +416,25 @@ static az_iot_result managed_store( return AZ_IOT_ERR_INVALID_ARG; } - BIO* b = BIO_new_file(m->operational_cert_path, "wb"); + /* All-or-nothing: write and validate a temporary file, then replace the + * operational cert with it, so a failure leaves the previous one in use. */ + size_t path_len = strlen(m->operational_cert_path); + char* tmp_path = (char*)malloc(path_len + sizeof(MANAGED_TMP_SUFFIX)); + if (!tmp_path) + { + return AZ_IOT_ERR_OUT_OF_MEMORY; + } + memcpy(tmp_path, m->operational_cert_path, path_len); + memcpy(tmp_path + path_len, MANAGED_TMP_SUFFIX, sizeof(MANAGED_TMP_SUFFIX)); + + BIO* b = BIO_new_file(tmp_path, "wb"); if (!b) { + free(tmp_path); return AZ_IOT_ERR_INTERNAL; } - /* Write each issued cert (leaf first) into the operational cert file. */ + /* Write each issued cert (leaf first) into the temporary file. */ az_iot_result rc = AZ_IOT_OK; for (size_t i = 0; i < issued->count; ++i) { @@ -413,8 +450,26 @@ static az_iot_result managed_store( break; } } + if (BIO_flush(b) != 1) + { + rc = AZ_IOT_ERR_INTERNAL; + } BIO_free(b); + if (rc == AZ_IOT_OK && !operational_cert_is_valid(tmp_path)) + { + rc = AZ_IOT_ERR_INVALID_ARG; + } + if (rc == AZ_IOT_OK && !replace_file(tmp_path, m->operational_cert_path)) + { + rc = AZ_IOT_ERR_INTERNAL; + } + if (rc != AZ_IOT_OK) + { + (void)remove(tmp_path); + } + free(tmp_path); + if (rc == AZ_IOT_OK) { m->has_operational = true; diff --git a/c/adapters/cert_openssl/az_iot_certificate_provider_managed.h b/c/adapters/cert_openssl/az_iot_certificate_provider_managed.h index c18a5b133..a784cf181 100644 --- a/c/adapters/cert_openssl/az_iot_certificate_provider_managed.h +++ b/c/adapters/cert_openssl/az_iot_certificate_provider_managed.h @@ -12,7 +12,8 @@ * - produces PKCS#10 CSRs over that operational key (get_csr); * - persists the DPS/Hub-issued operational certificate chain to disk * (store_issued_certificate) and serves it back on subsequent loads and - * process restarts. + * process restarts. A store replaces the file only once the new chain is + * written and parses, so a failed store keeps the previous certificate. * * This is the reference implementation of the CSR provider contract. Deployments * with a TPM/HSM/secure element should implement their own provider with a @@ -50,7 +51,8 @@ extern "C" * is generated and written here (PEM). Required. */ const char* operational_key_pem_path; /* required */ /* Where the issued operational certificate chain is persisted. Written by - * store_issued_certificate(); read back on load() and on restart. Required. */ + * store_issued_certificate() via ".tmp"; read back on load() and on + * restart. Required. */ const char* operational_cert_pem_path; /* required */ /* Key type used only when generating a new operational key. */ az_iot_certificate_managed_key_type key_type; diff --git a/c/docs/connection.md b/c/docs/connection.md index 5706b89e8..f1f6de375 100644 --- a/c/docs/connection.md +++ b/c/docs/connection.md @@ -135,7 +135,7 @@ sequenceDiagram DPS-->>Conn: status = assigned (assignedHub, deviceId, connectionProfile, issuedCertificateChain) opt issued chain present Conn->>Cert: store_issued_certificate(chain) - Conn-->>App: operational_cert_callback(chain) + Conn-->>App: operational_cert_callback(chain, store result) end Conn->>DPS: DISCONNECT + tear down v3.1.1 adapter Conn->>Conn: resolve connection profile, apply assigned host / client id, pick hub role @@ -445,7 +445,7 @@ flowchart TD J --> K{credentials response} K -->|202 accepted| L["CSR_ACCEPTED callback,
keep waiting"] L --> K - K -->|200 issued| M["CSR_ISSUED callback with chain,
app persists"] + K -->|200 issued| M["store_issued_certificate
(use_issued_certificates), then
CSR_ISSUED callback with chain + store_status"] K -->|"error or 120s timeout"| N["CSR_FAILED callback,
slot released"] M --> O["New certificate is used on
the NEXT connect attempt"] ``` @@ -462,7 +462,14 @@ Rules that apply to both clients: - Only one CSR operation may be in flight; a second request fails fast with a *busy* result. - The issued chain is delivered leaf-first as base64 DER and is only valid for the duration of the - callback — the application must copy or persist it. + callback — the application must copy it to use it afterwards. +- The DPS operational-cert callback fires whether or not the store succeeded, with its result. A + failed DPS store fails the registration. +- Hub renewal: with `opts.use_issued_certificates` (default on) the client stores the chain in the + provider before `CSR_ISSUED`, reported in `store_status`. If the store fails the session stays up + and the previous credential stays in use. Off: the application owns the chain. +- `store_issued_certificate()` must be all-or-nothing: on failure the provider keeps serving the + previous credential. - A successful renewal does **not** tear down the live session. The new credential takes effect on the next connect, whether that is a reconnect or an explicit reopen. - At connect time the provider is asked for `OPERATIONAL` first and falls back to `BOOTSTRAP` when diff --git a/c/docs/eng/certificate-management.md b/c/docs/eng/certificate-management.md index af769d29e..7a7e26e8c 100644 --- a/c/docs/eng/certificate-management.md +++ b/c/docs/eng/certificate-management.md @@ -355,14 +355,16 @@ az_iot_result az_iot_connection_client_send_csr( void* user_ctx); ``` -After `AZ_IOT_CSR_ISSUED`, the new chain is persisted and the client reconnects with it. +With `opts.use_issued_certificates` (default on), the client stores the new chain in the +provider before `AZ_IOT_CSR_ISSUED` (result in `evt->store_status`); the next connect uses it. +The live session is kept, and a failed store keeps the previous credential. Two integration options, mirroring the two ownership models: - **App-owned (C# style):** app supplies `csr` bytes, receives the chain in the callback, swaps certs, and calls `close()` / `open()` — explicit, no provider needed. -- **Provider-owned (this design):** the client calls `get_csr()` / - `store_issued_certificate()` around the exchange and re-`load()`s, so renewal is - transparent (same seam as the DPS path). +- **Provider-owned (this design):** the app takes the CSR from `get_csr()`; the client calls + `store_issued_certificate()` and the next connect re-`load()`s, so renewal is transparent + (same seam as the DPS path). --- diff --git a/c/docs/eng/connection-c.md b/c/docs/eng/connection-c.md index 206d483c7..24e2e6b27 100644 --- a/c/docs/eng/connection-c.md +++ b/c/docs/eng/connection-c.md @@ -146,7 +146,7 @@ sequenceDiagram DPS-->>Conn: status = assigned (assignedHub, deviceId, connectionProfile, issuedCertificateChain) opt issued chain present Conn->>Cert: store_issued_certificate(chain) - Conn-->>App: operational_cert_callback(chain) + Conn-->>App: operational_cert_callback(chain, store result) end alt no ref held (dps_refs_held() false) Conn->>DPS: DISCONNECT + tear down v3.1.1 adapter @@ -527,7 +527,7 @@ flowchart TD J --> K{credentials response} K -->|202 accepted| L["CSR_ACCEPTED callback,
keep waiting"] L --> K - K -->|200 issued| M["CSR_ISSUED callback with chain,
app persists"] + K -->|200 issued| M["store_issued_certificate
(use_issued_certificates), then
CSR_ISSUED callback with chain + store_status"] K -->|"error or 120s timeout"| N["CSR_FAILED callback,
slot released"] M --> O["New certificate is used on
the NEXT connect attempt"] ``` @@ -545,7 +545,14 @@ Rules every client must implement. The C client meets all of them; where .NET do - Only one CSR operation may be in flight; a second request fails fast with a *busy* result. - The issued chain is delivered leaf-first as base64 DER and is only valid for the duration of the - callback — the application must copy or persist it. + callback — the application must copy it to use it afterwards. +- The DPS operational-cert callback fires whether or not the store succeeded, with its result. A + failed DPS store fails the registration. +- Hub renewal: with `opts.use_issued_certificates` (default on) the client stores the chain in the + provider before `CSR_ISSUED`, reported in `store_status`. If the store fails the session stays up + and the previous credential stays in use. Off: the application owns the chain. +- `store_issued_certificate()` must be all-or-nothing: on failure the provider keeps serving the + previous credential. - A successful renewal does **not** tear down the live session. The new credential takes effect on the next connect, whether that is a reconnect or an explicit reopen. - At connect time the provider is asked for `OPERATIONAL` first and falls back to `BOOTSTRAP` when diff --git a/c/docs/test-coverage.md b/c/docs/test-coverage.md index e9fe55091..1d09274d2 100644 --- a/c/docs/test-coverage.md +++ b/c/docs/test-coverage.md @@ -196,7 +196,7 @@ Covers `az_iot_connection_client` lifecycle, CONNACK handling, reconnection, the | | MQTTv5 hub birth timeout retries with new nonce | A stale ack must not satisfy the retry. | unit | Done | [hub_mqtt_v5_birth_timeout_retries_with_a_new_nonce](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1402) | | | MQTTv5 hub without v5 factory not supported | No silent downgrade to an MQTTv3 session. | unit | Done | [hub_mqtt_v5_without_v5_factory_is_not_supported](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1302) | | | MQTTv5 hub birth ack before suback ignored | No nonce exists yet to match against. | unit | Done | [hub_mqtt_v5_birth_ack_before_suback_is_ignored](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1348) | -| DPS provisioning | DPS CSR flow stores issued chain | DPS register with CSR → ASSIGNED → operational chain stored. | unit | Done | [dps_csr_flow_sends_csr_and_stores_issued_chain](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1016) | +| DPS provisioning | DPS CSR flow stores issued chain | DPS register with CSR → ASSIGNED → operational chain stored. | unit | Done | [dps_csr_flow_sends_csr_and_stores_issued_chain](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1532) | | | Open rejects operational cert without CSR provider | `request_operational_certificate` set but provider has no `get_csr`. | unit | Done | [open_rejects_operational_cert_without_csr_provider](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L899) | | | Open rejects operational cert without payload buffer | `request_operational_certificate` set but `csr_payload_buffer` empty. | unit | Done | [open_rejects_operational_cert_without_payload_buffer](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1168) | | | DPS connects to the global endpoint | Default `global.azure-devices-provisioning.net`. | unit | Done | [dps_connects_to_the_global_endpoint_by_default](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_dps_test.c#L309) | @@ -849,8 +849,9 @@ runtime renewal against the connected hub over `$iothub/credentials/...` per | | Store persists the issued chain | — | unit | Done | [managed_store_persists_and_survives_restart](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L151) | | | The persisted chain reloads after a restart | — | unit | Done | [managed_store_persists_and_survives_restart](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L151) | | | A CSR buffer that is too small is reported | — | unit | Pending | *certificate_provider_managed_test.c* | -| | A stored chain that is not a certificate is rejected on the next load | The store hook PEM-wraps what the service sent without parsing it, so the check lands on the next init: a fresh provider over the same paths must not claim an operational identity. | unit | Done | [managed_a_stored_chain_that_is_not_a_certificate_is_rejected_on_restart](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L418) | -| | Store overwrites a previously issued chain | Renewal replaces rather than appends. | unit | Done | [managed_store_overwrites_a_previously_issued_chain](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L381) | +| | A stored chain that is not a certificate is refused | The store validates the chain before replacing the file; nothing is written and no operational identity is claimed. | unit | Done | [managed_a_stored_chain_that_is_not_a_certificate_is_refused](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L453) | +| | A failed store keeps the previous certificate | All-or-nothing, in-process and after a restart, so a failed renewal cannot break the next connect. | unit | Done | [managed_a_failed_store_keeps_the_previous_certificate](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L483) | +| | Store overwrites a previously issued chain | Renewal replaces rather than appends. | unit | Done | [managed_store_overwrites_a_previously_issued_chain](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L422) | | | The sign hook is not offered by either bundled provider | Neither provider sets `sign`, so the connect path must keep checking it for NULL. Pinned so that adding an implementation is a deliberate act. | unit | Done | [the_sign_hook_is_not_offered_by_this_provider](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L447) | | | An operational load with no stored chain returns not found | The connect path uses this code to decide it must still enroll. Belongs to the managed provider: the PEM loader ignores the role. | unit | Done | [managed_operational_load_without_a_stored_chain_is_not_found](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L261) | | | Load rejects null arguments | — | unit | Done | [managed_load_rejects_null_arguments](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L240) | @@ -859,12 +860,15 @@ runtime renewal against the connected hub over `$iothub/credentials/...` per | | Store rejects an issuance with no certificates | Accepting an empty chain would flip the operational flag with nothing on disk. | unit | Done | [managed_store_rejects_null_arguments](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L320) | | | Deinit tolerates null | — | unit | Done | [managed_deinit_tolerates_null](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L348) | | | Deinit through the vtable destroys the provider | What a generic owner of an `az_iot_certificate_provider` calls. | unit | Done | [managed_deinit_through_the_vtable_destroys_the_provider](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L356) | -| Issuance via DPS | The CSR rides the DPS registration body | — | unit | Done | [dps_csr_flow_sends_csr_and_stores_issued_chain](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1016) | -| | The issued chain is handed to the provider | — | unit | Done | [dps_csr_flow_sends_csr_and_stores_issued_chain](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1016) | +| Issuance via DPS | The CSR rides the DPS registration body | — | unit | Done | [dps_csr_flow_sends_csr_and_stores_issued_chain](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1532) | +| | The issued chain is handed to the provider | — | unit | Done | [dps_csr_flow_sends_csr_and_stores_issued_chain](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1532) | | | Open is refused without a CSR-capable provider | — | unit | Done | [open_rejects_operational_cert_without_csr_provider](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L899) | | | Open is refused without a payload buffer | — | unit | Done | [open_rejects_operational_cert_without_payload_buffer](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1168) | | | The DPS username carries the CSR API version | The CSR flow needs a newer api-version than the azure-sdk-for-c default. | unit | Pending | *connection_client_test.c* | -| | The operational cert callback fires with the issued chain | `az_iot_connection_client_set_operational_cert_callback()` has no test. | unit | Pending | *connection_client_test.c* | +| | The operational cert callback fires with the issued chain and store result | Fires after the store. | unit | Done | [dps_csr_flow_sends_csr_and_stores_issued_chain](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1532) | +| | A failed store still delivers the chain, with the error | The registration then fails. | unit | Done | [dps_store_failure_still_delivers_the_chain_with_the_error](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1565) | +| | A provider without a store hook reports not supported | The callback owns the chain; the hub connect proceeds. | unit | Done | [dps_without_a_store_hook_reports_not_supported_to_the_app](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1600) | +| | No store hook and no callback fails the registration | — | unit | Done | [dps_without_a_store_hook_or_callback_fails_the_registration](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1621) | | Hub-side renewal | A renewal publishes the issue-certificate topic | `$iothub/credentials/POST/issueCertificate/?$rid=`. | unit | Done | [send_csr_two_phase_delivers_issued_chain](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1189) | | | A 202 reports accepted without completing | — | unit | Done | [send_csr_two_phase_delivers_issued_chain](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1189) | | | A 200 delivers the issued chain | — | unit | Done | [send_csr_two_phase_delivers_issued_chain](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1189) | @@ -879,6 +883,11 @@ runtime renewal against the connected hub over `$iothub/credentials/...` per | | A 412 reports the service code | No matching request to replace. | unit | Done | [a_412_reports_the_service_code](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1581) | | | A 429 surfaces the retry-after hint | The response body carries `retryAfter`. | unit | Done | [a_429_surfaces_the_retry_after_hint](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1607) | | | A malformed issued chain reports a protocol error | 200 whose body has no `certificates` array. | unit | Done | [a_200_without_a_certificates_array_reports_a_failure](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1635) | +| | `use_issued_certificates` defaults to on | — | unit | Done | [options_default_enables_use_issued_certificates](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1987) | +| | An issued chain is stored and used on the next connect | Stored before the callback; the live session is not torn down. | unit | Done | [a_hub_issued_chain_is_stored_and_used_on_the_next_connect](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1994) | +| | A failed store keeps the session and the previous credential | Reported in `store_status`. | unit | Done | [a_failed_store_keeps_the_session_and_the_previous_credential](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2015) | +| | `use_issued_certificates` off leaves the chain to the app | — | unit | Done | [use_issued_certificates_off_leaves_the_chain_to_the_app](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2031) | +| | A v1 provider is not asked to store | The store slot is past a v1 vtable. | unit | Done | [a_v1_provider_is_not_asked_to_store_a_hub_issued_chain](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2047) | | | A renewal with no response times out | `CSR_OP_TIMEOUT_MS` elapses. | unit | Pending | *connection_client_test.c* | | | A 202 extends the timeout deadline | A slow-but-alive signer is not abandoned. | unit | Pending | *connection_client_test.c* | | | Send CSR while disconnected is refused | — | unit | Done | [send_csr_while_disconnected_is_refused](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1661) | diff --git a/c/inc/azure/iot/az_iot_certificate_provider.h b/c/inc/azure/iot/az_iot_certificate_provider.h index 28b0768aa..ba66aeedc 100644 --- a/c/inc/azure/iot/az_iot_certificate_provider.h +++ b/c/inc/azure/iot/az_iot_certificate_provider.h @@ -116,7 +116,11 @@ extern "C" * the matching private key was generated by the provider in get_csr() and * never transits this hook - so the single-cert material shape would not fit. * Each entry is base64 DER (the provider PEM-wraps to persist). The chain is - * valid only for this call (see the type's lifetime note). */ + * valid only for this call (see the type's lifetime note). + * + * Must be all-or-nothing: on failure, load(AZ_IOT_CRED_OPERATIONAL) keeps + * returning the previous credential, so a failed renewal does not break the + * next connect. */ az_iot_result (*store_issued_certificate)( az_iot_certificate_provider* self, const az_iot_issued_certificate* issued); diff --git a/c/inc/azure/iot/az_iot_connection_client.h b/c/inc/azure/iot/az_iot_connection_client.h index 50269b79d..2f9395cbb 100644 --- a/c/inc/azure/iot/az_iot_connection_client.h +++ b/c/inc/azure/iot/az_iot_connection_client.h @@ -565,6 +565,27 @@ extern "C" * disconnected session is also bounded by its queue (100 messages / 1 MB) -- * overflowing that destroys the session regardless of this value. */ uint32_t session_expiry_seconds; + + /** + * @brief Store a certificate issued by az_iot_connection_client_send_csr() + * in #certificate_provider, so the next hub connect uses it. + * + * When true, on AZ_IOT_CSR_ISSUED the client calls the provider's + * store_issued_certificate() before the CSR callback, and reports the result + * in az_iot_csr_event.store_status. The live session is never torn down; the + * new certificate takes effect on the next connect (reconnect, or close() + + * open()). If storing fails, the client stays connected and keeps using the + * previous credential. + * + * When false, the application owns the issued chain. + * + * Hub renewal (mqttv3) only. DPS enrollment always stores (see + * dps.request_operational_certificate). + * + * az_iot_connection_client_options_default() sets true; a zeroed struct has + * false. + */ + bool use_issued_certificates; } az_iot_connection_client_options; /* Which of the client's two lifecycles something refers to. @@ -725,15 +746,35 @@ extern "C" int32_t service_code; /* hub errorCode on FAILED; 0 otherwise */ uint32_t retry_after_s; /* suggested retry delay; 0 if none */ const az_iot_issued_certificate* issued; /* non-NULL on ISSUED */ + /** + * @brief ISSUED only: outcome of storing the chain in the certificate provider. + * + * AZ_IOT_OK: stored; the next hub connect uses it. + * AZ_IOT_ERR_NOT_SUPPORTED: not stored (opts.use_issued_certificates is + * false, or the provider has no store_issued_certificate()). + * Other: the provider's failure; the previous credential stays in use. + */ + az_iot_result store_status; } az_iot_csr_event; typedef void (*az_iot_csr_callback)(const az_iot_csr_event* evt, void* user_ctx); - /* Fired when the connection client obtains a DPS/provider-issued operational - * certificate during provisioning (D4). Optional; use for app-side persistence - * or to react (e.g. inventory). The chain is valid only during the callback. */ - typedef void ( - *az_iot_operational_cert_callback)(const az_iot_issued_certificate* issued, void* user_ctx); + /** + * @brief Fired when DPS issues an operational certificate during provisioning (D4). + * + * Fires after the client tries to store the chain in the certificate provider, + * whether or not that succeeded. + * + * @param issued Issued chain, leaf first. Valid only during the callback. + * @param store_result AZ_IOT_OK: stored in the provider. AZ_IOT_ERR_NOT_SUPPORTED: + * the provider has no store_issued_certificate(). Other: the provider's failure; + * the registration then fails, as any DPS failure does (see reconnection_policy). + * @param user_ctx Context given at registration. + */ + typedef void (*az_iot_operational_cert_callback)( + const az_iot_issued_certificate* issued, + az_iot_result store_result, + void* user_ctx); /* Fired when a DPS registration completes and the assignment carries a custom * payload -- `registrationState.payload`, the counterpart of @@ -1378,8 +1419,8 @@ extern "C" const az_iot_connection_client* client, az_iot_connection_scope scope); - /* Register a callback fired when a DPS/provider-issued operational certificate - * is obtained during provisioning (D4). Optional. */ + /* Register a callback fired when DPS issues an operational certificate during + * provisioning (D4), with the result of storing it. Optional. */ az_iot_result az_iot_connection_client_set_operational_cert_callback( az_iot_connection_client* client, az_iot_operational_cert_callback cb, @@ -1434,6 +1475,9 @@ extern "C" * The request's device id is taken from the connected client_id. Only one CSR * operation may be in flight; returns AZ_IOT_ERR_BUSY otherwise. The issued * chain in AZ_IOT_CSR_ISSUED is valid only for the duration of the callback. + * With opts.use_issued_certificates, the chain is stored in the certificate + * provider before that callback (result in evt->store_status) and used on the + * next hub connect; the live session is not torn down. * If no terminal (200/error) response arrives within an internal timeout, the * callback fires once with AZ_IOT_CSR_FAILED / AZ_IOT_ERR_TIMEOUT and the slot * is released, so a lost response can never wedge renewal permanently. */ diff --git a/c/samples/authentication/README.md b/c/samples/authentication/README.md index 2aaa055d6..ee6351831 100644 --- a/c/samples/authentication/README.md +++ b/c/samples/authentication/README.md @@ -72,7 +72,8 @@ adapter. Setup, run and troubleshooting: [dps_csr_managed/README.md](dps_csr_man Runtime operational-certificate renewal against a connected MQTTv3 hub (not available on MQTTv5 hubs): builds a fresh CSR from the managed provider, calls `az_iot_connection_client_send_csr()`, -and persists the renewed chain. Requires the managed provider and Paho. +and reconnects with the renewed chain, which the client stores in the provider +(`opts.use_issued_certificates`). Requires the managed provider and Paho. ### `hsm_pkcs11` Provisions through DPS and connects with a device key that never leaves a diff --git a/c/samples/authentication/custom_certificate_provider/main.c b/c/samples/authentication/custom_certificate_provider/main.c index 28a4e75c8..a1e96569e 100644 --- a/c/samples/authentication/custom_certificate_provider/main.c +++ b/c/samples/authentication/custom_certificate_provider/main.c @@ -64,13 +64,17 @@ static void on_conn_state(const az_iot_connection_state_event* event, void* user ((user_context*)user_ctx)->conn_state = s; } -static void on_operational_cert(const az_iot_issued_certificate* issued, void* user_ctx) +static void on_operational_cert( + const az_iot_issued_certificate* issued, + az_iot_result store_result, + void* user_ctx) { ((user_context*)user_ctx)->issued = 1; fprintf( stderr, - "[custom_cert] operational certificate issued: %zu cert(s) in chain\n", - issued ? issued->count : (size_t)0); + "[custom_cert] operational certificate issued: %zu cert(s) in chain, store status=%d\n", + issued ? issued->count : (size_t)0, + (int)store_result); } int main(void) diff --git a/c/samples/authentication/dps_csr_managed/main.c b/c/samples/authentication/dps_csr_managed/main.c index 220e68d5e..574b450b9 100644 --- a/c/samples/authentication/dps_csr_managed/main.c +++ b/c/samples/authentication/dps_csr_managed/main.c @@ -125,15 +125,19 @@ static void on_registration_payload(az_span payload, void* user_ctx) (const char*)az_span_ptr(payload)); } -/** @brief Called after the provider has persisted the issued chain. */ -static void on_operational_cert(const az_iot_issued_certificate* issued, void* user_ctx) +/** @brief Called after the client tried to store the issued chain in the provider. */ +static void on_operational_cert( + const az_iot_issued_certificate* issued, + az_iot_result store_result, + void* user_ctx) { sample_context* ctx = (sample_context*)user_ctx; ctx->issued = true; fprintf( stderr, - "[dps_csr] operational certificate issued (%zu cert(s) in chain), saved to %s\n", + "[dps_csr] operational certificate issued (%zu cert(s) in chain), %s %s\n", issued != NULL ? issued->count : (size_t)0, + store_result == AZ_IOT_OK ? "saved to" : "NOT saved to", ctx->operational_cert_path); } diff --git a/c/samples/authentication/hub_renew/main.c b/c/samples/authentication/hub_renew/main.c index 97198030b..e2253566e 100644 --- a/c/samples/authentication/hub_renew/main.c +++ b/c/samples/authentication/hub_renew/main.c @@ -12,10 +12,12 @@ * 1. Connect, then produce a fresh CSR from the managed provider and call * az_iot_connection_client_send_csr(). * 2. The hub responds in two phases - ACCEPTED (202) then ISSUED (200) with - * the new chain - and the sample persists it through the provider. + * the new chain. The client stores it in the provider + * (opts.use_issued_certificates, on by default) and reports the outcome. * 3. Apply the renewed cert by reconnecting: close + reopen re-establishes the * session with the new operational identity (a new client certificate - * requires a fresh TLS handshake, so the cert is applied on reconnect). + * requires a fresh TLS handshake). Without this, the next reconnect + * applies it. * * Requires the managed provider (OpenSSL 3.0+). * @@ -38,7 +40,6 @@ typedef struct { az_iot_connection_state conn_state; int provisioning_faulted; - az_iot_certificate_provider_managed* provider; int csr_done; az_iot_result csr_status; } user_context; @@ -83,13 +84,9 @@ static void on_csr_event(const az_iot_csr_event* evt, void* user_ctx) stderr, "[hub_renew] renewed chain issued: %zu cert(s)\n", evt->issued ? evt->issued->count : (size_t)0); - /* Persist the renewed chain through the provider. */ - if (evt->issued) - { - (void)ctx->provider->base.vtable->store_issued_certificate( - &ctx->provider->base, evt->issued); - } - ctx->csr_status = AZ_IOT_OK; + /* The client already stored it in the provider (use_issued_certificates). */ + fprintf(stderr, "[hub_renew] stored in provider: status=%d\n", (int)evt->store_status); + ctx->csr_status = evt->store_status; ctx->csr_done = 1; break; case AZ_IOT_CSR_FAILED: @@ -124,7 +121,6 @@ int main(void) user_context user_ctx = { 0 }; az_iot_certificate_provider_managed provider = { 0 }; az_iot_connection_client connection_client = { 0 }; - user_ctx.provider = &provider; az_iot_certificate_provider_managed_options mopts = { .bootstrap_cert_pem_path = config.cert, @@ -209,12 +205,10 @@ int main(void) } } - /* Apply the renewed certificate. on_csr_event persisted it through the - * provider, which flips load() to the OPERATIONAL identity; a new client - * certificate needs a fresh TLS handshake, so we close and reopen. The - * reopen's hub connection loads the OPERATIONAL identity first and thus - * reconnects with the renewed cert. (A production device that already knows - * its hub could reconnect to it directly instead of re-provisioning.) */ + /* Apply the renewed certificate now. The client stored it in the provider, + * which serves it as the OPERATIONAL identity; a new client certificate needs + * a fresh TLS handshake, so close and reopen. The reopen reconnects to the + * same hub (no re-provisioning) with the renewed cert. */ if (user_ctx.csr_done && user_ctx.csr_status == AZ_IOT_OK) { fprintf(stderr, "[hub_renew] renewal complete; reconnecting to apply the new certificate\n"); diff --git a/c/src/core/connection_client.c b/c/src/core/connection_client.c index 9697e6ea0..8012271c9 100644 --- a/c/src/core/connection_client.c +++ b/c/src/core/connection_client.c @@ -1604,24 +1604,27 @@ static az_iot_result dps_store_issued_cert(az_iot_connection_client* c, az_span issued.certificates = certs; issued.count = count; - /* Persist via the provider (if capable) and/or notify the app (D4). The - * issued chain must be handled by at least one of the two. */ - bool handled = false; - if (p && p->vtable && p->vtable->store_issued_certificate) + /* Persist via the provider (if capable), then always notify the app with the + * outcome (D4). At least one of the two must exist to take the chain. */ + bool has_store = p && p->vtable && p->vtable->store_issued_certificate; + az_iot_result store_rc = AZ_IOT_ERR_NOT_SUPPORTED; + if (has_store) { - rc = p->vtable->store_issued_certificate(p, &issued); - handled = (rc == AZ_IOT_OK); + store_rc = p->vtable->store_issued_certificate(p, &issued); + if (store_rc != AZ_IOT_OK) + { + AZ_IOT_LOG_ERRORF("dps: storing the issued certificate failed (%d)", (int)store_rc); + } } - if (rc == AZ_IOT_OK && c->op_cert_cb) + if (c->op_cert_cb) { - c->op_cert_cb(&issued, c->op_cert_cb_ctx); - handled = true; + c->op_cert_cb(&issued, store_rc, c->op_cert_cb_ctx); } - if (rc == AZ_IOT_OK && !handled) + if (has_store) { - rc = AZ_IOT_ERR_NOT_SUPPORTED; + return store_rc; } - return rc; + return c->op_cert_cb ? AZ_IOT_OK : AZ_IOT_ERR_NOT_SUPPORTED; } static void on_dps_mqtt_event(const az_iot_mqtt_event* evt, void* user_ctx) @@ -3357,6 +3360,7 @@ az_iot_connection_client_options az_iot_connection_client_options_default(void) opts.reconnection_policy = az_iot_reconnection_policy_get_default(); opts.dps.max_hub_connect_attempts_before_reprovision = AZ_IOT_DEFAULT_MAX_HUB_CONNECT_ATTEMPTS_BEFORE_REPROVISION; + opts.use_issued_certificates = true; return opts; } @@ -5387,6 +5391,33 @@ static void csr_parse_error(az_span payload, int32_t* out_code, int32_t* out_ret } } +/** + * @brief Store a hub-issued chain in the certificate provider when + * opts.use_issued_certificates is set. Never touches the live session: on + * failure the previous credential stays in use. + * + * @return AZ_IOT_OK if stored, AZ_IOT_ERR_NOT_SUPPORTED if not attempted, else + * the provider's error. + */ +static az_iot_result csr_store_issued( + az_iot_connection_client* c, + const az_iot_issued_certificate* issued) +{ + az_iot_certificate_provider* p = c->opts.certificate_provider; + if (!c->opts.use_issued_certificates || !p || !p->vtable + || p->vtable->version < CERT_PROVIDER_VTABLE_V2 || !p->vtable->store_issued_certificate) + { + return AZ_IOT_ERR_NOT_SUPPORTED; + } + az_iot_result rc = p->vtable->store_issued_certificate(p, issued); + if (rc != AZ_IOT_OK) + { + AZ_IOT_LOG_ERRORF( + "csr: storing the issued certificate failed (%d); keeping the current one", (int)rc); + } + return rc; +} + /* Inbound handler for $iothub/credentials/res/{status}/?$rid={rid}. */ static void on_csr_response(void* user_ctx, const az_iot_mqtt_message* msg) { @@ -5490,6 +5521,7 @@ static void on_csr_response(void* user_ctx, const az_iot_mqtt_message* msg) evt.kind = AZ_IOT_CSR_ISSUED; evt.status = AZ_IOT_OK; evt.issued = &issued; + evt.store_status = csr_store_issued(c, &issued); if (cb) { cb(&evt, uc); diff --git a/c/tests/e2e/tests/e2e_csr_test.c b/c/tests/e2e/tests/e2e_csr_test.c index a48b77de2..0cf1745bb 100644 --- a/c/tests/e2e/tests/e2e_csr_test.c +++ b/c/tests/e2e/tests/e2e_csr_test.c @@ -58,6 +58,7 @@ typedef struct az_iot_result last_reason; int issued; size_t issued_count; + az_iot_result store_result; int send_done; az_iot_result send_status; } csr_ctx; @@ -80,11 +81,15 @@ static void on_conn_state(const az_iot_connection_state_event* event, void* user fprintf(stderr, "[e2e-csr] conn state -> 0x%x (reason 0x%x)\n", (unsigned)s, (unsigned)reason); } -static void on_operational_cert(const az_iot_issued_certificate* issued, void* user_ctx) +static void on_operational_cert( + const az_iot_issued_certificate* issued, + az_iot_result store_result, + void* user_ctx) { csr_ctx* c = (csr_ctx*)user_ctx; c->issued = 1; c->issued_count = issued ? issued->count : 0; + c->store_result = store_result; } static void on_send_done(az_iot_result status, void* user_ctx) @@ -174,6 +179,7 @@ static void run_csr_enrollment(az_iot_certificate_managed_key_type key_type, con /* the operational-cert callback (D4) fired with a non-empty chain, and */ assert_int_equal(ctx.issued, 1); assert_true(ctx.issued_count >= 1); + assert_int_equal(ctx.store_result, AZ_IOT_OK); /* the managed provider persisted the issued cert for the next boot. */ assert_true(provider.has_operational); diff --git a/c/tests/unit/certificate_provider_managed_test.c b/c/tests/unit/certificate_provider_managed_test.c index b18c4f9c1..72f26bf8d 100644 --- a/c/tests/unit/certificate_provider_managed_test.c +++ b/c/tests/unit/certificate_provider_managed_test.c @@ -371,13 +371,54 @@ static void managed_deinit_through_the_vtable_destroys_the_provider(void** state remove_test_files(); } +/* base64 DER of every certificate in @p path, concatenated with ';' (heap; + * caller frees). Empty string when the file has none. */ +static char* read_chain_base64(const char* path) +{ + char* out = calloc(1, 1); + assert_non_null(out); + BIO* b = BIO_new_file(path, "rb"); + if (!b) + { + return out; + } + X509* x; + while ((x = PEM_read_bio_X509(b, NULL, NULL, NULL)) != NULL) + { + unsigned char* der = NULL; + int der_len = i2d_X509(x, &der); + assert_true(der_len > 0); + size_t cur = strlen(out); + size_t add = (((size_t)der_len + 2) / 3) * 4 + 2; + out = realloc(out, cur + add); + assert_non_null(out); + if (cur > 0) + { + out[cur++] = ';'; + } + int n = EVP_EncodeBlock((unsigned char*)out + cur, der, der_len); + assert_true(n > 0); + out[cur + (size_t)n] = '\0'; + OPENSSL_free(der); + X509_free(x); + } + BIO_free(b); + return out; +} + +static bool file_exists(const char* path) +{ + FILE* f = fopen(path, "rb"); + if (f) + { + fclose(f); + return true; + } + return false; +} + /* Renewal replaces the identity rather than appending to it: a file that - * accumulated every chain ever issued would present a stale leaf on connect. - * - * Proven through the public API rather than by inspecting the file: store a - * valid chain, then store a chain that is not a certificate, then restart. If - * the second store had appended, the valid first certificate would still be - * there and the restart would find a usable operational identity. */ + * accumulated every chain ever issued would present a stale leaf on connect. */ static void managed_store_overwrites_a_previously_issued_chain(void** state) { (void)state; @@ -386,36 +427,30 @@ static void managed_store_overwrites_a_previously_issued_chain(void** state) az_iot_certificate_provider_managed prov; assert_int_equal(AZ_IOT_OK, az_iot_certificate_provider_managed_init(&prov, &opts)); - char* good = make_self_signed_cert_base64(); - az_span chain1[1] = { az_span_create_from_str(good) }; + char* first = make_self_signed_cert_base64(); + char* second = make_self_signed_cert_base64(); + assert_string_not_equal(first, second); + + az_span chain1[1] = { az_span_create_from_str(first) }; az_iot_issued_certificate issued1 = { .certificates = chain1, .count = 1 }; assert_int_equal(AZ_IOT_OK, prov.base.vtable->store_issued_certificate(&prov.base, &issued1)); - - /* A restart here would find the good chain, which is the control for the - * assertion below. */ - az_iot_certificate_provider_managed_deinit(&prov); - az_iot_certificate_provider_managed check; - assert_int_equal(AZ_IOT_OK, az_iot_certificate_provider_managed_init(&check, &opts)); - assert_true(check.has_operational); - az_iot_certificate_provider_managed_deinit(&check); - - az_iot_certificate_provider_managed prov2; - assert_int_equal(AZ_IOT_OK, az_iot_certificate_provider_managed_init(&prov2, &opts)); - az_span chain2[1] = { AZ_SPAN_FROM_STR("bm90LWEtY2VydGlmaWNhdGU=") }; + az_span chain2[1] = { az_span_create_from_str(second) }; az_iot_issued_certificate issued2 = { .certificates = chain2, .count = 1 }; - assert_int_equal(AZ_IOT_OK, prov2.base.vtable->store_issued_certificate(&prov2.base, &issued2)); - az_iot_certificate_provider_managed_deinit(&prov2); + assert_int_equal(AZ_IOT_OK, prov.base.vtable->store_issued_certificate(&prov.base, &issued2)); + az_iot_certificate_provider_managed_deinit(&prov); - az_iot_certificate_provider_managed prov3; - assert_int_equal(AZ_IOT_OK, az_iot_certificate_provider_managed_init(&prov3, &opts)); - assert_false(prov3.has_operational); - az_iot_certificate_provider_managed_deinit(&prov3); + char* on_disk = read_chain_base64(OP_CERT); + assert_string_equal(on_disk, second); + assert_false(file_exists(OP_CERT ".tmp")); - free(good); + free(on_disk); + free(first); + free(second); remove_test_files(); } -static void managed_a_stored_chain_that_is_not_a_certificate_is_rejected_on_restart(void** state) +/* A chain that does not parse is refused and never becomes the identity. */ +static void managed_a_stored_chain_that_is_not_a_certificate_is_refused(void** state) { (void)state; remove_test_files(); @@ -425,19 +460,59 @@ static void managed_a_stored_chain_that_is_not_a_certificate_is_rejected_on_rest az_span chain[1] = { AZ_SPAN_FROM_STR("bm90LWEtY2VydGlmaWNhdGU=") }; az_iot_issued_certificate issued = { .certificates = chain, .count = 1 }; - assert_int_equal(AZ_IOT_OK, prov.base.vtable->store_issued_certificate(&prov.base, &issued)); + assert_int_not_equal(AZ_IOT_OK, prov.base.vtable->store_issued_certificate(&prov.base, &issued)); + + az_iot_certificate_material mat; + memset(&mat, 0, sizeof(mat)); + assert_int_equal( + AZ_IOT_ERR_NOT_FOUND, prov.base.vtable->load(&prov.base, AZ_IOT_CRED_OPERATIONAL, &mat)); + assert_false(file_exists(OP_CERT)); + assert_false(file_exists(OP_CERT ".tmp")); az_iot_certificate_provider_managed_deinit(&prov); az_iot_certificate_provider_managed prov2; assert_int_equal(AZ_IOT_OK, az_iot_certificate_provider_managed_init(&prov2, &opts)); assert_false(prov2.has_operational); + az_iot_certificate_provider_managed_deinit(&prov2); + remove_test_files(); +} + +/* A failed store is all-or-nothing: the previous certificate stays in use, in + * this process and after a restart, so a failed renewal cannot break the next + * connect. */ +static void managed_a_failed_store_keeps_the_previous_certificate(void** state) +{ + (void)state; + remove_test_files(); + az_iot_certificate_provider_managed_options opts = test_options(); + az_iot_certificate_provider_managed prov; + assert_int_equal(AZ_IOT_OK, az_iot_certificate_provider_managed_init(&prov, &opts)); + + char* good = make_self_signed_cert_base64(); + az_span chain1[1] = { az_span_create_from_str(good) }; + az_iot_issued_certificate issued1 = { .certificates = chain1, .count = 1 }; + assert_int_equal(AZ_IOT_OK, prov.base.vtable->store_issued_certificate(&prov.base, &issued1)); + + az_span chain2[1] = { AZ_SPAN_FROM_STR("bm90LWEtY2VydGlmaWNhdGU=") }; + az_iot_issued_certificate issued2 = { .certificates = chain2, .count = 1 }; + assert_int_not_equal(AZ_IOT_OK, prov.base.vtable->store_issued_certificate(&prov.base, &issued2)); az_iot_certificate_material mat; memset(&mat, 0, sizeof(mat)); - assert_int_equal( - AZ_IOT_ERR_NOT_FOUND, prov2.base.vtable->load(&prov2.base, AZ_IOT_CRED_OPERATIONAL, &mat)); + assert_int_equal(AZ_IOT_OK, prov.base.vtable->load(&prov.base, AZ_IOT_CRED_OPERATIONAL, &mat)); + assert_string_equal(OP_CERT, mat.client_cert_path); + char* on_disk = read_chain_base64(OP_CERT); + assert_string_equal(on_disk, good); + assert_false(file_exists(OP_CERT ".tmp")); + az_iot_certificate_provider_managed_deinit(&prov); + az_iot_certificate_provider_managed prov2; + assert_int_equal(AZ_IOT_OK, az_iot_certificate_provider_managed_init(&prov2, &opts)); + assert_true(prov2.has_operational); az_iot_certificate_provider_managed_deinit(&prov2); + + free(on_disk); + free(good); remove_test_files(); } @@ -472,7 +547,8 @@ int main(void) cmocka_unit_test(managed_deinit_tolerates_null), cmocka_unit_test(managed_deinit_through_the_vtable_destroys_the_provider), cmocka_unit_test(managed_store_overwrites_a_previously_issued_chain), - cmocka_unit_test(managed_a_stored_chain_that_is_not_a_certificate_is_rejected_on_restart), + cmocka_unit_test(managed_a_stored_chain_that_is_not_a_certificate_is_refused), + cmocka_unit_test(managed_a_failed_store_keeps_the_previous_certificate), cmocka_unit_test(the_sign_hook_is_not_offered_by_this_provider), }; return cmocka_run_group_tests(tests, NULL, NULL); diff --git a/c/tests/unit/connection_client_test.c b/c/tests/unit/connection_client_test.c index a45e17c94..ef871d878 100644 --- a/c/tests/unit/connection_client_test.c +++ b/c/tests/unit/connection_client_test.c @@ -1354,6 +1354,7 @@ typedef struct fake_csr_provider size_t stored_count; char stored_leaf[256]; az_iot_cert_role last_load_role; + az_iot_result store_rc; /* returned by fake_store; AZ_IOT_OK by default */ } fake_csr_provider; static az_iot_result fake_csr_load( @@ -1410,7 +1411,7 @@ static az_iot_result fake_store( memcpy(f->stored_leaf, az_span_ptr(leaf), n); f->stored_leaf[n] = '\0'; } - return AZ_IOT_OK; + return f->store_rc; } static const az_iot_certificate_provider_vtable k_fake_csr_vtable = { .version = AZ_IOT_CERTIFICATE_PROVIDER_VTABLE_VERSION, @@ -1424,42 +1425,57 @@ static const az_iot_certificate_provider_vtable k_fake_csr_vtable = { static int g_dps_op_cert_count = 0; static size_t g_dps_op_cert_chain = 0; -static void on_dps_op_cert(const az_iot_issued_certificate* issued, void* uc) +static az_iot_result g_dps_op_cert_store_result = AZ_IOT_OK; +static int g_dps_store_calls_at_cb = -1; +static fake_csr_provider* g_dps_prov = NULL; +static void on_dps_op_cert( + const az_iot_issued_certificate* issued, + az_iot_result store_result, + void* uc) { (void)uc; g_dps_op_cert_count++; g_dps_op_cert_chain = issued->count; + g_dps_op_cert_store_result = store_result; + g_dps_store_calls_at_cb = g_dps_prov ? g_dps_prov->store_calls : -1; } -static void dps_csr_flow_sends_csr_and_stores_issued_chain(void** state) +/* Init a DPS client with request_operational_certificate over @p prov, drive it + * through registration and deliver an ASSIGNED response carrying a two-cert + * chain. The client is left initialized; the caller deinits it. */ +static void drive_dps_csr_assignment( + az_iot_connection_client* client, + uint8_t* csr_buf, + size_t csr_buf_size, + fake_csr_provider* prov, + bool with_callback) { - (void)state; - - fake_csr_provider prov = { 0 }; - prov.base.vtable = &k_fake_csr_vtable; - - az_iot_connection_client client; - uint8_t csr_buf[AZ_IOT_CSR_PAYLOAD_BUFFER_MIN]; az_iot_connection_client_options opts = { 0 }; opts.host = NULL; /* DPS mode */ opts.client_id = "ut-device"; opts.dps.id_scope = "0ne00000000"; opts.dps.registration_id = "ut-device"; opts.dps.request_operational_certificate = true; - opts.certificate_provider = &prov.base; - opts.csr_payload_buffer = az_span_create(csr_buf, sizeof(csr_buf)); - assert_int_equal(az_iot_connection_client_init(&client, &opts), AZ_IOT_OK); + opts.certificate_provider = &prov->base; + opts.csr_payload_buffer = az_span_create(csr_buf, (int32_t)csr_buf_size); + assert_int_equal(az_iot_connection_client_init(client, &opts), AZ_IOT_OK); g_dps_op_cert_count = 0; g_dps_op_cert_chain = 0; - az_iot_connection_client_set_operational_cert_callback(&client, on_dps_op_cert, NULL); + g_dps_op_cert_store_result = AZ_IOT_OK; + g_dps_store_calls_at_cb = -1; + g_dps_prov = prov; + if (with_callback) + { + az_iot_connection_client_set_operational_cert_callback(client, on_dps_op_cert, NULL); + } az_iot_mqtt_factory* factory = az_iot_mock_mqtt_factory_create(AZ_IOT_MQTT_VERSION_3_1_1); assert_non_null(factory); - assert_int_equal(az_iot_connection_client_register_mqtt_factory(&client, factory), AZ_IOT_OK); + assert_int_equal(az_iot_connection_client_register_mqtt_factory(client, factory), AZ_IOT_OK); /* open() -> dps_start creates the DPS mock client and connects. */ - assert_int_equal(az_iot_connection_client_open(&client), AZ_IOT_OK); + assert_int_equal(az_iot_connection_client_open(client), AZ_IOT_OK); az_iot_mock_mqtt_client* dps = az_iot_mock_mqtt_factory_last_client(factory); assert_non_null(dps); const az_iot_mock_call* connect = az_iot_mock_mqtt_client_last_of(dps, AZ_IOT_MOCK_CALL_CONNECT); @@ -1469,7 +1485,7 @@ static void dps_csr_flow_sends_csr_and_stores_issued_chain(void** state) /* CONNECTED -> subscribe. */ assert_true(az_iot_mock_mqtt_client_inject_connected(dps, AZ_IOT_OK)); - (void)az_iot_connection_client_do_work(&client, 0); + (void)az_iot_connection_client_do_work(client, 0); /* SUBSCRIBE_ACK -> register publish carrying the CSR. */ az_iot_mqtt_event suback; @@ -1477,9 +1493,9 @@ static void dps_csr_flow_sends_csr_and_stores_issued_chain(void** state) suback.kind = AZ_IOT_MQTT_EVT_SUBSCRIBE_ACK; suback.status = AZ_IOT_OK; assert_true(az_iot_mock_mqtt_client_inject_event(dps, &suback)); - (void)az_iot_connection_client_do_work(&client, 0); + (void)az_iot_connection_client_do_work(client, 0); - assert_true(prov.get_csr_calls >= 1); + assert_true(prov->get_csr_calls >= 1); bool found_csr_publish = false; for (size_t i = 0; i < az_iot_mock_mqtt_client_call_count(dps); ++i) { @@ -1508,8 +1524,20 @@ static void dps_csr_flow_sends_csr_and_stores_issued_chain(void** state) * hub connect). Several do_work iterations cover the deferred steps. */ for (int i = 0; i < 5; ++i) { - (void)az_iot_connection_client_do_work(&client, 0); + (void)az_iot_connection_client_do_work(client, 0); } + g_dps_prov = NULL; +} + +static void dps_csr_flow_sends_csr_and_stores_issued_chain(void** state) +{ + (void)state; + + fake_csr_provider prov = { 0 }; + prov.base.vtable = &k_fake_csr_vtable; + az_iot_connection_client client; + uint8_t csr_buf[AZ_IOT_CSR_PAYLOAD_BUFFER_MIN]; + drive_dps_csr_assignment(&client, csr_buf, sizeof(csr_buf), &prov, true); /* Provider received the PEM-wrapped issued chain. */ assert_int_equal(prov.store_calls, 1); @@ -1519,10 +1547,90 @@ static void dps_csr_flow_sends_csr_and_stores_issued_chain(void** state) /* Hub connect selected the OPERATIONAL identity. */ assert_int_equal(prov.last_load_role, AZ_IOT_CRED_OPERATIONAL); + assert_int_equal( + az_iot_connection_client_get_state(&client, AZ_IOT_CONN_SCOPE_HUB), + AZ_IOT_CONN_STATE_CONNECTING); + + /* The app operational-cert callback (D4) fired after the store, with its result. */ + assert_int_equal(g_dps_op_cert_count, 1); + assert_int_equal((int)g_dps_op_cert_chain, 2); + assert_int_equal(g_dps_store_calls_at_cb, 1); + assert_int_equal(g_dps_op_cert_store_result, AZ_IOT_OK); + + az_iot_connection_client_deinit(&client); +} + +/* A failed store must not hide the chain from the app: the callback still fires, + * carrying the provider's error, and the registration fails as before. */ +static void dps_store_failure_still_delivers_the_chain_with_the_error(void** state) +{ + (void)state; + + fake_csr_provider prov = { 0 }; + prov.base.vtable = &k_fake_csr_vtable; + prov.store_rc = AZ_IOT_ERR_INTERNAL; + az_iot_connection_client client; + uint8_t csr_buf[AZ_IOT_CSR_PAYLOAD_BUFFER_MIN]; + drive_dps_csr_assignment(&client, csr_buf, sizeof(csr_buf), &prov, true); - /* The app operational-cert callback (D4) also fired with the chain. */ + assert_int_equal(prov.store_calls, 1); assert_int_equal(g_dps_op_cert_count, 1); assert_int_equal((int)g_dps_op_cert_chain, 2); + assert_int_equal(g_dps_op_cert_store_result, AZ_IOT_ERR_INTERNAL); + + /* Registration failed: no hub connect was attempted. */ + assert_int_equal( + az_iot_connection_client_get_state(&client, AZ_IOT_CONN_SCOPE_HUB), AZ_IOT_CONN_STATE_IDLE); + assert_null(az_iot_connection_client_get_iothub_address(&client)); + + az_iot_connection_client_deinit(&client); +} + +static const az_iot_certificate_provider_vtable k_fake_csr_vtable_no_store = { + .version = AZ_IOT_CERTIFICATE_PROVIDER_VTABLE_VERSION, + .load = fake_csr_load, + .release = fake_csr_release, + .deinit = fake_csr_destroy, + .get_csr = fake_get_csr, + .release_csr = fake_release_csr, +}; + +/* A provider without a store hook: the app callback owns the chain, and is told + * nothing was stored; the registration proceeds to the hub. */ +static void dps_without_a_store_hook_reports_not_supported_to_the_app(void** state) +{ + (void)state; + + fake_csr_provider prov = { 0 }; + prov.base.vtable = &k_fake_csr_vtable_no_store; + az_iot_connection_client client; + uint8_t csr_buf[AZ_IOT_CSR_PAYLOAD_BUFFER_MIN]; + drive_dps_csr_assignment(&client, csr_buf, sizeof(csr_buf), &prov, true); + + assert_int_equal(g_dps_op_cert_count, 1); + assert_int_equal(g_dps_op_cert_store_result, AZ_IOT_ERR_NOT_SUPPORTED); + assert_int_equal( + az_iot_connection_client_get_state(&client, AZ_IOT_CONN_SCOPE_HUB), + AZ_IOT_CONN_STATE_CONNECTING); + + az_iot_connection_client_deinit(&client); +} + +/* No store hook and no callback: nothing can take the chain, so the + * registration fails. */ +static void dps_without_a_store_hook_or_callback_fails_the_registration(void** state) +{ + (void)state; + + fake_csr_provider prov = { 0 }; + prov.base.vtable = &k_fake_csr_vtable_no_store; + az_iot_connection_client client; + uint8_t csr_buf[AZ_IOT_CSR_PAYLOAD_BUFFER_MIN]; + drive_dps_csr_assignment(&client, csr_buf, sizeof(csr_buf), &prov, false); + + assert_int_equal(g_dps_op_cert_count, 0); + assert_int_equal( + az_iot_connection_client_get_state(&client, AZ_IOT_CONN_SCOPE_HUB), AZ_IOT_CONN_STATE_IDLE); az_iot_connection_client_deinit(&client); } @@ -1691,6 +1799,265 @@ static void send_csr_error_reports_service_code(void** state) assert_int_equal((int)tc.retry_after_s, 5); } +/* ---- Hub renewal: storing the issued chain (use_issued_certificates) ---- */ + +/* Serves BOOTSTRAP until a store succeeds, then OPERATIONAL; paths let a test + * see which identity a CONNECT used. */ +typedef struct renew_provider +{ + az_iot_certificate_provider base; + int store_calls; + char stored_leaf[64]; + az_iot_result store_rc; + bool has_operational; +} renew_provider; + +static az_iot_result renew_load( + az_iot_certificate_provider* s, + az_iot_cert_role role, + az_iot_certificate_material* out) +{ + renew_provider* p = (renew_provider*)s; + memset(out, 0, sizeof(*out)); + if (role == AZ_IOT_CRED_OPERATIONAL) + { + if (!p->has_operational) + { + return AZ_IOT_ERR_NOT_FOUND; + } + out->client_cert_path = "operational-cert.pem"; + out->client_key_path = "operational-key.pem"; + } + else + { + out->client_cert_path = "bootstrap-cert.pem"; + out->client_key_path = "bootstrap-key.pem"; + } + return AZ_IOT_OK; +} + +static az_iot_result renew_store( + az_iot_certificate_provider* s, + const az_iot_issued_certificate* issued) +{ + renew_provider* p = (renew_provider*)s; + p->store_calls++; + az_span leaf = issued->certificates[0]; + size_t n = (size_t)az_span_size(leaf); + n = n < sizeof(p->stored_leaf) - 1 ? n : sizeof(p->stored_leaf) - 1; + memcpy(p->stored_leaf, az_span_ptr(leaf), n); + p->stored_leaf[n] = '\0'; + if (p->store_rc == AZ_IOT_OK) + { + p->has_operational = true; + } + return p->store_rc; +} + +static const az_iot_certificate_provider_vtable k_renew_vtable = { + .version = AZ_IOT_CERTIFICATE_PROVIDER_VTABLE_VERSION, + .load = renew_load, + .release = fake_csr_release, + .deinit = fake_csr_destroy, + .store_issued_certificate = renew_store, +}; + +/* v1 vtable: the store slot is beyond what the provider declares, so the client + * must not call it even though it is populated here. */ +static const az_iot_certificate_provider_vtable k_renew_vtable_v1 = { + .version = 1u, + .load = renew_load, + .release = fake_csr_release, + .deinit = fake_csr_destroy, + .store_issued_certificate = renew_store, +}; + +typedef struct renew_cb_ctx +{ + int issued; + size_t issued_count; + az_iot_result store_status; + int store_calls_at_cb; + renew_provider* prov; +} renew_cb_ctx; + +static void on_renew_evt(const az_iot_csr_event* evt, void* uc) +{ + renew_cb_ctx* t = (renew_cb_ctx*)uc; + if (evt->kind == AZ_IOT_CSR_ISSUED) + { + t->issued++; + t->issued_count = evt->issued ? evt->issued->count : 0; + t->store_status = evt->store_status; + t->store_calls_at_cb = t->prov->store_calls; + } +} + +typedef struct renew_fixture +{ + az_iot_connection_client client; + az_iot_mqtt_factory* factory; + az_iot_mock_mqtt_client* hub; + uint8_t csr_buf[AZ_IOT_CSR_PAYLOAD_BUFFER_MIN]; +} renew_fixture; + +/* Connect a direct-hub client over @p prov, run send_csr() to a 200 with a + * two-cert chain, and return with the session still up. */ +static void renew_connect_and_issue( + renew_fixture* rf, + renew_provider* prov, + bool use_issued_certificates, + renew_cb_ctx* tc) +{ + az_iot_connection_client_options opts = az_iot_connection_client_options_default(); + opts.reconnection_policy = az_iot_reconnection_policy_get_retry_disabled(); + opts.host = "broker.example"; + opts.client_id = "ut-device"; + opts.certificate_provider = &prov->base; + opts.csr_payload_buffer = az_span_create(rf->csr_buf, sizeof(rf->csr_buf)); + opts.use_issued_certificates = use_issued_certificates; + assert_int_equal(az_iot_connection_client_init(&rf->client, &opts), AZ_IOT_OK); + + rf->factory = az_iot_mock_mqtt_factory_create(AZ_IOT_MQTT_VERSION_3_1_1); + assert_non_null(rf->factory); + assert_int_equal( + az_iot_connection_client_register_mqtt_factory(&rf->client, rf->factory), AZ_IOT_OK); + assert_int_equal(az_iot_connection_client_open(&rf->client), AZ_IOT_OK); + rf->hub = az_iot_mock_mqtt_factory_last_client(rf->factory); + assert_non_null(rf->hub); + const az_iot_mock_call* connect + = az_iot_mock_mqtt_client_last_of(rf->hub, AZ_IOT_MOCK_CALL_CONNECT); + assert_non_null(connect); + assert_string_equal(connect->connect.client_cert_path, "bootstrap-cert.pem"); + assert_true(az_iot_mock_mqtt_client_inject_connected(rf->hub, AZ_IOT_OK)); + (void)az_iot_connection_client_do_work(&rf->client, 0); + assert_int_equal( + az_iot_connection_client_get_state(&rf->client, AZ_IOT_CONN_SCOPE_HUB), + AZ_IOT_CONN_STATE_CONNECTED); + + tc->prov = prov; + tc->store_calls_at_cb = -1; + az_iot_certificate_signing_request csr = { .csr_base64 = "TESTCSR==" }; + assert_int_equal( + az_iot_connection_client_send_csr(&rf->client, &csr, "req-renew", NULL, on_renew_evt, tc), + AZ_IOT_OK); + const char* r200 = "{\"correlationId\":\"x\",\"certificates\":[\"TEEF\",\"SU5U\"]}"; + assert_true(az_iot_mock_mqtt_client_inject_message( + rf->hub, + "$iothub/credentials/res/200/?$rid=req-renew", + (const uint8_t*)r200, + strlen(r200), + AZ_IOT_MQTT_QOS_1)); + (void)az_iot_connection_client_do_work(&rf->client, 0); + assert_int_equal(tc->issued, 1); + assert_int_equal((int)tc->issued_count, 2); +} + +/* The live session survives the issuance: no DISCONNECT, still CONNECTED. */ +static void assert_session_untouched(renew_fixture* rf) +{ + assert_null(az_iot_mock_mqtt_client_last_of(rf->hub, AZ_IOT_MOCK_CALL_DISCONNECT)); + assert_int_equal( + az_iot_connection_client_get_state(&rf->client, AZ_IOT_CONN_SCOPE_HUB), + AZ_IOT_CONN_STATE_CONNECTED); +} + +/* close() + open(), and return the client certificate the new CONNECT used. */ +static const char* renew_reconnect(renew_fixture* rf) +{ + assert_int_equal(az_iot_connection_client_close(&rf->client), AZ_IOT_OK); + az_iot_mqtt_event evt = { 0 }; + evt.kind = AZ_IOT_MQTT_EVT_DISCONNECTED; + evt.status = AZ_IOT_OK; + assert_true(az_iot_mock_mqtt_client_inject_event(rf->hub, &evt)); + (void)az_iot_connection_client_do_work(&rf->client, 0); + assert_int_equal( + az_iot_connection_client_get_state(&rf->client, AZ_IOT_CONN_SCOPE_HUB), + AZ_IOT_CONN_STATE_IDLE); + + assert_int_equal(az_iot_connection_client_open(&rf->client), AZ_IOT_OK); + rf->hub = az_iot_mock_mqtt_factory_last_client(rf->factory); + assert_non_null(rf->hub); + const az_iot_mock_call* connect + = az_iot_mock_mqtt_client_last_of(rf->hub, AZ_IOT_MOCK_CALL_CONNECT); + assert_non_null(connect); + return connect->connect.client_cert_path; +} + +static void options_default_enables_use_issued_certificates(void** state) +{ + (void)state; + az_iot_connection_client_options opts = az_iot_connection_client_options_default(); + assert_true(opts.use_issued_certificates); +} + +static void a_hub_issued_chain_is_stored_and_used_on_the_next_connect(void** state) +{ + (void)state; + renew_fixture rf; + renew_provider prov = { .base.vtable = &k_renew_vtable }; + renew_cb_ctx tc = { 0 }; + renew_connect_and_issue(&rf, &prov, true, &tc); + + /* Stored before the callback, which gets the outcome. */ + assert_int_equal(prov.store_calls, 1); + assert_string_equal(prov.stored_leaf, "TEEF"); + assert_int_equal(tc.store_calls_at_cb, 1); + assert_int_equal(tc.store_status, AZ_IOT_OK); + assert_session_untouched(&rf); + + assert_string_equal(renew_reconnect(&rf), "operational-cert.pem"); + az_iot_connection_client_deinit(&rf.client); +} + +/* Staying connected comes first: a failed store is reported, the session is + * kept, and the next connect still uses the previous credential. */ +static void a_failed_store_keeps_the_session_and_the_previous_credential(void** state) +{ + (void)state; + renew_fixture rf; + renew_provider prov = { .base.vtable = &k_renew_vtable, .store_rc = AZ_IOT_ERR_INTERNAL }; + renew_cb_ctx tc = { 0 }; + renew_connect_and_issue(&rf, &prov, true, &tc); + + assert_int_equal(prov.store_calls, 1); + assert_int_equal(tc.store_status, AZ_IOT_ERR_INTERNAL); + assert_session_untouched(&rf); + + assert_string_equal(renew_reconnect(&rf), "bootstrap-cert.pem"); + az_iot_connection_client_deinit(&rf.client); +} + +static void use_issued_certificates_off_leaves_the_chain_to_the_app(void** state) +{ + (void)state; + renew_fixture rf; + renew_provider prov = { .base.vtable = &k_renew_vtable }; + renew_cb_ctx tc = { 0 }; + renew_connect_and_issue(&rf, &prov, false, &tc); + + assert_int_equal(prov.store_calls, 0); + assert_int_equal(tc.store_status, AZ_IOT_ERR_NOT_SUPPORTED); + assert_session_untouched(&rf); + + assert_string_equal(renew_reconnect(&rf), "bootstrap-cert.pem"); + az_iot_connection_client_deinit(&rf.client); +} + +static void a_v1_provider_is_not_asked_to_store_a_hub_issued_chain(void** state) +{ + (void)state; + renew_fixture rf; + renew_provider prov = { .base.vtable = &k_renew_vtable_v1 }; + renew_cb_ctx tc = { 0 }; + renew_connect_and_issue(&rf, &prov, true, &tc); + + assert_int_equal(prov.store_calls, 0); + assert_int_equal(tc.store_status, AZ_IOT_ERR_NOT_SUPPORTED); + assert_session_untouched(&rf); + az_iot_connection_client_deinit(&rf.client); +} + static void send_csr_cancel_frees_slot(void** state) { fixture* fx = *state; @@ -2241,9 +2608,17 @@ int main(void) hub_mqtt_v5_birth_timeout_retries_with_a_new_nonce, setup_mqtt_v5_with_reconnect, teardown), cmocka_unit_test(open_rejects_operational_cert_without_csr_provider), cmocka_unit_test(dps_csr_flow_sends_csr_and_stores_issued_chain), + cmocka_unit_test(dps_store_failure_still_delivers_the_chain_with_the_error), + cmocka_unit_test(dps_without_a_store_hook_reports_not_supported_to_the_app), + cmocka_unit_test(dps_without_a_store_hook_or_callback_fails_the_registration), cmocka_unit_test(open_rejects_operational_cert_without_payload_buffer), cmocka_unit_test_setup_teardown(send_csr_two_phase_delivers_issued_chain, setup, teardown), cmocka_unit_test_setup_teardown(send_csr_error_reports_service_code, setup, teardown), + cmocka_unit_test(options_default_enables_use_issued_certificates), + cmocka_unit_test(a_hub_issued_chain_is_stored_and_used_on_the_next_connect), + cmocka_unit_test(a_failed_store_keeps_the_session_and_the_previous_credential), + cmocka_unit_test(use_issued_certificates_off_leaves_the_chain_to_the_app), + cmocka_unit_test(a_v1_provider_is_not_asked_to_store_a_hub_issued_chain), cmocka_unit_test_setup_teardown(send_csr_cancel_frees_slot, setup, teardown), cmocka_unit_test_setup_teardown( send_csr_subscribes_the_credentials_response_filter, setup, teardown), From b11b689367c05f8a4e055bc86024ad6f385d0ae1 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Mon, 28 Sep 2026 21:26:31 +0000 Subject: [PATCH 02/17] [C] Managed provider: key-match check, owner-only atomic writes - store_issued_certificate() refuses a chain whose leaf does not certify the operational key; init ignores a stored chain that does not. - Key and chain are written to a uniquely named, exclusively created file next to the destination (0600 / owner-only DACL), flushed, then renamed over it. A link at the destination is replaced, not followed. - Tests for each; POSIX-only for permissions and links. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- c/adapters/cert_openssl/CMakeLists.txt | 4 + .../az_iot_certificate_provider_managed.c | 274 +++++++++++++----- .../az_iot_certificate_provider_managed.h | 17 +- c/docs/test-coverage.md | 38 +-- .../unit/certificate_provider_managed_test.c | 222 +++++++++++++- 5 files changed, 453 insertions(+), 102 deletions(-) diff --git a/c/adapters/cert_openssl/CMakeLists.txt b/c/adapters/cert_openssl/CMakeLists.txt index d50ae7e2d..c83baa517 100644 --- a/c/adapters/cert_openssl/CMakeLists.txt +++ b/c/adapters/cert_openssl/CMakeLists.txt @@ -40,5 +40,9 @@ target_link_libraries(az_iot_certificate_provider_managed PRIVATE OpenSSL::Crypto ) +if(WIN32) + # ConvertStringSecurityDescriptorToSecurityDescriptorA (owner-only files). + target_link_libraries(az_iot_certificate_provider_managed PRIVATE advapi32) +endif() az_iot_apply_warnings(az_iot_certificate_provider_managed) az_iot_apply_coverage(az_iot_certificate_provider_managed) diff --git a/c/adapters/cert_openssl/az_iot_certificate_provider_managed.c b/c/adapters/cert_openssl/az_iot_certificate_provider_managed.c index 6d8217445..e2856341b 100644 --- a/c/adapters/cert_openssl/az_iot_certificate_provider_managed.c +++ b/c/adapters/cert_openssl/az_iot_certificate_provider_managed.c @@ -6,10 +6,16 @@ /* OpenSSL 3.0+ implementation of the "managed" certificate provider (D5). * * Uses only public OpenSSL 3.0 APIs: EVP_PKEY_Q_keygen for key generation, - * the X509_REQ_* family for PKCS#10, and BIO for file I/O (so this file needs - * no CRT fopen and stays clean under MSVC /W4 /WX). */ + * the X509_REQ_* family for PKCS#10, and BIO for reads. Writes are built in a + * memory BIO and written with OS calls (write_file_private), so no CRT FILE* + * crosses the OpenSSL boundary and the file stays clean under MSVC /W4 /WX. */ +#if !defined(_WIN32) +#define _POSIX_C_SOURCE 200809L +#endif + #include "az_iot_certificate_provider_managed.h" +#include #include #include #include @@ -20,6 +26,10 @@ #define WIN32_LEAN_AND_MEAN #endif #include +#include +#else +#include +#include #endif #include @@ -56,8 +66,16 @@ /* Encoded length (excluding NUL) of base64 over `binary_len` bytes. */ #define BASE64_ENCODED_LEN(binary_len) ((((binary_len) + 2) / 3) * 4) -/* Suffix of the temporary file a new operational cert is staged in. */ -#define MANAGED_TMP_SUFFIX ".tmp" +#ifdef _WIN32 +/* Owner-only DACL for written files: full access for the owner and SYSTEM, + * protected from inheriting the directory's ACEs. */ +#define MANAGED_PRIVATE_SDDL "D:P(A;;FA;;;OW)(A;;FA;;;SY)" +/* Attempts at a unique temporary name before giving up. */ +#define MANAGED_TMP_ATTEMPTS 16 +#else +/* mkstemp() template appended to the destination path. */ +#define MANAGED_TMP_TEMPLATE ".XXXXXX" +#endif /* -------------------------------------------------------------------------- * Small helpers @@ -101,52 +119,185 @@ static EVP_PKEY* load_key_file(const char* path) return k; } -static az_iot_result write_key_file(const char* path, EVP_PKEY* key) +#ifdef _WIN32 +/** + * @brief Create a new file with an owner-only DACL, failing if it exists. + * @return The open handle, or INVALID_HANDLE_VALUE (errors other than a name + * collision leave *collided false). + */ +static HANDLE create_private_file(const char* path, bool* collided) { - BIO* b = BIO_new_file(path, "wb"); - if (!b) + *collided = false; + PSECURITY_DESCRIPTOR sd = NULL; + if (!ConvertStringSecurityDescriptorToSecurityDescriptorA( + MANAGED_PRIVATE_SDDL, SDDL_REVISION_1, &sd, NULL)) + { + return INVALID_HANDLE_VALUE; + } + SECURITY_ATTRIBUTES sa = { (DWORD)sizeof(sa), sd, FALSE }; + HANDLE h = CreateFileA(path, GENERIC_WRITE, 0, &sa, CREATE_NEW, FILE_ATTRIBUTE_NORMAL, NULL); + if (h == INVALID_HANDLE_VALUE) + { + DWORD err = GetLastError(); + *collided = (err == ERROR_FILE_EXISTS || err == ERROR_ALREADY_EXISTS); + } + LocalFree(sd); + return h; +} +#endif + +/** + * @brief Replace @p path with @p data, all-or-nothing. + * + * Writes a new file next to @p path that only the current user can access + * (0600 / owner-only DACL), created exclusively under a unique name so an + * existing file or link there is never opened, flushes it, then renames it over + * @p path. The rename replaces a link at @p path rather than following it. On + * failure @p path is untouched and the temporary file is removed. + */ +static az_iot_result write_file_private(const char* path, const char* data, size_t len) +{ + size_t path_len = strlen(path); +#ifdef _WIN32 + /* ".<8 hex>.tmp" */ + char* tmp = (char*)malloc(path_len + 14); + if (!tmp) + { + return AZ_IOT_ERR_OUT_OF_MEMORY; + } + HANDLE h = INVALID_HANDLE_VALUE; + unsigned long seed = (unsigned long)GetTickCount() ^ ((unsigned long)GetCurrentProcessId() << 16) + ^ (unsigned long)(uintptr_t)tmp; + for (int i = 0; i < MANAGED_TMP_ATTEMPTS && h == INVALID_HANDLE_VALUE; ++i) { + seed = seed * 1103515245UL + 12345UL; + (void)snprintf(tmp, path_len + 14, "%s.%08lx.tmp", path, seed & 0xFFFFFFFFUL); + bool collided = false; + h = create_private_file(tmp, &collided); + if (h == INVALID_HANDLE_VALUE && !collided) + { + break; + } + } + if (h == INVALID_HANDLE_VALUE) + { + free(tmp); return AZ_IOT_ERR_INTERNAL; } - int ok = PEM_write_bio_PrivateKey(b, key, NULL, NULL, 0, NULL, NULL); - BIO_free(b); - return (ok == 1) ? AZ_IOT_OK : AZ_IOT_ERR_INTERNAL; + bool ok = true; + size_t off = 0; + while (ok && off < len) + { + DWORD chunk = (len - off) > 0x40000000u ? 0x40000000u : (DWORD)(len - off); + DWORD written = 0; + ok = WriteFile(h, data + off, chunk, &written, NULL) && written > 0; + off += written; + } + ok = ok && FlushFileBuffers(h); + ok = CloseHandle(h) && ok; + ok = ok && MoveFileExA(tmp, path, MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH); + if (!ok) + { + (void)DeleteFileA(tmp); + } +#else + char* tmp = (char*)malloc(path_len + sizeof(MANAGED_TMP_TEMPLATE)); + if (!tmp) + { + return AZ_IOT_ERR_OUT_OF_MEMORY; + } + memcpy(tmp, path, path_len); + memcpy(tmp + path_len, MANAGED_TMP_TEMPLATE, sizeof(MANAGED_TMP_TEMPLATE)); + int fd = mkstemp(tmp); /* O_CREAT|O_EXCL, mode 0600 */ + if (fd < 0) + { + free(tmp); + return AZ_IOT_ERR_INTERNAL; + } + bool ok = true; + size_t off = 0; + while (ok && off < len) + { + ssize_t n = write(fd, data + off, len - off); + if (n < 0 && errno == EINTR) + { + continue; + } + ok = n > 0; + off += ok ? (size_t)n : 0; + } + ok = ok && fsync(fd) == 0; + ok = (close(fd) == 0) && ok; + ok = ok && rename(tmp, path) == 0; + if (!ok) + { + (void)unlink(tmp); + } +#endif + free(tmp); + return ok ? AZ_IOT_OK : AZ_IOT_ERR_INTERNAL; } -static bool operational_cert_is_valid(const char* path) +/** + * @brief Write the contents of memory BIO @p mem to @p path (write_file_private), + * then wipe the BIO's buffer. + */ +static az_iot_result write_bio_private(const char* path, BIO* mem) { - /* Treat the operational cert as present only when the file holds at least one - * parseable PEM certificate. A zero-length or partially-written file (e.g. a - * crash mid-write, or a pre-created empty file) must NOT be mistaken for a - * usable identity, or TLS would later fail to load a valid chain. */ - BIO* b = BIO_new_file(path, "rb"); - if (!b) + char* data = NULL; + long len = BIO_get_mem_data(mem, &data); + if (len <= 0 || !data) { - ERR_clear_error(); - return false; + return AZ_IOT_ERR_INTERNAL; } - X509* cert = PEM_read_bio_X509(b, NULL, NULL, NULL); - BIO_free(b); - if (!cert) + az_iot_result rc = write_file_private(path, data, (size_t)len); + OPENSSL_cleanse(data, (size_t)len); + return rc; +} + +static az_iot_result write_key_file(const char* path, EVP_PKEY* key) +{ + BIO* mem = BIO_new(BIO_s_mem()); + if (!mem) { - ERR_clear_error(); - return false; + return AZ_IOT_ERR_OUT_OF_MEMORY; } - X509_free(cert); - return true; + az_iot_result rc = (PEM_write_bio_PrivateKey(mem, key, NULL, NULL, 0, NULL, NULL) == 1) + ? write_bio_private(path, mem) + : AZ_IOT_ERR_INTERNAL; + BIO_free(mem); + return rc; } /** - * @brief Replace @p to with @p from in one step, overwriting @p to if present. - * @return true on success. + * @brief True when the first PEM certificate in @p b parses and certifies @p key. */ -static bool replace_file(const char* from, const char* to) +static bool leaf_matches_key(BIO* b, EVP_PKEY* key) { -#ifdef _WIN32 - return MoveFileExA(from, to, MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH) != 0; -#else - return rename(from, to) == 0; -#endif + X509* leaf = PEM_read_bio_X509(b, NULL, NULL, NULL); + bool ok = leaf != NULL && key != NULL && X509_check_private_key(leaf, key) == 1; + X509_free(leaf); + ERR_clear_error(); + return ok; +} + +/** + * @brief True when @p path holds a certificate chain whose leaf certifies @p key. + * + * An empty, partial or unparseable file, or one issued for a different key, + * is not a usable identity: TLS would fail with it on every connect. + */ +static bool operational_cert_is_valid(const char* path, EVP_PKEY* key) +{ + BIO* b = BIO_new_file(path, "rb"); + if (!b) + { + ERR_clear_error(); + return false; + } + bool ok = leaf_matches_key(b, key); + BIO_free(b); + return ok; } /* -------------------------------------------------------------------------- @@ -416,25 +567,16 @@ static az_iot_result managed_store( return AZ_IOT_ERR_INVALID_ARG; } - /* All-or-nothing: write and validate a temporary file, then replace the - * operational cert with it, so a failure leaves the previous one in use. */ - size_t path_len = strlen(m->operational_cert_path); - char* tmp_path = (char*)malloc(path_len + sizeof(MANAGED_TMP_SUFFIX)); - if (!tmp_path) + /* All-or-nothing: build the chain in memory, refuse it unless its leaf + * certifies the operational key, then replace the file in one step. A + * failure leaves the previous certificate in use. */ + BIO* mem = BIO_new(BIO_s_mem()); + if (!mem) { return AZ_IOT_ERR_OUT_OF_MEMORY; } - memcpy(tmp_path, m->operational_cert_path, path_len); - memcpy(tmp_path + path_len, MANAGED_TMP_SUFFIX, sizeof(MANAGED_TMP_SUFFIX)); - BIO* b = BIO_new_file(tmp_path, "wb"); - if (!b) - { - free(tmp_path); - return AZ_IOT_ERR_INTERNAL; - } - - /* Write each issued cert (leaf first) into the temporary file. */ + /* Write each issued cert (leaf first). */ az_iot_result rc = AZ_IOT_OK; for (size_t i = 0; i < issued->count; ++i) { @@ -444,31 +586,29 @@ static az_iot_result managed_store( { continue; } - if (!write_issued_cert(b, az_span_ptr(cert), len)) + if (!write_issued_cert(mem, az_span_ptr(cert), len)) { rc = AZ_IOT_ERR_INTERNAL; break; } } - if (BIO_flush(b) != 1) - { - rc = AZ_IOT_ERR_INTERNAL; - } - BIO_free(b); - if (rc == AZ_IOT_OK && !operational_cert_is_valid(tmp_path)) - { - rc = AZ_IOT_ERR_INVALID_ARG; - } - if (rc == AZ_IOT_OK && !replace_file(tmp_path, m->operational_cert_path)) + if (rc == AZ_IOT_OK) { - rc = AZ_IOT_ERR_INTERNAL; + char* data = NULL; + long data_len = BIO_get_mem_data(mem, &data); + BIO* check = (data_len > 0) ? BIO_new_mem_buf(data, (int)data_len) : NULL; + if (!check || !leaf_matches_key(check, (EVP_PKEY*)m->operational_key)) + { + rc = AZ_IOT_ERR_INVALID_ARG; + } + BIO_free(check); } - if (rc != AZ_IOT_OK) + if (rc == AZ_IOT_OK) { - (void)remove(tmp_path); + rc = write_bio_private(m->operational_cert_path, mem); } - free(tmp_path); + BIO_free(mem); if (rc == AZ_IOT_OK) { @@ -574,9 +714,9 @@ az_iot_result az_iot_certificate_provider_managed_init( } provider->operational_key = key; - /* An operational cert persisted by a previous run means we can connect - * with the OPERATIONAL identity immediately (no re-enrollment needed). */ - provider->has_operational = operational_cert_is_valid(provider->operational_cert_path); + /* An operational cert persisted by a previous run, issued for this key, means + * we can connect with the OPERATIONAL identity immediately (no re-enrollment). */ + provider->has_operational = operational_cert_is_valid(provider->operational_cert_path, key); provider->loaded = true; return AZ_IOT_OK; diff --git a/c/adapters/cert_openssl/az_iot_certificate_provider_managed.h b/c/adapters/cert_openssl/az_iot_certificate_provider_managed.h index a784cf181..facb0b9f7 100644 --- a/c/adapters/cert_openssl/az_iot_certificate_provider_managed.h +++ b/c/adapters/cert_openssl/az_iot_certificate_provider_managed.h @@ -12,8 +12,14 @@ * - produces PKCS#10 CSRs over that operational key (get_csr); * - persists the DPS/Hub-issued operational certificate chain to disk * (store_issued_certificate) and serves it back on subsequent loads and - * process restarts. A store replaces the file only once the new chain is - * written and parses, so a failed store keeps the previous certificate. + * process restarts. A store is refused unless the chain parses and its + * leaf certifies the operational key, and replaces the file in one step, so + * a failed store keeps the previous certificate. + * + * Files it writes (key and chain) are created readable only by the current user + * (0600 on POSIX, owner-only DACL on Windows), under a unique temporary name + * next to the destination, then renamed over it. The private key is stored + * unencrypted: protect the directory accordingly. * * This is the reference implementation of the CSR provider contract. Deployments * with a TPM/HSM/secure element should implement their own provider with a @@ -48,11 +54,12 @@ extern "C" /* Trusted CA presented to both bootstrap and operational connections. */ const char* trusted_ca_pem_path; /* may be NULL */ /* Operational private key. Loaded if the file exists, otherwise a new key - * is generated and written here (PEM). Required. */ + * is generated and written here (unencrypted PEM, owner-only). The mode of + * an existing file is not changed. Required. */ const char* operational_key_pem_path; /* required */ /* Where the issued operational certificate chain is persisted. Written by - * store_issued_certificate() via ".tmp"; read back on load() and on - * restart. Required. */ + * store_issued_certificate(); read back on load() and on restart, and used + * only if its leaf certifies the operational key. Required. */ const char* operational_cert_pem_path; /* required */ /* Key type used only when generating a new operational key. */ az_iot_certificate_managed_key_type key_type; diff --git a/c/docs/test-coverage.md b/c/docs/test-coverage.md index 1d09274d2..d721fde5f 100644 --- a/c/docs/test-coverage.md +++ b/c/docs/test-coverage.md @@ -842,24 +842,28 @@ runtime renewal against the connected hub over `$iothub/credentials/...` per | | An operational load returns the same static material | This provider holds one identity and ignores the role; the connect path is what falls back to bootstrap. | unit | Done | [test_load_of_an_operational_credential_returns_the_static_material](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_pem_test.c#L247) | | | Deinit through the vtable destroys the provider | — | unit | Done | [test_deinit_through_the_vtable_destroys_the_provider](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_pem_test.c#L296) | | | Init fails when a named CA cannot be read | The CA path is optional, but connecting without the trust anchor the operator asked for is worse than not connecting. | unit | Done | [test_create_fails_when_the_named_ca_is_missing](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_pem_test.c#L331) | -| Managed provider | Init generates a key | — | unit | Done | [managed_init_generates_key_and_valid_csr](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L90) | -| | Init emits a parseable CSR | — | unit | Done | [managed_init_generates_key_and_valid_csr](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L90) | -| | The CSR subject carries the registration id | — | unit | Done | [managed_init_generates_key_and_valid_csr](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L90) | -| | Init rejects bad args | — | unit | Done | [managed_init_rejects_bad_args](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L206) | -| | Store persists the issued chain | — | unit | Done | [managed_store_persists_and_survives_restart](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L151) | -| | The persisted chain reloads after a restart | — | unit | Done | [managed_store_persists_and_survives_restart](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L151) | +| Managed provider | Init generates a key | — | unit | Done | [managed_init_generates_key_and_valid_csr](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L105) | +| | Init emits a parseable CSR | — | unit | Done | [managed_init_generates_key_and_valid_csr](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L105) | +| | The CSR subject carries the registration id | — | unit | Done | [managed_init_generates_key_and_valid_csr](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L105) | +| | Init rejects bad args | — | unit | Done | [managed_init_rejects_bad_args](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L221) | +| | Store persists the issued chain | — | unit | Done | [managed_store_persists_and_survives_restart](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L166) | +| | The persisted chain reloads after a restart | — | unit | Done | [managed_store_persists_and_survives_restart](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L166) | | | A CSR buffer that is too small is reported | — | unit | Pending | *certificate_provider_managed_test.c* | -| | A stored chain that is not a certificate is refused | The store validates the chain before replacing the file; nothing is written and no operational identity is claimed. | unit | Done | [managed_a_stored_chain_that_is_not_a_certificate_is_refused](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L453) | -| | A failed store keeps the previous certificate | All-or-nothing, in-process and after a restart, so a failed renewal cannot break the next connect. | unit | Done | [managed_a_failed_store_keeps_the_previous_certificate](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L483) | -| | Store overwrites a previously issued chain | Renewal replaces rather than appends. | unit | Done | [managed_store_overwrites_a_previously_issued_chain](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L422) | -| | The sign hook is not offered by either bundled provider | Neither provider sets `sign`, so the connect path must keep checking it for NULL. Pinned so that adding an implementation is a deliberate act. | unit | Done | [the_sign_hook_is_not_offered_by_this_provider](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L447) | -| | An operational load with no stored chain returns not found | The connect path uses this code to decide it must still enroll. Belongs to the managed provider: the PEM loader ignores the role. | unit | Done | [managed_operational_load_without_a_stored_chain_is_not_found](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L261) | -| | Load rejects null arguments | — | unit | Done | [managed_load_rejects_null_arguments](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L240) | -| | Release leaves the material usable | The paths are borrowed from the provider struct, so freeing them here would dangle what the connection is still using. | unit | Done | [managed_release_leaves_the_material_usable](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L281) | -| | Get CSR rejects null arguments | — | unit | Done | [managed_get_csr_rejects_null_arguments](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L303) | -| | Store rejects an issuance with no certificates | Accepting an empty chain would flip the operational flag with nothing on disk. | unit | Done | [managed_store_rejects_null_arguments](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L320) | -| | Deinit tolerates null | — | unit | Done | [managed_deinit_tolerates_null](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L348) | -| | Deinit through the vtable destroys the provider | What a generic owner of an `az_iot_certificate_provider` calls. | unit | Done | [managed_deinit_through_the_vtable_destroys_the_provider](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L356) | +| | A stored chain that is not a certificate is refused | The store validates the chain before replacing the file; nothing is written and no operational identity is claimed. | unit | Done | [managed_a_stored_chain_that_is_not_a_certificate_is_refused](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L504) | +| | A failed store keeps the previous certificate | All-or-nothing, in-process and after a restart, so a failed renewal cannot break the next connect. | unit | Done | [managed_a_failed_store_keeps_the_previous_certificate](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L534) | +| | A chain for another key is refused | The leaf must certify the operational key; the previous chain is kept. | unit | Done | [managed_store_refuses_a_chain_for_another_key](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L572) | +| | A persisted chain for another key is not used | After the key is replaced, the stored chain no longer certifies it. | unit | Done | [managed_a_persisted_chain_for_another_key_is_not_used](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L607) | +| | Written files are owner-only | Key and chain are 0600 whatever the umask. POSIX only; the Windows owner-only DACL is not tested. | unit | Done | [managed_written_files_are_owner_only](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L647) | +| | A link at a destination is replaced, not followed | Key and chain. POSIX only. | unit | Done | [managed_writes_replace_a_link_instead_of_following_it](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L670) | +| | Store overwrites a previously issued chain | Renewal replaces rather than appends. | unit | Done | [managed_store_overwrites_a_previously_issued_chain](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L473) | +| | The sign hook is not offered by either bundled provider | Neither provider sets `sign`, so the connect path must keep checking it for NULL. Pinned so that adding an implementation is a deliberate act. | unit | Done | [the_sign_hook_is_not_offered_by_this_provider](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L712) | +| | An operational load with no stored chain returns not found | The connect path uses this code to decide it must still enroll. Belongs to the managed provider: the PEM loader ignores the role. | unit | Done | [managed_operational_load_without_a_stored_chain_is_not_found](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L276) | +| | Load rejects null arguments | — | unit | Done | [managed_load_rejects_null_arguments](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L255) | +| | Release leaves the material usable | The paths are borrowed from the provider struct, so freeing them here would dangle what the connection is still using. | unit | Done | [managed_release_leaves_the_material_usable](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L296) | +| | Get CSR rejects null arguments | — | unit | Done | [managed_get_csr_rejects_null_arguments](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L318) | +| | Store rejects an issuance with no certificates | Accepting an empty chain would flip the operational flag with nothing on disk. | unit | Done | [managed_store_rejects_null_arguments](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L335) | +| | Deinit tolerates null | — | unit | Done | [managed_deinit_tolerates_null](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L363) | +| | Deinit through the vtable destroys the provider | What a generic owner of an `az_iot_certificate_provider` calls. | unit | Done | [managed_deinit_through_the_vtable_destroys_the_provider](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L371) | | Issuance via DPS | The CSR rides the DPS registration body | — | unit | Done | [dps_csr_flow_sends_csr_and_stores_issued_chain](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1532) | | | The issued chain is handed to the provider | — | unit | Done | [dps_csr_flow_sends_csr_and_stores_issued_chain](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1532) | | | Open is refused without a CSR-capable provider | — | unit | Done | [open_rejects_operational_cert_without_csr_provider](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L899) | diff --git a/c/tests/unit/certificate_provider_managed_test.c b/c/tests/unit/certificate_provider_managed_test.c index 72f26bf8d..d22d92505 100644 --- a/c/tests/unit/certificate_provider_managed_test.c +++ b/c/tests/unit/certificate_provider_managed_test.c @@ -5,6 +5,10 @@ /* SPDX-License-Identifier: MIT */ /* Tests for the OpenSSL-backed "managed" certificate provider (D5). Links * OpenSSL only to decode and validate the CSR the provider produces. */ +#if !defined(_WIN32) +#define _POSIX_C_SOURCE 200809L +#endif + #include #include #include @@ -14,6 +18,17 @@ #include #include +#if defined(_WIN32) +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#endif +#include +#else +#include +#include +#include +#endif + #include "az_iot_certificate_provider_managed.h" #include @@ -49,19 +64,19 @@ static X509_REQ* decode_csr(const char* b64) return req; } -/* Build a real, self-signed certificate as base64 DER (heap; caller frees) - the - * on-the-wire form the store hook receives. The provider PEM-wraps it, so the - * persistence test still exercises the provider's parse-based validity check. */ -static char* make_self_signed_cert_base64(void) +/* Build a certificate for @p subject_key, signed by a throwaway issuer key, as + * base64 DER (heap; caller frees) - the on-the-wire form the store hook + * receives. @p serial makes two certificates for the same key differ. */ +static char* make_cert_base64(EVP_PKEY* subject_key, long serial) { EVP_PKEY* key = EVP_PKEY_Q_keygen(NULL, NULL, "EC", "P-256"); assert_non_null(key); X509* x = X509_new(); assert_non_null(x); - ASN1_INTEGER_set(X509_get_serialNumber(x), 1); + ASN1_INTEGER_set(X509_get_serialNumber(x), serial); X509_gmtime_adj(X509_getm_notBefore(x), 0); X509_gmtime_adj(X509_getm_notAfter(x), 3600); - assert_int_equal(1, X509_set_pubkey(x, key)); + assert_int_equal(1, X509_set_pubkey(x, subject_key)); X509_NAME* name = X509_NAME_new(); assert_non_null(name); X509_NAME_add_entry_by_txt( @@ -164,7 +179,7 @@ static void managed_store_persists_and_survives_restart(void** state) /* A real cert as base64 DER (the wire form). The provider PEM-wraps it; the * restart check then rejects empty/garbage, so the chain must be valid. */ - char* cert_b64 = make_self_signed_cert_base64(); + char* cert_b64 = make_cert_base64((EVP_PKEY*)prov.operational_key, 1); az_span chain[2] = { az_span_create_from_str(cert_b64), az_span_create_from_str(cert_b64) }; az_iot_issued_certificate issued = { .certificates = chain, @@ -417,6 +432,42 @@ static bool file_exists(const char* path) return false; } +/* Temporary files the provider left next to OP_CERT or OP_KEY. */ +static int count_temp_files(void) +{ + int n = 0; +#if defined(_WIN32) + static const char* const patterns[] = { OP_CERT ".*", OP_KEY ".*" }; + for (size_t i = 0; i < sizeof(patterns) / sizeof(patterns[0]); ++i) + { + WIN32_FIND_DATAA fd; + HANDLE h = FindFirstFileA(patterns[i], &fd); + if (h != INVALID_HANDLE_VALUE) + { + do + { + n++; + } while (FindNextFileA(h, &fd)); + FindClose(h); + } + } +#else + DIR* d = opendir("."); + assert_non_null(d); + struct dirent* e; + while ((e = readdir(d)) != NULL) + { + if (strncmp(e->d_name, OP_CERT ".", strlen(OP_CERT ".")) == 0 + || strncmp(e->d_name, OP_KEY ".", strlen(OP_KEY ".")) == 0) + { + n++; + } + } + closedir(d); +#endif + return n; +} + /* Renewal replaces the identity rather than appending to it: a file that * accumulated every chain ever issued would present a stale leaf on connect. */ static void managed_store_overwrites_a_previously_issued_chain(void** state) @@ -427,8 +478,8 @@ static void managed_store_overwrites_a_previously_issued_chain(void** state) az_iot_certificate_provider_managed prov; assert_int_equal(AZ_IOT_OK, az_iot_certificate_provider_managed_init(&prov, &opts)); - char* first = make_self_signed_cert_base64(); - char* second = make_self_signed_cert_base64(); + char* first = make_cert_base64((EVP_PKEY*)prov.operational_key, 1); + char* second = make_cert_base64((EVP_PKEY*)prov.operational_key, 2); assert_string_not_equal(first, second); az_span chain1[1] = { az_span_create_from_str(first) }; @@ -441,7 +492,7 @@ static void managed_store_overwrites_a_previously_issued_chain(void** state) char* on_disk = read_chain_base64(OP_CERT); assert_string_equal(on_disk, second); - assert_false(file_exists(OP_CERT ".tmp")); + assert_int_equal(0, count_temp_files()); free(on_disk); free(first); @@ -467,7 +518,7 @@ static void managed_a_stored_chain_that_is_not_a_certificate_is_refused(void** s assert_int_equal( AZ_IOT_ERR_NOT_FOUND, prov.base.vtable->load(&prov.base, AZ_IOT_CRED_OPERATIONAL, &mat)); assert_false(file_exists(OP_CERT)); - assert_false(file_exists(OP_CERT ".tmp")); + assert_int_equal(0, count_temp_files()); az_iot_certificate_provider_managed_deinit(&prov); az_iot_certificate_provider_managed prov2; @@ -488,7 +539,7 @@ static void managed_a_failed_store_keeps_the_previous_certificate(void** state) az_iot_certificate_provider_managed prov; assert_int_equal(AZ_IOT_OK, az_iot_certificate_provider_managed_init(&prov, &opts)); - char* good = make_self_signed_cert_base64(); + char* good = make_cert_base64((EVP_PKEY*)prov.operational_key, 1); az_span chain1[1] = { az_span_create_from_str(good) }; az_iot_issued_certificate issued1 = { .certificates = chain1, .count = 1 }; assert_int_equal(AZ_IOT_OK, prov.base.vtable->store_issued_certificate(&prov.base, &issued1)); @@ -503,7 +554,7 @@ static void managed_a_failed_store_keeps_the_previous_certificate(void** state) assert_string_equal(OP_CERT, mat.client_cert_path); char* on_disk = read_chain_base64(OP_CERT); assert_string_equal(on_disk, good); - assert_false(file_exists(OP_CERT ".tmp")); + assert_int_equal(0, count_temp_files()); az_iot_certificate_provider_managed_deinit(&prov); az_iot_certificate_provider_managed prov2; @@ -516,6 +567,145 @@ static void managed_a_failed_store_keeps_the_previous_certificate(void** state) remove_test_files(); } +/* The leaf must certify the operational key: a chain issued for another key + * would fail every connect, so it is refused and the previous one kept. */ +static void managed_store_refuses_a_chain_for_another_key(void** state) +{ + (void)state; + remove_test_files(); + az_iot_certificate_provider_managed_options opts = test_options(); + az_iot_certificate_provider_managed prov; + assert_int_equal(AZ_IOT_OK, az_iot_certificate_provider_managed_init(&prov, &opts)); + + char* good = make_cert_base64((EVP_PKEY*)prov.operational_key, 1); + az_span chain1[1] = { az_span_create_from_str(good) }; + az_iot_issued_certificate issued1 = { .certificates = chain1, .count = 1 }; + assert_int_equal(AZ_IOT_OK, prov.base.vtable->store_issued_certificate(&prov.base, &issued1)); + + EVP_PKEY* other = EVP_PKEY_Q_keygen(NULL, NULL, "EC", "P-256"); + assert_non_null(other); + char* foreign = make_cert_base64(other, 2); + az_span chain2[2] = { az_span_create_from_str(foreign), az_span_create_from_str(good) }; + az_iot_issued_certificate issued2 = { .certificates = chain2, .count = 2 }; + assert_int_equal( + AZ_IOT_ERR_INVALID_ARG, prov.base.vtable->store_issued_certificate(&prov.base, &issued2)); + + char* on_disk = read_chain_base64(OP_CERT); + assert_string_equal(on_disk, good); + assert_int_equal(0, count_temp_files()); + az_iot_certificate_provider_managed_deinit(&prov); + + free(on_disk); + free(foreign); + free(good); + EVP_PKEY_free(other); + remove_test_files(); +} + +/* A chain on disk that no longer matches the key (the key was replaced) is not + * served as the operational identity after a restart. */ +static void managed_a_persisted_chain_for_another_key_is_not_used(void** state) +{ + (void)state; + remove_test_files(); + az_iot_certificate_provider_managed_options opts = test_options(); + az_iot_certificate_provider_managed prov; + assert_int_equal(AZ_IOT_OK, az_iot_certificate_provider_managed_init(&prov, &opts)); + char* good = make_cert_base64((EVP_PKEY*)prov.operational_key, 1); + az_span chain[1] = { az_span_create_from_str(good) }; + az_iot_issued_certificate issued = { .certificates = chain, .count = 1 }; + assert_int_equal(AZ_IOT_OK, prov.base.vtable->store_issued_certificate(&prov.base, &issued)); + az_iot_certificate_provider_managed_deinit(&prov); + + /* Lose the key: init generates a new one, which the stored chain does not certify. */ + assert_int_equal(0, remove(OP_KEY)); + az_iot_certificate_provider_managed prov2; + assert_int_equal(AZ_IOT_OK, az_iot_certificate_provider_managed_init(&prov2, &opts)); + assert_false(prov2.has_operational); + az_iot_certificate_material mat; + memset(&mat, 0, sizeof(mat)); + assert_int_equal( + AZ_IOT_ERR_NOT_FOUND, prov2.base.vtable->load(&prov2.base, AZ_IOT_CRED_OPERATIONAL, &mat)); + az_iot_certificate_provider_managed_deinit(&prov2); + + free(good); + remove_test_files(); +} + +#if !defined(_WIN32) +#define LINK_TARGET "az_iot_managed_test_link_target.pem" +#define LINK_TARGET_KEY "az_iot_managed_test_link_target_key.pem" + +static mode_t file_mode(const char* path) +{ + struct stat st; + assert_int_equal(0, lstat(path, &st)); + return st.st_mode; +} + +/* The key and chain are readable only by their owner, whatever the umask. */ +static void managed_written_files_are_owner_only(void** state) +{ + (void)state; + remove_test_files(); + mode_t old_umask = umask(022); + az_iot_certificate_provider_managed_options opts = test_options(); + az_iot_certificate_provider_managed prov; + assert_int_equal(AZ_IOT_OK, az_iot_certificate_provider_managed_init(&prov, &opts)); + assert_int_equal(0600, file_mode(OP_KEY) & 0777); + + char* good = make_cert_base64((EVP_PKEY*)prov.operational_key, 1); + az_span chain[1] = { az_span_create_from_str(good) }; + az_iot_issued_certificate issued = { .certificates = chain, .count = 1 }; + assert_int_equal(AZ_IOT_OK, prov.base.vtable->store_issued_certificate(&prov.base, &issued)); + assert_int_equal(0600, file_mode(OP_CERT) & 0777); + az_iot_certificate_provider_managed_deinit(&prov); + + (void)umask(old_umask); + free(good); + remove_test_files(); +} + +/* A link at either destination is replaced, never written through. */ +static void managed_writes_replace_a_link_instead_of_following_it(void** state) +{ + (void)state; + remove_test_files(); + (void)remove(LINK_TARGET); + (void)remove(LINK_TARGET_KEY); + FILE* v = fopen(LINK_TARGET, "wb"); + assert_non_null(v); + assert_int_equal(6, (int)fwrite("target", 1, 6, v)); + assert_int_equal(0, fclose(v)); + assert_int_equal(0, symlink(LINK_TARGET, OP_CERT)); + assert_int_equal(0, symlink(LINK_TARGET_KEY, OP_KEY)); /* dangling: init generates a key */ + + az_iot_certificate_provider_managed_options opts = test_options(); + az_iot_certificate_provider_managed prov; + assert_int_equal(AZ_IOT_OK, az_iot_certificate_provider_managed_init(&prov, &opts)); + assert_true(S_ISREG(file_mode(OP_KEY))); + assert_false(file_exists(LINK_TARGET_KEY)); + + char* good = make_cert_base64((EVP_PKEY*)prov.operational_key, 1); + az_span chain[1] = { az_span_create_from_str(good) }; + az_iot_issued_certificate issued = { .certificates = chain, .count = 1 }; + assert_int_equal(AZ_IOT_OK, prov.base.vtable->store_issued_certificate(&prov.base, &issued)); + assert_true(S_ISREG(file_mode(OP_CERT))); + az_iot_certificate_provider_managed_deinit(&prov); + + char buf[16] = { 0 }; + v = fopen(LINK_TARGET, "rb"); + assert_non_null(v); + assert_int_equal(6, (int)fread(buf, 1, sizeof(buf) - 1, v)); + assert_int_equal(0, fclose(v)); + assert_string_equal(buf, "target"); + + free(good); + (void)remove(LINK_TARGET); + remove_test_files(); +} +#endif + /* Neither bundled provider implements the optional sign() hook, so the connect * path must keep checking it for NULL before calling it. Pinning that here * makes adding an implementation a deliberate act rather than a surprise. */ @@ -549,6 +739,12 @@ int main(void) cmocka_unit_test(managed_store_overwrites_a_previously_issued_chain), cmocka_unit_test(managed_a_stored_chain_that_is_not_a_certificate_is_refused), cmocka_unit_test(managed_a_failed_store_keeps_the_previous_certificate), + cmocka_unit_test(managed_store_refuses_a_chain_for_another_key), + cmocka_unit_test(managed_a_persisted_chain_for_another_key_is_not_used), +#if !defined(_WIN32) + cmocka_unit_test(managed_written_files_are_owner_only), + cmocka_unit_test(managed_writes_replace_a_link_instead_of_following_it), +#endif cmocka_unit_test(the_sign_hook_is_not_offered_by_this_provider), }; return cmocka_run_group_tests(tests, NULL, NULL); From 4142e0e6cb53d8adcccbb3fd733b5899fa811415 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Mon, 28 Sep 2026 21:41:17 +0000 Subject: [PATCH 03/17] [C] Managed provider test: exclude the real files from the Windows temp-file count Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- c/tests/unit/certificate_provider_managed_test.c | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/c/tests/unit/certificate_provider_managed_test.c b/c/tests/unit/certificate_provider_managed_test.c index d22d92505..b64a06a75 100644 --- a/c/tests/unit/certificate_provider_managed_test.c +++ b/c/tests/unit/certificate_provider_managed_test.c @@ -446,7 +446,11 @@ static int count_temp_files(void) { do { - n++; + /* "name.*" also matches "name" itself. */ + if (strcmp(fd.cFileName, OP_CERT) != 0 && strcmp(fd.cFileName, OP_KEY) != 0) + { + n++; + } } while (FindNextFileA(h, &fd)); FindClose(h); } From 080fbbabd41b92661fb06204cb07e9faa5da5b05 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Mon, 28 Sep 2026 22:35:48 +0000 Subject: [PATCH 04/17] [C] Hub renewal: the key owner owns the chain Replace opts.use_issued_certificates with ownership from the CSR source: - send_csr(csr = NULL): the client takes the CSR from the provider's get_csr() (subject = device id) and stores the issued chain with store_issued_certificate(). Requires a v2 provider with both hooks, else AZ_IOT_ERR_NOT_SUPPORTED before anything is published. - send_csr(csr): the application owns the key; the chain is delivered, never stored (store_status AZ_IOT_ERR_NOT_SUPPORTED). hub_renew sample uses send_csr(NULL). Tests and docs updated. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- c/docs/connection.md | 9 +- c/docs/eng/certificate-management.md | 13 +- c/docs/eng/connection-c.md | 9 +- c/docs/test-coverage.md | 10 +- c/inc/azure/iot/az_iot_connection_client.h | 69 ++++--- c/samples/authentication/README.md | 7 +- c/samples/authentication/hub_renew/main.c | 28 ++- c/src/core/connection_client.c | 95 ++++++++-- c/tests/unit/connection_client_test.c | 198 ++++++++++++++++----- 9 files changed, 295 insertions(+), 143 deletions(-) diff --git a/c/docs/connection.md b/c/docs/connection.md index f1f6de375..ccc847892 100644 --- a/c/docs/connection.md +++ b/c/docs/connection.md @@ -445,7 +445,7 @@ flowchart TD J --> K{credentials response} K -->|202 accepted| L["CSR_ACCEPTED callback,
keep waiting"] L --> K - K -->|200 issued| M["store_issued_certificate
(use_issued_certificates), then
CSR_ISSUED callback with chain + store_status"] + K -->|200 issued| M["store_issued_certificate
(provider-made CSR only), then
CSR_ISSUED callback with chain + store_status"] K -->|"error or 120s timeout"| N["CSR_FAILED callback,
slot released"] M --> O["New certificate is used on
the NEXT connect attempt"] ``` @@ -465,9 +465,10 @@ Rules that apply to both clients: callback — the application must copy it to use it afterwards. - The DPS operational-cert callback fires whether or not the store succeeded, with its result. A failed DPS store fails the registration. -- Hub renewal: with `opts.use_issued_certificates` (default on) the client stores the chain in the - provider before `CSR_ISSUED`, reported in `store_status`. If the store fails the session stays up - and the previous credential stays in use. Off: the application owns the chain. +- Hub renewal: whoever owns the key owns the chain. `send_csr(csr = NULL)` takes the CSR from the + provider's `get_csr()` and stores the chain with `store_issued_certificate()` before `CSR_ISSUED`, + reported in `store_status`; if the store fails the session stays up and the previous credential + stays in use. An application-supplied CSR is never stored: the application owns the chain. - `store_issued_certificate()` must be all-or-nothing: on failure the provider keeps serving the previous credential. - A successful renewal does **not** tear down the live session. The new credential takes effect on diff --git a/c/docs/eng/certificate-management.md b/c/docs/eng/certificate-management.md index 7a7e26e8c..1539c1c84 100644 --- a/c/docs/eng/certificate-management.md +++ b/c/docs/eng/certificate-management.md @@ -355,14 +355,15 @@ az_iot_result az_iot_connection_client_send_csr( void* user_ctx); ``` -With `opts.use_issued_certificates` (default on), the client stores the new chain in the -provider before `AZ_IOT_CSR_ISSUED` (result in `evt->store_status`); the next connect uses it. -The live session is kept, and a failed store keeps the previous credential. +Who owns the key owns the chain. With `csr` NULL, the client takes the CSR from the provider's +`get_csr()` and stores the new chain there before `AZ_IOT_CSR_ISSUED` (result in +`evt->store_status`); the next connect uses it. An application-supplied CSR is never stored. The +live session is kept, and a failed store keeps the previous credential. Two integration options, mirroring the two ownership models: -- **App-owned (C# style):** app supplies `csr` bytes, receives the chain in the callback, - swaps certs, and calls `close()` / `open()` — explicit, no provider needed. -- **Provider-owned (this design):** the app takes the CSR from `get_csr()`; the client calls +- **App-owned (C# style):** app supplies `csr` bytes and receives the chain in the callback; the + client never stores it. The app installs it in its provider and reconnects. +- **Provider-owned (this design):** `csr` NULL; the client calls `get_csr()` and `store_issued_certificate()` and the next connect re-`load()`s, so renewal is transparent (same seam as the DPS path). diff --git a/c/docs/eng/connection-c.md b/c/docs/eng/connection-c.md index 24e2e6b27..90c88d8ae 100644 --- a/c/docs/eng/connection-c.md +++ b/c/docs/eng/connection-c.md @@ -527,7 +527,7 @@ flowchart TD J --> K{credentials response} K -->|202 accepted| L["CSR_ACCEPTED callback,
keep waiting"] L --> K - K -->|200 issued| M["store_issued_certificate
(use_issued_certificates), then
CSR_ISSUED callback with chain + store_status"] + K -->|200 issued| M["store_issued_certificate
(provider-made CSR only), then
CSR_ISSUED callback with chain + store_status"] K -->|"error or 120s timeout"| N["CSR_FAILED callback,
slot released"] M --> O["New certificate is used on
the NEXT connect attempt"] ``` @@ -548,9 +548,10 @@ Rules every client must implement. The C client meets all of them; where .NET do callback — the application must copy it to use it afterwards. - The DPS operational-cert callback fires whether or not the store succeeded, with its result. A failed DPS store fails the registration. -- Hub renewal: with `opts.use_issued_certificates` (default on) the client stores the chain in the - provider before `CSR_ISSUED`, reported in `store_status`. If the store fails the session stays up - and the previous credential stays in use. Off: the application owns the chain. +- Hub renewal: whoever owns the key owns the chain. `send_csr(csr = NULL)` takes the CSR from the + provider's `get_csr()` and stores the chain with `store_issued_certificate()` before `CSR_ISSUED`, + reported in `store_status`; if the store fails the session stays up and the previous credential + stays in use. An application-supplied CSR is never stored: the application owns the chain. - `store_issued_certificate()` must be all-or-nothing: on failure the provider keeps serving the previous credential. - A successful renewal does **not** tear down the live session. The new credential takes effect on diff --git a/c/docs/test-coverage.md b/c/docs/test-coverage.md index d721fde5f..576c63b8b 100644 --- a/c/docs/test-coverage.md +++ b/c/docs/test-coverage.md @@ -887,11 +887,11 @@ runtime renewal against the connected hub over `$iothub/credentials/...` per | | A 412 reports the service code | No matching request to replace. | unit | Done | [a_412_reports_the_service_code](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1581) | | | A 429 surfaces the retry-after hint | The response body carries `retryAfter`. | unit | Done | [a_429_surfaces_the_retry_after_hint](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1607) | | | A malformed issued chain reports a protocol error | 200 whose body has no `certificates` array. | unit | Done | [a_200_without_a_certificates_array_reports_a_failure](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1635) | -| | `use_issued_certificates` defaults to on | — | unit | Done | [options_default_enables_use_issued_certificates](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1987) | -| | An issued chain is stored and used on the next connect | Stored before the callback; the live session is not torn down. | unit | Done | [a_hub_issued_chain_is_stored_and_used_on_the_next_connect](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1994) | -| | A failed store keeps the session and the previous credential | Reported in `store_status`. | unit | Done | [a_failed_store_keeps_the_session_and_the_previous_credential](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2015) | -| | `use_issued_certificates` off leaves the chain to the app | — | unit | Done | [use_issued_certificates_off_leaves_the_chain_to_the_app](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2031) | -| | A v1 provider is not asked to store | The store slot is past a v1 vtable. | unit | Done | [a_v1_provider_is_not_asked_to_store_a_hub_issued_chain](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2047) | +| | A provider-made CSR's chain is stored and used on the next connect | `send_csr(NULL)`: CSR from `get_csr()` (subject = device id); stored before the callback; session kept. | unit | Done | [a_provider_csr_chain_is_stored_and_used_on_the_next_connect](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2045) | +| | A failed store keeps the session and the previous credential | Reported in `store_status`. | unit | Done | [a_failed_store_keeps_the_session_and_the_previous_credential](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2072) | +| | An application CSR's chain is not stored | The application owns the key and the chain. | unit | Done | [an_application_csr_chain_is_not_stored](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2092) | +| | Send CSR without a CSR needs a provider that can renew | No provider, a v1 provider, or no `get_csr`: refused before publishing; slot stays free. | unit | Done | [send_csr_without_a_csr_needs_a_provider_that_can_renew](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2127) | +| | A failed get_csr fails send CSR | Nothing published. | unit | Done | [a_failed_get_csr_fails_send_csr](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2147) | | | A renewal with no response times out | `CSR_OP_TIMEOUT_MS` elapses. | unit | Pending | *connection_client_test.c* | | | A 202 extends the timeout deadline | A slow-but-alive signer is not abandoned. | unit | Pending | *connection_client_test.c* | | | Send CSR while disconnected is refused | — | unit | Done | [send_csr_while_disconnected_is_refused](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1661) | diff --git a/c/inc/azure/iot/az_iot_connection_client.h b/c/inc/azure/iot/az_iot_connection_client.h index 2f9395cbb..ab4c4f9c3 100644 --- a/c/inc/azure/iot/az_iot_connection_client.h +++ b/c/inc/azure/iot/az_iot_connection_client.h @@ -565,27 +565,6 @@ extern "C" * disconnected session is also bounded by its queue (100 messages / 1 MB) -- * overflowing that destroys the session regardless of this value. */ uint32_t session_expiry_seconds; - - /** - * @brief Store a certificate issued by az_iot_connection_client_send_csr() - * in #certificate_provider, so the next hub connect uses it. - * - * When true, on AZ_IOT_CSR_ISSUED the client calls the provider's - * store_issued_certificate() before the CSR callback, and reports the result - * in az_iot_csr_event.store_status. The live session is never torn down; the - * new certificate takes effect on the next connect (reconnect, or close() + - * open()). If storing fails, the client stays connected and keeps using the - * previous credential. - * - * When false, the application owns the issued chain. - * - * Hub renewal (mqttv3) only. DPS enrollment always stores (see - * dps.request_operational_certificate). - * - * az_iot_connection_client_options_default() sets true; a zeroed struct has - * false. - */ - bool use_issued_certificates; } az_iot_connection_client_options; /* Which of the client's two lifecycles something refers to. @@ -749,9 +728,9 @@ extern "C" /** * @brief ISSUED only: outcome of storing the chain in the certificate provider. * - * AZ_IOT_OK: stored; the next hub connect uses it. - * AZ_IOT_ERR_NOT_SUPPORTED: not stored (opts.use_issued_certificates is - * false, or the provider has no store_issued_certificate()). + * AZ_IOT_OK: stored; subsequent hub connects use it. + * AZ_IOT_ERR_NOT_SUPPORTED: not stored, because the application supplied + * the CSR and so owns the chain. * Other: the provider's failure; the previous credential stays in use. */ az_iot_result store_status; @@ -1280,6 +1259,7 @@ extern "C" bool in_use; bool subscribed; char request_id[64]; + bool store_issued; /* CSR came from the provider, so the chain is stored there */ az_iot_csr_callback cb; void* user_ctx; uint64_t deadline_ms; /* abandon the op if no terminal response by here */ @@ -1467,20 +1447,33 @@ extern "C" az_iot_connection_client* client, uint32_t timeout_ms); - /* Request a renewed operational certificate from the connected (MQTTv3) hub by - * sending a CSR. Two-phase: the callback fires with AZ_IOT_CSR_ACCEPTED (202), - * then AZ_IOT_CSR_ISSUED (200) carrying the new chain, or AZ_IOT_CSR_FAILED. - * request_id: NULL => the SDK generates one; pass a prior id to resubmit. - * replace: NULL, or "*" / a request id to supersede an active hub-side op. - * The request's device id is taken from the connected client_id. Only one CSR - * operation may be in flight; returns AZ_IOT_ERR_BUSY otherwise. The issued - * chain in AZ_IOT_CSR_ISSUED is valid only for the duration of the callback. - * With opts.use_issued_certificates, the chain is stored in the certificate - * provider before that callback (result in evt->store_status) and used on the - * next hub connect; the live session is not torn down. - * If no terminal (200/error) response arrives within an internal timeout, the - * callback fires once with AZ_IOT_CSR_FAILED / AZ_IOT_ERR_TIMEOUT and the slot - * is released, so a lost response can never wedge renewal permanently. */ + /** + * @brief Request a renewed operational certificate from the connected MQTTv3 + * hub. + * + * Two-phase: @p cb fires with AZ_IOT_CSR_ACCEPTED (202), then AZ_IOT_CSR_ISSUED + * (200) carrying the new chain, or AZ_IOT_CSR_FAILED. If no final response + * arrives within an internal timeout, @p cb fires once with AZ_IOT_CSR_FAILED / + * AZ_IOT_ERR_TIMEOUT and the slot is released. The chain is valid only during + * @p cb. The live session is never torn down. + * + * Who owns the key decides who owns the chain: + * - @p csr NULL: the client takes the CSR from opts.certificate_provider's + * get_csr() (subject = device id) and, on ISSUED, stores the chain with its + * store_issued_certificate() before @p cb (result in evt->store_status). The + * following hub connect uses it; if the store fails, the previous credential + * stays in use. The provider must implement both hooks (vtable v2). + * - @p csr non-NULL: the application made the CSR and owns the key, so the + * client never stores the chain (store_status AZ_IOT_ERR_NOT_SUPPORTED). + * + * @param csr NULL, or an application CSR (base64 DER, at most 8 KB). + * @param request_id NULL to generate one, or a prior id to resubmit. With + * @p csr NULL, a resubmit carries a new CSR from get_csr(). + * @param replace NULL, or "*" / a request id to supersede an active hub-side op. + * @return AZ_IOT_ERR_BUSY while an operation is in flight; + * AZ_IOT_ERR_NOT_SUPPORTED when @p csr is NULL and the provider cannot produce + * and store a CSR, or on an MQTTv5 session. + */ AZ_NODISCARD az_iot_result az_iot_connection_client_send_csr( az_iot_connection_client* client, const az_iot_certificate_signing_request* csr, diff --git a/c/samples/authentication/README.md b/c/samples/authentication/README.md index ee6351831..84607b4aa 100644 --- a/c/samples/authentication/README.md +++ b/c/samples/authentication/README.md @@ -70,10 +70,9 @@ adapter. Setup, run and troubleshooting: [dps_csr_managed/README.md](dps_csr_man ### `hub_renew` Runtime operational-certificate renewal against a connected MQTTv3 hub (not -available on MQTTv5 hubs): builds -a fresh CSR from the managed provider, calls `az_iot_connection_client_send_csr()`, -and reconnects with the renewed chain, which the client stores in the provider -(`opts.use_issued_certificates`). Requires the managed provider and Paho. +available on MQTTv5 hubs): calls `az_iot_connection_client_send_csr()` with no CSR, so +the client takes it from the managed provider and stores the renewed chain there, +then reconnects with it. Requires the managed provider and Paho. ### `hsm_pkcs11` Provisions through DPS and connects with a device key that never leaves a diff --git a/c/samples/authentication/hub_renew/main.c b/c/samples/authentication/hub_renew/main.c index e2253566e..edfdad295 100644 --- a/c/samples/authentication/hub_renew/main.c +++ b/c/samples/authentication/hub_renew/main.c @@ -9,11 +9,11 @@ * (D7). Connects to whichever hub DPS assigns; renewal through send_csr() is * MQTTv3-only, so on an MQTTv5 hub the sample reports that and exits non-zero. * End to end: - * 1. Connect, then produce a fresh CSR from the managed provider and call - * az_iot_connection_client_send_csr(). + * 1. Connect, then call az_iot_connection_client_send_csr() with no CSR: the + * client takes it from the managed provider. * 2. The hub responds in two phases - ACCEPTED (202) then ISSUED (200) with - * the new chain. The client stores it in the provider - * (opts.use_issued_certificates, on by default) and reports the outcome. + * the new chain. Because the provider made the CSR, the client stores the + * chain there and reports the outcome. * 3. Apply the renewed cert by reconnecting: close + reopen re-establishes the * session with the new operational identity (a new client certificate * requires a fresh TLS handshake). Without this, the next reconnect @@ -84,7 +84,7 @@ static void on_csr_event(const az_iot_csr_event* evt, void* user_ctx) stderr, "[hub_renew] renewed chain issued: %zu cert(s)\n", evt->issued ? evt->issued->count : (size_t)0); - /* The client already stored it in the provider (use_issued_certificates). */ + /* The provider made the CSR, so the client already stored the chain there. */ fprintf(stderr, "[hub_renew] stored in provider: status=%d\n", (int)evt->store_status); ctx->csr_status = evt->store_status; ctx->csr_done = 1; @@ -187,20 +187,14 @@ int main(void) } else if (user_ctx.conn_state == AZ_IOT_CONN_STATE_CONNECTED) { - /* Produce a CSR from the operational key and request renewal. */ - az_iot_certificate_signing_request csr = { 0 }; - if (provider.base.vtable->get_csr(&provider.base, config.reg_id, &csr) == AZ_IOT_OK) + /* No CSR: the client takes it from the provider, which keeps the key. */ + if (az_iot_connection_client_send_csr( + &connection_client, NULL, NULL, NULL, on_csr_event, &user_ctx) + == AZ_IOT_OK) { - az_iot_result send_rc = az_iot_connection_client_send_csr( - &connection_client, &csr, NULL, NULL, on_csr_event, &user_ctx); - provider.base.vtable->release_csr(&provider.base, &csr); - - if (send_rc == AZ_IOT_OK) + for (int i = 0; i < 1200 && !user_ctx.csr_done; ++i) { - for (int i = 0; i < 1200 && !user_ctx.csr_done; ++i) - { - (void)az_iot_connection_client_do_work(&connection_client, 50); - } + (void)az_iot_connection_client_do_work(&connection_client, 50); } } } diff --git a/c/src/core/connection_client.c b/c/src/core/connection_client.c index 8012271c9..60493ca01 100644 --- a/c/src/core/connection_client.c +++ b/c/src/core/connection_client.c @@ -3360,7 +3360,6 @@ az_iot_connection_client_options az_iot_connection_client_options_default(void) opts.reconnection_policy = az_iot_reconnection_policy_get_default(); opts.dps.max_hub_connect_attempts_before_reprovision = AZ_IOT_DEFAULT_MAX_HUB_CONNECT_ATTEMPTS_BEFORE_REPROVISION; - opts.use_issued_certificates = true; return opts; } @@ -5392,20 +5391,29 @@ static void csr_parse_error(az_span payload, int32_t* out_code, int32_t* out_ret } /** - * @brief Store a hub-issued chain in the certificate provider when - * opts.use_issued_certificates is set. Never touches the live session: on - * failure the previous credential stays in use. + * @brief True when @p p can both produce a CSR and store its chain (vtable v2). + */ +static bool provider_owns_renewal(const az_iot_certificate_provider* p) +{ + return p != NULL && p->vtable != NULL && p->vtable->version >= CERT_PROVIDER_VTABLE_V2 + && p->vtable->get_csr != NULL && p->vtable->release_csr != NULL + && p->vtable->store_issued_certificate != NULL; +} + +/** + * @brief Store a hub-issued chain in the certificate provider when the CSR came + * from it. Never touches the live session: on failure the previous credential + * stays in use. * - * @return AZ_IOT_OK if stored, AZ_IOT_ERR_NOT_SUPPORTED if not attempted, else - * the provider's error. + * @return AZ_IOT_OK if stored, AZ_IOT_ERR_NOT_SUPPORTED if the application owns + * the chain, else the provider's error. */ static az_iot_result csr_store_issued( az_iot_connection_client* c, const az_iot_issued_certificate* issued) { az_iot_certificate_provider* p = c->opts.certificate_provider; - if (!c->opts.use_issued_certificates || !p || !p->vtable - || p->vtable->version < CERT_PROVIDER_VTABLE_V2 || !p->vtable->store_issued_certificate) + if (!c->csr_op.store_issued || !provider_owns_renewal(p)) { return AZ_IOT_ERR_NOT_SUPPORTED; } @@ -5554,6 +5562,13 @@ static void on_csr_response(void* user_ctx, const az_iot_mqtt_message* msg) } } +static az_iot_result csr_publish( + az_iot_connection_client* client, + const az_iot_certificate_signing_request* csr, + const char* device_id, + const char* request_id, + const char* replace); + az_iot_result az_iot_connection_client_send_csr( az_iot_connection_client* client, const az_iot_certificate_signing_request* csr, @@ -5562,7 +5577,7 @@ az_iot_result az_iot_connection_client_send_csr( az_iot_csr_callback cb, void* user_ctx) { - if (!client || !csr || !csr->csr_base64 || !cb) + if (!client || (csr && !csr->csr_base64) || !cb) { return AZ_IOT_ERR_INVALID_ARG; } @@ -5572,7 +5587,7 @@ az_iot_result az_iot_connection_client_send_csr( } if (client->session_role != AZ_IOT_MQTT_ROLE_HUB_MQTT_V3) { - return AZ_IOT_ERR_NOT_SUPPORTED; /* MQTTv5 path not defined yet */ + return AZ_IOT_ERR_NOT_SUPPORTED; /* MQTTv5 renewal path not defined yet */ } if (client->csr_op.in_use) { @@ -5583,6 +5598,59 @@ az_iot_result az_iot_connection_client_send_csr( return AZ_IOT_ERR_NOT_ENOUGH_SPACE; /* caller must provide opts.csr_payload_buffer */ } + const char* device_id = az_iot_connection_client__device_id(client); + if (!device_id) + { + device_id = ""; + } + + /* No CSR from the application: the provider makes it and will own the chain. */ + az_iot_certificate_provider* provider = client->opts.certificate_provider; + az_iot_certificate_signing_request provider_csr = { 0 }; + const bool from_provider = (csr == NULL); + if (from_provider) + { + if (!provider_owns_renewal(provider)) + { + AZ_IOT_LOG_ERROR("send_csr: no CSR given and the certificate provider cannot produce and " + "store one"); + return AZ_IOT_ERR_NOT_SUPPORTED; + } + az_iot_result gr = provider->vtable->get_csr(provider, device_id, &provider_csr); + if (gr != AZ_IOT_OK || !provider_csr.csr_base64) + { + AZ_IOT_LOG_ERROR("send_csr: certificate provider get_csr failed"); + if (gr == AZ_IOT_OK) + { + provider->vtable->release_csr(provider, &provider_csr); + } + return (gr != AZ_IOT_OK) ? gr : AZ_IOT_ERR_INTERNAL; + } + csr = &provider_csr; + } + + client->csr_op.store_issued = from_provider; + client->csr_op.cb = cb; + client->csr_op.user_ctx = user_ctx; + az_iot_result r = csr_publish(client, csr, device_id, request_id, replace); + if (from_provider) + { + provider->vtable->release_csr(provider, &provider_csr); + } + return r; +} + +/** + * @brief Validate @p csr, then subscribe (once), build and publish the renewal + * request, and open the in-flight slot. + */ +static az_iot_result csr_publish( + az_iot_connection_client* client, + const az_iot_certificate_signing_request* csr, + const char* device_id, + const char* request_id, + const char* replace) +{ /* Validate the CSR: base64 and within the 8 KB service cap. */ size_t csr_len = 0; if (!az_iot_cert_util_is_base64(csr->csr_base64, &csr_len) || csr_len > CSR_MAX_BASE64) @@ -5626,11 +5694,6 @@ az_iot_result az_iot_connection_client_send_csr( } /* Build the request body into the caller-provided payload buffer. */ - const char* device_id = az_iot_connection_client__device_id(client); - if (!device_id) - { - device_id = ""; - } char* body = (char*)az_span_ptr(client->opts.csr_payload_buffer); size_t body_len = 0; const char* body_parts[] @@ -5664,8 +5727,6 @@ az_iot_result az_iot_connection_client_send_csr( msg.payload_len = body_len; msg.qos = AZ_IOT_MQTT_QOS_1; - client->csr_op.cb = cb; - client->csr_op.user_ctx = user_ctx; client->csr_op.in_use = true; client->csr_op.deadline_ms = az_iot_time_mono_ms() + CSR_OP_TIMEOUT_MS; diff --git a/c/tests/unit/connection_client_test.c b/c/tests/unit/connection_client_test.c index ef871d878..8b9dbf887 100644 --- a/c/tests/unit/connection_client_test.c +++ b/c/tests/unit/connection_client_test.c @@ -1799,13 +1799,17 @@ static void send_csr_error_reports_service_code(void** state) assert_int_equal((int)tc.retry_after_s, 5); } -/* ---- Hub renewal: storing the issued chain (use_issued_certificates) ---- */ +/* ---- Hub renewal: who owns the key owns the chain ---- */ /* Serves BOOTSTRAP until a store succeeds, then OPERATIONAL; paths let a test * see which identity a CONNECT used. */ typedef struct renew_provider { az_iot_certificate_provider base; + int get_csr_calls; + int release_csr_calls; + char csr_subject[64]; + az_iot_result get_csr_rc; int store_calls; char stored_leaf[64]; az_iot_result store_rc; @@ -1836,6 +1840,33 @@ static az_iot_result renew_load( return AZ_IOT_OK; } +static az_iot_result renew_get_csr( + az_iot_certificate_provider* s, + const char* cn, + az_iot_certificate_signing_request* out) +{ + renew_provider* p = (renew_provider*)s; + p->get_csr_calls++; + size_t n = strlen(cn); + n = n < sizeof(p->csr_subject) - 1 ? n : sizeof(p->csr_subject) - 1; + memcpy(p->csr_subject, cn, n); + p->csr_subject[n] = '\0'; + if (p->get_csr_rc != AZ_IOT_OK) + { + return p->get_csr_rc; + } + out->csr_base64 = "UFJPVkNTUg=="; + return AZ_IOT_OK; +} + +static void renew_release_csr( + az_iot_certificate_provider* s, + az_iot_certificate_signing_request* csr) +{ + ((renew_provider*)s)->release_csr_calls++; + csr->csr_base64 = NULL; +} + static az_iot_result renew_store( az_iot_certificate_provider* s, const az_iot_issued_certificate* issued) @@ -1855,6 +1886,17 @@ static az_iot_result renew_store( } static const az_iot_certificate_provider_vtable k_renew_vtable = { + .version = AZ_IOT_CERTIFICATE_PROVIDER_VTABLE_VERSION, + .load = renew_load, + .release = fake_csr_release, + .deinit = fake_csr_destroy, + .get_csr = renew_get_csr, + .release_csr = renew_release_csr, + .store_issued_certificate = renew_store, +}; + +/* Can store but not produce a CSR. */ +static const az_iot_certificate_provider_vtable k_renew_vtable_no_csr = { .version = AZ_IOT_CERTIFICATE_PROVIDER_VTABLE_VERSION, .load = renew_load, .release = fake_csr_release, @@ -1862,13 +1904,15 @@ static const az_iot_certificate_provider_vtable k_renew_vtable = { .store_issued_certificate = renew_store, }; -/* v1 vtable: the store slot is beyond what the provider declares, so the client - * must not call it even though it is populated here. */ +/* v1 vtable: the CSR and store slots are beyond what the provider declares, so + * the client must not call them even though they are populated here. */ static const az_iot_certificate_provider_vtable k_renew_vtable_v1 = { .version = 1u, .load = renew_load, .release = fake_csr_release, .deinit = fake_csr_destroy, + .get_csr = renew_get_csr, + .release_csr = renew_release_csr, .store_issued_certificate = renew_store, }; @@ -1889,7 +1933,7 @@ static void on_renew_evt(const az_iot_csr_event* evt, void* uc) t->issued++; t->issued_count = evt->issued ? evt->issued->count : 0; t->store_status = evt->store_status; - t->store_calls_at_cb = t->prov->store_calls; + t->store_calls_at_cb = t->prov ? t->prov->store_calls : -1; } } @@ -1901,21 +1945,16 @@ typedef struct renew_fixture uint8_t csr_buf[AZ_IOT_CSR_PAYLOAD_BUFFER_MIN]; } renew_fixture; -/* Connect a direct-hub client over @p prov, run send_csr() to a 200 with a - * two-cert chain, and return with the session still up. */ -static void renew_connect_and_issue( - renew_fixture* rf, - renew_provider* prov, - bool use_issued_certificates, - renew_cb_ctx* tc) +/* Connect a direct-hub client over @p prov (may be NULL) and return with the + * session up. */ +static void renew_connect(renew_fixture* rf, renew_provider* prov) { az_iot_connection_client_options opts = az_iot_connection_client_options_default(); opts.reconnection_policy = az_iot_reconnection_policy_get_retry_disabled(); opts.host = "broker.example"; opts.client_id = "ut-device"; - opts.certificate_provider = &prov->base; + opts.certificate_provider = prov ? &prov->base : NULL; opts.csr_payload_buffer = az_span_create(rf->csr_buf, sizeof(rf->csr_buf)); - opts.use_issued_certificates = use_issued_certificates; assert_int_equal(az_iot_connection_client_init(&rf->client, &opts), AZ_IOT_OK); rf->factory = az_iot_mock_mqtt_factory_create(AZ_IOT_MQTT_VERSION_3_1_1); @@ -1925,22 +1964,39 @@ static void renew_connect_and_issue( assert_int_equal(az_iot_connection_client_open(&rf->client), AZ_IOT_OK); rf->hub = az_iot_mock_mqtt_factory_last_client(rf->factory); assert_non_null(rf->hub); - const az_iot_mock_call* connect - = az_iot_mock_mqtt_client_last_of(rf->hub, AZ_IOT_MOCK_CALL_CONNECT); - assert_non_null(connect); - assert_string_equal(connect->connect.client_cert_path, "bootstrap-cert.pem"); + if (prov) + { + const az_iot_mock_call* connect + = az_iot_mock_mqtt_client_last_of(rf->hub, AZ_IOT_MOCK_CALL_CONNECT); + assert_non_null(connect); + assert_string_equal(connect->connect.client_cert_path, "bootstrap-cert.pem"); + } assert_true(az_iot_mock_mqtt_client_inject_connected(rf->hub, AZ_IOT_OK)); (void)az_iot_connection_client_do_work(&rf->client, 0); assert_int_equal( az_iot_connection_client_get_state(&rf->client, AZ_IOT_CONN_SCOPE_HUB), AZ_IOT_CONN_STATE_CONNECTED); +} - tc->prov = prov; +/* send_csr(@p csr) on the connected fixture, then answer it with a 200 carrying + * a two-cert chain. Returns the request body that was published. */ +static void renew_issue( + renew_fixture* rf, + renew_cb_ctx* tc, + const az_iot_certificate_signing_request* csr, + char* body, + size_t body_size) +{ tc->store_calls_at_cb = -1; - az_iot_certificate_signing_request csr = { .csr_base64 = "TESTCSR==" }; assert_int_equal( - az_iot_connection_client_send_csr(&rf->client, &csr, "req-renew", NULL, on_renew_evt, tc), + az_iot_connection_client_send_csr(&rf->client, csr, "req-renew", NULL, on_renew_evt, tc), AZ_IOT_OK); + const az_iot_mock_call* pub = az_iot_mock_mqtt_client_last_of(rf->hub, AZ_IOT_MOCK_CALL_PUBLISH); + assert_non_null(pub); + size_t n = pub->payload_len < body_size - 1 ? pub->payload_len : body_size - 1; + memcpy(body, pub->payload, n); + body[n] = '\0'; + const char* r200 = "{\"correlationId\":\"x\",\"certificates\":[\"TEEF\",\"SU5U\"]}"; assert_true(az_iot_mock_mqtt_client_inject_message( rf->hub, @@ -1984,22 +2040,23 @@ static const char* renew_reconnect(renew_fixture* rf) return connect->connect.client_cert_path; } -static void options_default_enables_use_issued_certificates(void** state) -{ - (void)state; - az_iot_connection_client_options opts = az_iot_connection_client_options_default(); - assert_true(opts.use_issued_certificates); -} - -static void a_hub_issued_chain_is_stored_and_used_on_the_next_connect(void** state) +/* No CSR from the app: the provider makes it (subject = device id), and the + * chain is stored there before the callback and used on the next connect. */ +static void a_provider_csr_chain_is_stored_and_used_on_the_next_connect(void** state) { (void)state; renew_fixture rf; renew_provider prov = { .base.vtable = &k_renew_vtable }; - renew_cb_ctx tc = { 0 }; - renew_connect_and_issue(&rf, &prov, true, &tc); + renew_cb_ctx tc = { .prov = &prov }; + char body[256]; + renew_connect(&rf, &prov); + renew_issue(&rf, &tc, NULL, body, sizeof(body)); + + assert_int_equal(prov.get_csr_calls, 1); + assert_int_equal(prov.release_csr_calls, 1); + assert_string_equal(prov.csr_subject, "ut-device"); + assert_non_null(strstr(body, "\"csr\":\"UFJPVkNTUg==\"")); - /* Stored before the callback, which gets the outcome. */ assert_int_equal(prov.store_calls, 1); assert_string_equal(prov.stored_leaf, "TEEF"); assert_int_equal(tc.store_calls_at_cb, 1); @@ -2017,8 +2074,10 @@ static void a_failed_store_keeps_the_session_and_the_previous_credential(void** (void)state; renew_fixture rf; renew_provider prov = { .base.vtable = &k_renew_vtable, .store_rc = AZ_IOT_ERR_INTERNAL }; - renew_cb_ctx tc = { 0 }; - renew_connect_and_issue(&rf, &prov, true, &tc); + renew_cb_ctx tc = { .prov = &prov }; + char body[256]; + renew_connect(&rf, &prov); + renew_issue(&rf, &tc, NULL, body, sizeof(body)); assert_int_equal(prov.store_calls, 1); assert_int_equal(tc.store_status, AZ_IOT_ERR_INTERNAL); @@ -2028,14 +2087,21 @@ static void a_failed_store_keeps_the_session_and_the_previous_credential(void** az_iot_connection_client_deinit(&rf.client); } -static void use_issued_certificates_off_leaves_the_chain_to_the_app(void** state) +/* An application CSR means the application owns the key, so the chain is only + * delivered, never stored. */ +static void an_application_csr_chain_is_not_stored(void** state) { (void)state; renew_fixture rf; renew_provider prov = { .base.vtable = &k_renew_vtable }; - renew_cb_ctx tc = { 0 }; - renew_connect_and_issue(&rf, &prov, false, &tc); - + renew_cb_ctx tc = { .prov = &prov }; + char body[256]; + az_iot_certificate_signing_request csr = { .csr_base64 = "QVBQQ1NS" }; + renew_connect(&rf, &prov); + renew_issue(&rf, &tc, &csr, body, sizeof(body)); + + assert_int_equal(prov.get_csr_calls, 0); + assert_non_null(strstr(body, "\"csr\":\"QVBQQ1NS\"")); assert_int_equal(prov.store_calls, 0); assert_int_equal(tc.store_status, AZ_IOT_ERR_NOT_SUPPORTED); assert_session_untouched(&rf); @@ -2044,17 +2110,53 @@ static void use_issued_certificates_off_leaves_the_chain_to_the_app(void** state az_iot_connection_client_deinit(&rf.client); } -static void a_v1_provider_is_not_asked_to_store_a_hub_issued_chain(void** state) +/* Refused before anything is published, and the slot stays free. */ +static void assert_send_csr_without_csr_refused(renew_fixture* rf, renew_provider* prov) +{ + renew_cb_ctx tc = { .prov = prov }; + assert_int_equal( + az_iot_connection_client_send_csr(&rf->client, NULL, NULL, NULL, on_renew_evt, &tc), + AZ_IOT_ERR_NOT_SUPPORTED); + assert_null(az_iot_mock_mqtt_client_last_of(rf->hub, AZ_IOT_MOCK_CALL_PUBLISH)); + az_iot_certificate_signing_request csr = { .csr_base64 = "QVBQQ1NS" }; + assert_int_equal( + az_iot_connection_client_send_csr(&rf->client, &csr, NULL, NULL, on_renew_evt, &tc), + AZ_IOT_OK); +} + +static void send_csr_without_a_csr_needs_a_provider_that_can_renew(void** state) { (void)state; renew_fixture rf; - renew_provider prov = { .base.vtable = &k_renew_vtable_v1 }; - renew_cb_ctx tc = { 0 }; - renew_connect_and_issue(&rf, &prov, true, &tc); + renew_provider v1 = { .base.vtable = &k_renew_vtable_v1 }; + renew_connect(&rf, &v1); + assert_send_csr_without_csr_refused(&rf, &v1); + assert_int_equal(v1.get_csr_calls, 0); + az_iot_connection_client_deinit(&rf.client); - assert_int_equal(prov.store_calls, 0); - assert_int_equal(tc.store_status, AZ_IOT_ERR_NOT_SUPPORTED); - assert_session_untouched(&rf); + renew_provider no_csr = { .base.vtable = &k_renew_vtable_no_csr }; + renew_connect(&rf, &no_csr); + assert_send_csr_without_csr_refused(&rf, &no_csr); + az_iot_connection_client_deinit(&rf.client); + + renew_connect(&rf, NULL); + assert_send_csr_without_csr_refused(&rf, NULL); + az_iot_connection_client_deinit(&rf.client); +} + +static void a_failed_get_csr_fails_send_csr(void** state) +{ + (void)state; + renew_fixture rf; + renew_provider prov = { .base.vtable = &k_renew_vtable, .get_csr_rc = AZ_IOT_ERR_INTERNAL }; + renew_cb_ctx tc = { .prov = &prov }; + renew_connect(&rf, &prov); + assert_int_equal( + az_iot_connection_client_send_csr(&rf.client, NULL, NULL, NULL, on_renew_evt, &tc), + AZ_IOT_ERR_INTERNAL); + assert_int_equal(prov.get_csr_calls, 1); + assert_int_equal(prov.release_csr_calls, 0); + assert_null(az_iot_mock_mqtt_client_last_of(rf.hub, AZ_IOT_MOCK_CALL_PUBLISH)); az_iot_connection_client_deinit(&rf.client); } @@ -2614,11 +2716,11 @@ int main(void) cmocka_unit_test(open_rejects_operational_cert_without_payload_buffer), cmocka_unit_test_setup_teardown(send_csr_two_phase_delivers_issued_chain, setup, teardown), cmocka_unit_test_setup_teardown(send_csr_error_reports_service_code, setup, teardown), - cmocka_unit_test(options_default_enables_use_issued_certificates), - cmocka_unit_test(a_hub_issued_chain_is_stored_and_used_on_the_next_connect), + cmocka_unit_test(a_provider_csr_chain_is_stored_and_used_on_the_next_connect), cmocka_unit_test(a_failed_store_keeps_the_session_and_the_previous_credential), - cmocka_unit_test(use_issued_certificates_off_leaves_the_chain_to_the_app), - cmocka_unit_test(a_v1_provider_is_not_asked_to_store_a_hub_issued_chain), + cmocka_unit_test(an_application_csr_chain_is_not_stored), + cmocka_unit_test(send_csr_without_a_csr_needs_a_provider_that_can_renew), + cmocka_unit_test(a_failed_get_csr_fails_send_csr), cmocka_unit_test_setup_teardown(send_csr_cancel_frees_slot, setup, teardown), cmocka_unit_test_setup_teardown( send_csr_subscribes_the_credentials_response_filter, setup, teardown), From a0ab0d81cbeef6c6e33b54699b1c59eb16e0ca5d Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:20:40 +0000 Subject: [PATCH 05/17] [C] Validate the whole issued chain; size-stamp the CSR event - Managed provider: a stored or persisted chain is used only if every certificate parses (not just the leaf) and, on store, the parsed count matches the issued count. - az_iot_csr_event gains _internal_size, stamped by the SDK, so fields like store_status can be appended safely. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../az_iot_certificate_provider_managed.c | 55 ++++++++++---- .../az_iot_certificate_provider_managed.h | 9 +-- c/docs/test-coverage.md | 16 +++-- c/inc/azure/iot/az_iot_connection_client.h | 5 ++ c/src/core/connection_client.c | 2 + .../unit/certificate_provider_managed_test.c | 71 +++++++++++++++++++ c/tests/unit/connection_client_test.c | 2 + 7 files changed, 137 insertions(+), 23 deletions(-) diff --git a/c/adapters/cert_openssl/az_iot_certificate_provider_managed.c b/c/adapters/cert_openssl/az_iot_certificate_provider_managed.c index e2856341b..072e65f10 100644 --- a/c/adapters/cert_openssl/az_iot_certificate_provider_managed.c +++ b/c/adapters/cert_openssl/az_iot_certificate_provider_managed.c @@ -270,19 +270,45 @@ static az_iot_result write_key_file(const char* path, EVP_PKEY* key) } /** - * @brief True when the first PEM certificate in @p b parses and certifies @p key. + * @brief True when @p b holds at least one PEM certificate, every one parses, + * and the first (the leaf) certifies @p key. + * + * Reading stops at the first object that is not a certificate; the chain is + * accepted only if that is the end of the input, so a malformed or truncated + * entry anywhere in the chain rejects it. + * + * @param[out] out_count Certificates parsed; may be NULL. */ -static bool leaf_matches_key(BIO* b, EVP_PKEY* key) +static bool chain_matches_key(BIO* b, EVP_PKEY* key, size_t* out_count) { - X509* leaf = PEM_read_bio_X509(b, NULL, NULL, NULL); - bool ok = leaf != NULL && key != NULL && X509_check_private_key(leaf, key) == 1; - X509_free(leaf); + size_t count = 0; + bool ok = key != NULL; + X509* cert = NULL; + while (ok && (cert = PEM_read_bio_X509(b, NULL, NULL, NULL)) != NULL) + { + if (count == 0 && X509_check_private_key(cert, key) != 1) + { + ok = false; + } + X509_free(cert); + count++; + } + unsigned long err = ERR_peek_last_error(); + if (ok && !(ERR_GET_LIB(err) == ERR_LIB_PEM && ERR_GET_REASON(err) == PEM_R_NO_START_LINE)) + { + ok = false; + } ERR_clear_error(); - return ok; + if (out_count) + { + *out_count = count; + } + return ok && count > 0; } /** - * @brief True when @p path holds a certificate chain whose leaf certifies @p key. + * @brief True when @p path holds a certificate chain that passes + * chain_matches_key() for @p key. * * An empty, partial or unparseable file, or one issued for a different key, * is not a usable identity: TLS would fail with it on every connect. @@ -295,7 +321,7 @@ static bool operational_cert_is_valid(const char* path, EVP_PKEY* key) ERR_clear_error(); return false; } - bool ok = leaf_matches_key(b, key); + bool ok = chain_matches_key(b, key, NULL); BIO_free(b); return ok; } @@ -567,9 +593,10 @@ static az_iot_result managed_store( return AZ_IOT_ERR_INVALID_ARG; } - /* All-or-nothing: build the chain in memory, refuse it unless its leaf - * certifies the operational key, then replace the file in one step. A - * failure leaves the previous certificate in use. */ + /* All-or-nothing: build the chain in memory, refuse it unless every entry + * parses as a certificate and the leaf certifies the operational key, then + * replace the file in one step. A failure leaves the previous certificate in + * use. */ BIO* mem = BIO_new(BIO_s_mem()); if (!mem) { @@ -578,6 +605,7 @@ static az_iot_result managed_store( /* Write each issued cert (leaf first). */ az_iot_result rc = AZ_IOT_OK; + size_t written = 0; for (size_t i = 0; i < issued->count; ++i) { az_span cert = issued->certificates[i]; @@ -591,6 +619,7 @@ static az_iot_result managed_store( rc = AZ_IOT_ERR_INTERNAL; break; } + written++; } if (rc == AZ_IOT_OK) @@ -598,7 +627,9 @@ static az_iot_result managed_store( char* data = NULL; long data_len = BIO_get_mem_data(mem, &data); BIO* check = (data_len > 0) ? BIO_new_mem_buf(data, (int)data_len) : NULL; - if (!check || !leaf_matches_key(check, (EVP_PKEY*)m->operational_key)) + size_t parsed = 0; + if (!check || !chain_matches_key(check, (EVP_PKEY*)m->operational_key, &parsed) + || parsed != written) { rc = AZ_IOT_ERR_INVALID_ARG; } diff --git a/c/adapters/cert_openssl/az_iot_certificate_provider_managed.h b/c/adapters/cert_openssl/az_iot_certificate_provider_managed.h index facb0b9f7..84505a72c 100644 --- a/c/adapters/cert_openssl/az_iot_certificate_provider_managed.h +++ b/c/adapters/cert_openssl/az_iot_certificate_provider_managed.h @@ -12,9 +12,9 @@ * - produces PKCS#10 CSRs over that operational key (get_csr); * - persists the DPS/Hub-issued operational certificate chain to disk * (store_issued_certificate) and serves it back on subsequent loads and - * process restarts. A store is refused unless the chain parses and its - * leaf certifies the operational key, and replaces the file in one step, so - * a failed store keeps the previous certificate. + * process restarts. A store is refused unless every certificate in the + * chain parses and the leaf certifies the operational key, and replaces the + * file in one step, so a failed store keeps the previous certificate. * * Files it writes (key and chain) are created readable only by the current user * (0600 on POSIX, owner-only DACL on Windows), under a unique temporary name @@ -59,7 +59,8 @@ extern "C" const char* operational_key_pem_path; /* required */ /* Where the issued operational certificate chain is persisted. Written by * store_issued_certificate(); read back on load() and on restart, and used - * only if its leaf certifies the operational key. Required. */ + * only if every certificate parses and the leaf certifies the operational + * key. Required. */ const char* operational_cert_pem_path; /* required */ /* Key type used only when generating a new operational key. */ az_iot_certificate_managed_key_type key_type; diff --git a/c/docs/test-coverage.md b/c/docs/test-coverage.md index 576c63b8b..6c956a117 100644 --- a/c/docs/test-coverage.md +++ b/c/docs/test-coverage.md @@ -849,13 +849,15 @@ runtime renewal against the connected hub over `$iothub/credentials/...` per | | Store persists the issued chain | — | unit | Done | [managed_store_persists_and_survives_restart](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L166) | | | The persisted chain reloads after a restart | — | unit | Done | [managed_store_persists_and_survives_restart](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L166) | | | A CSR buffer that is too small is reported | — | unit | Pending | *certificate_provider_managed_test.c* | -| | A stored chain that is not a certificate is refused | The store validates the chain before replacing the file; nothing is written and no operational identity is claimed. | unit | Done | [managed_a_stored_chain_that_is_not_a_certificate_is_refused](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L504) | -| | A failed store keeps the previous certificate | All-or-nothing, in-process and after a restart, so a failed renewal cannot break the next connect. | unit | Done | [managed_a_failed_store_keeps_the_previous_certificate](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L534) | -| | A chain for another key is refused | The leaf must certify the operational key; the previous chain is kept. | unit | Done | [managed_store_refuses_a_chain_for_another_key](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L572) | -| | A persisted chain for another key is not used | After the key is replaced, the stored chain no longer certifies it. | unit | Done | [managed_a_persisted_chain_for_another_key_is_not_used](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L607) | -| | Written files are owner-only | Key and chain are 0600 whatever the umask. POSIX only; the Windows owner-only DACL is not tested. | unit | Done | [managed_written_files_are_owner_only](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L647) | -| | A link at a destination is replaced, not followed | Key and chain. POSIX only. | unit | Done | [managed_writes_replace_a_link_instead_of_following_it](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L670) | -| | Store overwrites a previously issued chain | Renewal replaces rather than appends. | unit | Done | [managed_store_overwrites_a_previously_issued_chain](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L473) | +| | A stored chain that is not a certificate is refused | The store validates the chain before replacing the file; nothing is written and no operational identity is claimed. | unit | Done | [managed_a_stored_chain_that_is_not_a_certificate_is_refused](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L508) | +| | A failed store keeps the previous certificate | All-or-nothing, in-process and after a restart, so a failed renewal cannot break the next connect. | unit | Done | [managed_a_failed_store_keeps_the_previous_certificate](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L538) | +| | A chain for another key is refused | The leaf must certify the operational key; the previous chain is kept. | unit | Done | [managed_store_refuses_a_chain_for_another_key](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L576) | +| | A persisted chain for another key is not used | After the key is replaced, the stored chain no longer certifies it. | unit | Done | [managed_a_persisted_chain_for_another_key_is_not_used](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L679) | +| | A chain with a bad trailing entry is refused | Every entry must parse; the previous chain is kept. | unit | Done | [managed_store_refuses_a_chain_with_a_bad_trailing_entry](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L611) | +| | A persisted chain with a truncated entry is not used | — | unit | Done | [managed_a_persisted_chain_with_a_truncated_entry_is_not_used](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L643) | +| | Written files are owner-only | Key and chain are 0600 whatever the umask. POSIX only; the Windows owner-only DACL is not tested. | unit | Done | [managed_written_files_are_owner_only](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L720) | +| | A link at a destination is replaced, not followed | Key and chain. POSIX only. | unit | Done | [managed_writes_replace_a_link_instead_of_following_it](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L743) | +| | Store overwrites a previously issued chain | Renewal replaces rather than appends. | unit | Done | [managed_store_overwrites_a_previously_issued_chain](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L477) | | | The sign hook is not offered by either bundled provider | Neither provider sets `sign`, so the connect path must keep checking it for NULL. Pinned so that adding an implementation is a deliberate act. | unit | Done | [the_sign_hook_is_not_offered_by_this_provider](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L712) | | | An operational load with no stored chain returns not found | The connect path uses this code to decide it must still enroll. Belongs to the managed provider: the PEM loader ignores the role. | unit | Done | [managed_operational_load_without_a_stored_chain_is_not_found](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L276) | | | Load rejects null arguments | — | unit | Done | [managed_load_rejects_null_arguments](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L255) | diff --git a/c/inc/azure/iot/az_iot_connection_client.h b/c/inc/azure/iot/az_iot_connection_client.h index ab4c4f9c3..053d549e1 100644 --- a/c/inc/azure/iot/az_iot_connection_client.h +++ b/c/inc/azure/iot/az_iot_connection_client.h @@ -718,8 +718,13 @@ extern "C" AZ_IOT_CSR_FAILED /* rejected/failed (evt->status, service_code) */ } az_iot_csr_event_kind; + /* SDK-produced, callback-lifetime. The SDK stamps _internal_size; callers + * never initialize this struct. Future SDKs may append fields, so callbacks + * must check _internal_size before reading a field added after the version + * they were compiled against. */ typedef struct az_iot_csr_event { + uint32_t _internal_size; az_iot_csr_event_kind kind; az_iot_result status; /* AZ_IOT_OK unless FAILED */ int32_t service_code; /* hub errorCode on FAILED; 0 otherwise */ diff --git a/c/src/core/connection_client.c b/c/src/core/connection_client.c index 60493ca01..31b5b1103 100644 --- a/c/src/core/connection_client.c +++ b/c/src/core/connection_client.c @@ -4260,6 +4260,7 @@ az_iot_result az_iot_connection_client_do_work( client->csr_op.in_use = false; az_iot_csr_event evt; memset(&evt, 0, sizeof(evt)); + evt._internal_size = (uint32_t)sizeof(evt); evt.kind = AZ_IOT_CSR_FAILED; evt.status = AZ_IOT_ERR_TIMEOUT; if (cb) @@ -5460,6 +5461,7 @@ static void on_csr_response(void* user_ctx, const az_iot_mqtt_message* msg) az_span payload = az_span_create((uint8_t*)(uintptr_t)msg->payload, (int32_t)msg->payload_len); az_iot_csr_event evt; memset(&evt, 0, sizeof(evt)); + evt._internal_size = (uint32_t)sizeof(evt); if (status == 202) { diff --git a/c/tests/unit/certificate_provider_managed_test.c b/c/tests/unit/certificate_provider_managed_test.c index b64a06a75..d6bcfc172 100644 --- a/c/tests/unit/certificate_provider_managed_test.c +++ b/c/tests/unit/certificate_provider_managed_test.c @@ -606,6 +606,75 @@ static void managed_store_refuses_a_chain_for_another_key(void** state) remove_test_files(); } +/* Every entry must parse, not just the leaf: a matching leaf followed by an + * entry that is not a certificate is refused and the previous chain kept. */ +static void managed_store_refuses_a_chain_with_a_bad_trailing_entry(void** state) +{ + (void)state; + remove_test_files(); + az_iot_certificate_provider_managed_options opts = test_options(); + az_iot_certificate_provider_managed prov; + assert_int_equal(AZ_IOT_OK, az_iot_certificate_provider_managed_init(&prov, &opts)); + + char* good = make_cert_base64((EVP_PKEY*)prov.operational_key, 1); + az_span chain1[1] = { az_span_create_from_str(good) }; + az_iot_issued_certificate issued1 = { .certificates = chain1, .count = 1 }; + assert_int_equal(AZ_IOT_OK, prov.base.vtable->store_issued_certificate(&prov.base, &issued1)); + + char* next = make_cert_base64((EVP_PKEY*)prov.operational_key, 2); + az_span chain2[2] + = { az_span_create_from_str(next), AZ_SPAN_FROM_STR("bm90LWEtY2VydGlmaWNhdGU=") }; + az_iot_issued_certificate issued2 = { .certificates = chain2, .count = 2 }; + assert_int_equal( + AZ_IOT_ERR_INVALID_ARG, prov.base.vtable->store_issued_certificate(&prov.base, &issued2)); + + char* on_disk = read_chain_base64(OP_CERT); + assert_string_equal(on_disk, good); + assert_int_equal(0, count_temp_files()); + az_iot_certificate_provider_managed_deinit(&prov); + + free(on_disk); + free(next); + free(good); + remove_test_files(); +} + +/* A persisted chain whose trailing entry is truncated is not served after a + * restart, even though its leaf is valid. */ +static void managed_a_persisted_chain_with_a_truncated_entry_is_not_used(void** state) +{ + (void)state; + remove_test_files(); + az_iot_certificate_provider_managed_options opts = test_options(); + az_iot_certificate_provider_managed prov; + assert_int_equal(AZ_IOT_OK, az_iot_certificate_provider_managed_init(&prov, &opts)); + char* good = make_cert_base64((EVP_PKEY*)prov.operational_key, 1); + az_span chain[1] = { az_span_create_from_str(good) }; + az_iot_issued_certificate issued = { .certificates = chain, .count = 1 }; + assert_int_equal(AZ_IOT_OK, prov.base.vtable->store_issued_certificate(&prov.base, &issued)); + az_iot_certificate_provider_managed_deinit(&prov); + + /* Control: the file as written is usable after a restart. */ + az_iot_certificate_provider_managed check; + assert_int_equal(AZ_IOT_OK, az_iot_certificate_provider_managed_init(&check, &opts)); + assert_true(check.has_operational); + az_iot_certificate_provider_managed_deinit(&check); + + FILE* f = fopen(OP_CERT, "ab"); + assert_non_null(f); + const char tail[] = "-----BEGIN CERTIFICATE-----\nMIIB\n"; + assert_int_equal((int)sizeof(tail) - 1, (int)fwrite(tail, 1, sizeof(tail) - 1, f)); + assert_int_equal(0, fclose(f)); + + az_iot_certificate_provider_managed prov2; + assert_int_equal(AZ_IOT_OK, az_iot_certificate_provider_managed_init(&prov2, &opts)); + assert_false(prov2.has_operational); + az_iot_certificate_provider_managed_deinit(&prov2); + + free(good); + remove_test_files(); +} + /* A chain on disk that no longer matches the key (the key was replaced) is not * served as the operational identity after a restart. */ static void managed_a_persisted_chain_for_another_key_is_not_used(void** state) @@ -745,6 +814,8 @@ int main(void) cmocka_unit_test(managed_a_failed_store_keeps_the_previous_certificate), cmocka_unit_test(managed_store_refuses_a_chain_for_another_key), cmocka_unit_test(managed_a_persisted_chain_for_another_key_is_not_used), + cmocka_unit_test(managed_store_refuses_a_chain_with_a_bad_trailing_entry), + cmocka_unit_test(managed_a_persisted_chain_with_a_truncated_entry_is_not_used), #if !defined(_WIN32) cmocka_unit_test(managed_written_files_are_owner_only), cmocka_unit_test(managed_writes_replace_a_link_instead_of_following_it), diff --git a/c/tests/unit/connection_client_test.c b/c/tests/unit/connection_client_test.c index 8b9dbf887..1a880e88c 100644 --- a/c/tests/unit/connection_client_test.c +++ b/c/tests/unit/connection_client_test.c @@ -1649,6 +1649,7 @@ typedef struct csr_test_ctx static void on_csr_evt(const az_iot_csr_event* evt, void* uc) { csr_test_ctx* t = (csr_test_ctx*)uc; + assert_int_equal(evt->_internal_size, sizeof(az_iot_csr_event)); switch (evt->kind) { case AZ_IOT_CSR_ACCEPTED: @@ -1928,6 +1929,7 @@ typedef struct renew_cb_ctx static void on_renew_evt(const az_iot_csr_event* evt, void* uc) { renew_cb_ctx* t = (renew_cb_ctx*)uc; + assert_int_equal(evt->_internal_size, sizeof(az_iot_csr_event)); if (evt->kind == AZ_IOT_CSR_ISSUED) { t->issued++; From bfd45d8a4d8572f3547aada9fb5c9ea0cf99f0e7 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:44:56 +0000 Subject: [PATCH 06/17] [C] Gate the DPS store hook on the provider vtable version; fix coverage links Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- c/docs/test-coverage.md | 16 ++++++++-------- c/src/core/connection_client.c | 3 ++- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/c/docs/test-coverage.md b/c/docs/test-coverage.md index 6c956a117..92b41dfc5 100644 --- a/c/docs/test-coverage.md +++ b/c/docs/test-coverage.md @@ -852,13 +852,13 @@ runtime renewal against the connected hub over `$iothub/credentials/...` per | | A stored chain that is not a certificate is refused | The store validates the chain before replacing the file; nothing is written and no operational identity is claimed. | unit | Done | [managed_a_stored_chain_that_is_not_a_certificate_is_refused](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L508) | | | A failed store keeps the previous certificate | All-or-nothing, in-process and after a restart, so a failed renewal cannot break the next connect. | unit | Done | [managed_a_failed_store_keeps_the_previous_certificate](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L538) | | | A chain for another key is refused | The leaf must certify the operational key; the previous chain is kept. | unit | Done | [managed_store_refuses_a_chain_for_another_key](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L576) | -| | A persisted chain for another key is not used | After the key is replaced, the stored chain no longer certifies it. | unit | Done | [managed_a_persisted_chain_for_another_key_is_not_used](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L679) | +| | A persisted chain for another key is not used | After the key is replaced, the stored chain no longer certifies it. | unit | Done | [managed_a_persisted_chain_for_another_key_is_not_used](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L680) | | | A chain with a bad trailing entry is refused | Every entry must parse; the previous chain is kept. | unit | Done | [managed_store_refuses_a_chain_with_a_bad_trailing_entry](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L611) | -| | A persisted chain with a truncated entry is not used | — | unit | Done | [managed_a_persisted_chain_with_a_truncated_entry_is_not_used](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L643) | +| | A persisted chain with a truncated entry is not used | — | unit | Done | [managed_a_persisted_chain_with_a_truncated_entry_is_not_used](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L644) | | | Written files are owner-only | Key and chain are 0600 whatever the umask. POSIX only; the Windows owner-only DACL is not tested. | unit | Done | [managed_written_files_are_owner_only](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L720) | | | A link at a destination is replaced, not followed | Key and chain. POSIX only. | unit | Done | [managed_writes_replace_a_link_instead_of_following_it](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L743) | | | Store overwrites a previously issued chain | Renewal replaces rather than appends. | unit | Done | [managed_store_overwrites_a_previously_issued_chain](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L477) | -| | The sign hook is not offered by either bundled provider | Neither provider sets `sign`, so the connect path must keep checking it for NULL. Pinned so that adding an implementation is a deliberate act. | unit | Done | [the_sign_hook_is_not_offered_by_this_provider](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L712) | +| | The sign hook is not offered by either bundled provider | Neither provider sets `sign`, so the connect path must keep checking it for NULL. Pinned so that adding an implementation is a deliberate act. | unit | Done | [the_sign_hook_is_not_offered_by_this_provider](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L785) | | | An operational load with no stored chain returns not found | The connect path uses this code to decide it must still enroll. Belongs to the managed provider: the PEM loader ignores the role. | unit | Done | [managed_operational_load_without_a_stored_chain_is_not_found](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L276) | | | Load rejects null arguments | — | unit | Done | [managed_load_rejects_null_arguments](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L255) | | | Release leaves the material usable | The paths are borrowed from the provider struct, so freeing them here would dangle what the connection is still using. | unit | Done | [managed_release_leaves_the_material_usable](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L296) | @@ -889,11 +889,11 @@ runtime renewal against the connected hub over `$iothub/credentials/...` per | | A 412 reports the service code | No matching request to replace. | unit | Done | [a_412_reports_the_service_code](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1581) | | | A 429 surfaces the retry-after hint | The response body carries `retryAfter`. | unit | Done | [a_429_surfaces_the_retry_after_hint](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1607) | | | A malformed issued chain reports a protocol error | 200 whose body has no `certificates` array. | unit | Done | [a_200_without_a_certificates_array_reports_a_failure](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1635) | -| | A provider-made CSR's chain is stored and used on the next connect | `send_csr(NULL)`: CSR from `get_csr()` (subject = device id); stored before the callback; session kept. | unit | Done | [a_provider_csr_chain_is_stored_and_used_on_the_next_connect](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2045) | -| | A failed store keeps the session and the previous credential | Reported in `store_status`. | unit | Done | [a_failed_store_keeps_the_session_and_the_previous_credential](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2072) | -| | An application CSR's chain is not stored | The application owns the key and the chain. | unit | Done | [an_application_csr_chain_is_not_stored](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2092) | -| | Send CSR without a CSR needs a provider that can renew | No provider, a v1 provider, or no `get_csr`: refused before publishing; slot stays free. | unit | Done | [send_csr_without_a_csr_needs_a_provider_that_can_renew](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2127) | -| | A failed get_csr fails send CSR | Nothing published. | unit | Done | [a_failed_get_csr_fails_send_csr](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2147) | +| | A provider-made CSR's chain is stored and used on the next connect | `send_csr(NULL)`: CSR from `get_csr()` (subject = device id); stored before the callback; session kept. | unit | Done | [a_provider_csr_chain_is_stored_and_used_on_the_next_connect](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2047) | +| | A failed store keeps the session and the previous credential | Reported in `store_status`. | unit | Done | [a_failed_store_keeps_the_session_and_the_previous_credential](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2074) | +| | An application CSR's chain is not stored | The application owns the key and the chain. | unit | Done | [an_application_csr_chain_is_not_stored](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2094) | +| | Send CSR without a CSR needs a provider that can renew | No provider, a v1 provider, or no `get_csr`: refused before publishing; slot stays free. | unit | Done | [send_csr_without_a_csr_needs_a_provider_that_can_renew](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2129) | +| | A failed get_csr fails send CSR | Nothing published. | unit | Done | [a_failed_get_csr_fails_send_csr](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2149) | | | A renewal with no response times out | `CSR_OP_TIMEOUT_MS` elapses. | unit | Pending | *connection_client_test.c* | | | A 202 extends the timeout deadline | A slow-but-alive signer is not abandoned. | unit | Pending | *connection_client_test.c* | | | Send CSR while disconnected is refused | — | unit | Done | [send_csr_while_disconnected_is_refused](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1661) | diff --git a/c/src/core/connection_client.c b/c/src/core/connection_client.c index 31b5b1103..bd81db5dd 100644 --- a/c/src/core/connection_client.c +++ b/c/src/core/connection_client.c @@ -1606,7 +1606,8 @@ static az_iot_result dps_store_issued_cert(az_iot_connection_client* c, az_span /* Persist via the provider (if capable), then always notify the app with the * outcome (D4). At least one of the two must exist to take the chain. */ - bool has_store = p && p->vtable && p->vtable->store_issued_certificate; + bool has_store = p && p->vtable && p->vtable->version >= CERT_PROVIDER_VTABLE_V2 + && p->vtable->store_issued_certificate; az_iot_result store_rc = AZ_IOT_ERR_NOT_SUPPORTED; if (has_store) { From 0a9645b3abcc8389d0a0efced36240c4f9814fa7 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Tue, 29 Sep 2026 00:01:30 +0000 Subject: [PATCH 07/17] [C] Sample provider: all-or-nothing store; sync design doc - sample_cert_provider writes the chain to ".tmp", flushes it, then replaces the destination, so a failed store keeps the previous chain. - certificate-management.md: az_iot_csr_event and send_csr() match the header (_internal_size, store_status, csr NULL). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- c/docs/eng/certificate-management.md | 10 +++-- c/samples/common/sample_cert_provider.c | 52 ++++++++++++++++++++++++- 2 files changed, 57 insertions(+), 5 deletions(-) diff --git a/c/docs/eng/certificate-management.md b/c/docs/eng/certificate-management.md index 1539c1c84..e22404ece 100644 --- a/c/docs/eng/certificate-management.md +++ b/c/docs/eng/certificate-management.md @@ -334,18 +334,22 @@ typedef enum typedef struct { + uint32_t _internal_size; /* SDK-stamped sizeof */ az_iot_csr_event_kind kind; az_iot_result status; /* AZ_IOT_OK unless FAILED */ int32_t service_code; /* e.g. 409005; 0 if none */ uint32_t retry_after_s; /* 0 if none */ const az_iot_issued_certificate* issued; /* non-NULL on ISSUED */ + az_iot_result store_status; /* ISSUED: provider store result */ } az_iot_csr_event; typedef void (*az_iot_csr_callback)(const az_iot_csr_event* evt, void* user_ctx); -/* Device-initiated renewal against the connected Hub. request_id NULL => the - * SDK generates one; pass a prior id to resubmit. replace NULL, or "*" to - * supersede any active request. */ +/* Device-initiated renewal against the connected Hub. csr NULL => the SDK takes + * it from the provider's get_csr() and stores the issued chain there; an + * application CSR is never stored. request_id NULL => the SDK generates one; + * pass a prior id to resubmit. replace NULL, or "*" to supersede any active + * request. */ az_iot_result az_iot_connection_client_send_csr( az_iot_connection_client* client, const az_iot_certificate_signing_request* csr, diff --git a/c/samples/common/sample_cert_provider.c b/c/samples/common/sample_cert_provider.c index 9c890b821..863c558eb 100644 --- a/c/samples/common/sample_cert_provider.c +++ b/c/samples/common/sample_cert_provider.c @@ -16,10 +16,31 @@ #include #include +#ifdef _WIN32 +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#endif +#include +#endif + /* PEM framing written around each base64 DER certificate the service issues. */ #define PEM_CERT_BEGIN "-----BEGIN CERTIFICATE-----\n" #define PEM_CERT_END "\n-----END CERTIFICATE-----\n" +/* Suffix of the temporary file a new chain is written to before it replaces + * the operational cert file. */ +#define SAMPLE_TMP_SUFFIX ".tmp" + +/* Replace @p to with @p from in one step. Returns nonzero on success. */ +static int sample_replace_file(const char* from, const char* to) +{ +#ifdef _WIN32 + return MoveFileExA(from, to, MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH) != 0; +#else + return rename(from, to) == 0; +#endif +} + static char* dup_str(const char* s) { if (!s) @@ -132,13 +153,27 @@ static az_iot_result provider_store( return AZ_IOT_ERR_INVALID_ARG; } - FILE* f = fopen(p->operational_cert_path, "wb"); + /* All-or-nothing: write a temporary file next to the destination, then + * replace the destination with it, so a failure keeps the previous chain. + * A production provider should also create it owner-only under a unique + * name (see the managed provider). */ + size_t path_len = strlen(p->operational_cert_path); + char* tmp = (char*)malloc(path_len + sizeof(SAMPLE_TMP_SUFFIX)); + if (!tmp) + { + return AZ_IOT_ERR_OUT_OF_MEMORY; + } + memcpy(tmp, p->operational_cert_path, path_len); + memcpy(tmp + path_len, SAMPLE_TMP_SUFFIX, sizeof(SAMPLE_TMP_SUFFIX)); + + FILE* f = fopen(tmp, "wb"); if (!f) { + free(tmp); return AZ_IOT_ERR_INTERNAL; } - /* PEM-wrap each base64 DER cert (leaf first) into the operational cert file. */ + /* PEM-wrap each base64 DER cert (leaf first) into the temporary file. */ az_iot_result rc = AZ_IOT_OK; for (size_t i = 0; i < issued->count; ++i) { @@ -155,10 +190,23 @@ static az_iot_result provider_store( break; } } + if (fflush(f) != 0) + { + rc = AZ_IOT_ERR_INTERNAL; + } if (fclose(f) != 0) { rc = AZ_IOT_ERR_INTERNAL; } + if (rc == AZ_IOT_OK && !sample_replace_file(tmp, p->operational_cert_path)) + { + rc = AZ_IOT_ERR_INTERNAL; + } + if (rc != AZ_IOT_OK) + { + (void)remove(tmp); + } + free(tmp); if (rc == AZ_IOT_OK) { From 02d28fed7cbfe5b2f8e6b3d51479af1cc2e11569 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Tue, 29 Sep 2026 00:36:41 +0000 Subject: [PATCH 08/17] [C] Refuse empty chain entries; keep the op-cert callback captured - Managed provider: an empty entry in an issued chain fails the store, so every issued entry must parse as a certificate. - DPS: the operational-cert callback is captured before it runs, so one that unregisters itself still counts as having handled the chain. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../az_iot_certificate_provider_managed.c | 3 +- c/docs/test-coverage.md | 36 ++++++++++--------- c/src/core/connection_client.c | 8 +++-- .../unit/certificate_provider_managed_test.c | 32 +++++++++++++++++ c/tests/unit/connection_client_test.c | 29 +++++++++++++++ 5 files changed, 87 insertions(+), 21 deletions(-) diff --git a/c/adapters/cert_openssl/az_iot_certificate_provider_managed.c b/c/adapters/cert_openssl/az_iot_certificate_provider_managed.c index 072e65f10..63fb3179b 100644 --- a/c/adapters/cert_openssl/az_iot_certificate_provider_managed.c +++ b/c/adapters/cert_openssl/az_iot_certificate_provider_managed.c @@ -612,7 +612,8 @@ static az_iot_result managed_store( int len = (int)az_span_size(cert); if (len <= 0) { - continue; + rc = AZ_IOT_ERR_INVALID_ARG; /* every issued entry must be a certificate */ + break; } if (!write_issued_cert(mem, az_span_ptr(cert), len)) { diff --git a/c/docs/test-coverage.md b/c/docs/test-coverage.md index 92b41dfc5..932d27fdf 100644 --- a/c/docs/test-coverage.md +++ b/c/docs/test-coverage.md @@ -196,7 +196,7 @@ Covers `az_iot_connection_client` lifecycle, CONNACK handling, reconnection, the | | MQTTv5 hub birth timeout retries with new nonce | A stale ack must not satisfy the retry. | unit | Done | [hub_mqtt_v5_birth_timeout_retries_with_a_new_nonce](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1402) | | | MQTTv5 hub without v5 factory not supported | No silent downgrade to an MQTTv3 session. | unit | Done | [hub_mqtt_v5_without_v5_factory_is_not_supported](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1302) | | | MQTTv5 hub birth ack before suback ignored | No nonce exists yet to match against. | unit | Done | [hub_mqtt_v5_birth_ack_before_suback_is_ignored](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1348) | -| DPS provisioning | DPS CSR flow stores issued chain | DPS register with CSR → ASSIGNED → operational chain stored. | unit | Done | [dps_csr_flow_sends_csr_and_stores_issued_chain](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1532) | +| DPS provisioning | DPS CSR flow stores issued chain | DPS register with CSR → ASSIGNED → operational chain stored. | unit | Done | [dps_csr_flow_sends_csr_and_stores_issued_chain](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1538) | | | Open rejects operational cert without CSR provider | `request_operational_certificate` set but provider has no `get_csr`. | unit | Done | [open_rejects_operational_cert_without_csr_provider](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L899) | | | Open rejects operational cert without payload buffer | `request_operational_certificate` set but `csr_payload_buffer` empty. | unit | Done | [open_rejects_operational_cert_without_payload_buffer](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1168) | | | DPS connects to the global endpoint | Default `global.azure-devices-provisioning.net`. | unit | Done | [dps_connects_to_the_global_endpoint_by_default](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_dps_test.c#L309) | @@ -852,13 +852,14 @@ runtime renewal against the connected hub over `$iothub/credentials/...` per | | A stored chain that is not a certificate is refused | The store validates the chain before replacing the file; nothing is written and no operational identity is claimed. | unit | Done | [managed_a_stored_chain_that_is_not_a_certificate_is_refused](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L508) | | | A failed store keeps the previous certificate | All-or-nothing, in-process and after a restart, so a failed renewal cannot break the next connect. | unit | Done | [managed_a_failed_store_keeps_the_previous_certificate](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L538) | | | A chain for another key is refused | The leaf must certify the operational key; the previous chain is kept. | unit | Done | [managed_store_refuses_a_chain_for_another_key](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L576) | -| | A persisted chain for another key is not used | After the key is replaced, the stored chain no longer certifies it. | unit | Done | [managed_a_persisted_chain_for_another_key_is_not_used](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L680) | +| | A persisted chain for another key is not used | After the key is replaced, the stored chain no longer certifies it. | unit | Done | [managed_a_persisted_chain_for_another_key_is_not_used](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L711) | | | A chain with a bad trailing entry is refused | Every entry must parse; the previous chain is kept. | unit | Done | [managed_store_refuses_a_chain_with_a_bad_trailing_entry](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L611) | -| | A persisted chain with a truncated entry is not used | — | unit | Done | [managed_a_persisted_chain_with_a_truncated_entry_is_not_used](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L644) | -| | Written files are owner-only | Key and chain are 0600 whatever the umask. POSIX only; the Windows owner-only DACL is not tested. | unit | Done | [managed_written_files_are_owner_only](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L720) | -| | A link at a destination is replaced, not followed | Key and chain. POSIX only. | unit | Done | [managed_writes_replace_a_link_instead_of_following_it](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L743) | +| | A chain with an empty entry is refused | — | unit | Done | [managed_store_refuses_a_chain_with_an_empty_entry](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L644) | +| | A persisted chain with a truncated entry is not used | — | unit | Done | [managed_a_persisted_chain_with_a_truncated_entry_is_not_used](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L675) | +| | Written files are owner-only | Key and chain are 0600 whatever the umask. POSIX only; the Windows owner-only DACL is not tested. | unit | Done | [managed_written_files_are_owner_only](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L751) | +| | A link at a destination is replaced, not followed | Key and chain. POSIX only. | unit | Done | [managed_writes_replace_a_link_instead_of_following_it](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L774) | | | Store overwrites a previously issued chain | Renewal replaces rather than appends. | unit | Done | [managed_store_overwrites_a_previously_issued_chain](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L477) | -| | The sign hook is not offered by either bundled provider | Neither provider sets `sign`, so the connect path must keep checking it for NULL. Pinned so that adding an implementation is a deliberate act. | unit | Done | [the_sign_hook_is_not_offered_by_this_provider](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L785) | +| | The sign hook is not offered by either bundled provider | Neither provider sets `sign`, so the connect path must keep checking it for NULL. Pinned so that adding an implementation is a deliberate act. | unit | Done | [the_sign_hook_is_not_offered_by_this_provider](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L816) | | | An operational load with no stored chain returns not found | The connect path uses this code to decide it must still enroll. Belongs to the managed provider: the PEM loader ignores the role. | unit | Done | [managed_operational_load_without_a_stored_chain_is_not_found](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L276) | | | Load rejects null arguments | — | unit | Done | [managed_load_rejects_null_arguments](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L255) | | | Release leaves the material usable | The paths are borrowed from the provider struct, so freeing them here would dangle what the connection is still using. | unit | Done | [managed_release_leaves_the_material_usable](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L296) | @@ -866,15 +867,16 @@ runtime renewal against the connected hub over `$iothub/credentials/...` per | | Store rejects an issuance with no certificates | Accepting an empty chain would flip the operational flag with nothing on disk. | unit | Done | [managed_store_rejects_null_arguments](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L335) | | | Deinit tolerates null | — | unit | Done | [managed_deinit_tolerates_null](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L363) | | | Deinit through the vtable destroys the provider | What a generic owner of an `az_iot_certificate_provider` calls. | unit | Done | [managed_deinit_through_the_vtable_destroys_the_provider](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L371) | -| Issuance via DPS | The CSR rides the DPS registration body | — | unit | Done | [dps_csr_flow_sends_csr_and_stores_issued_chain](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1532) | -| | The issued chain is handed to the provider | — | unit | Done | [dps_csr_flow_sends_csr_and_stores_issued_chain](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1532) | +| Issuance via DPS | The CSR rides the DPS registration body | — | unit | Done | [dps_csr_flow_sends_csr_and_stores_issued_chain](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1538) | +| | The issued chain is handed to the provider | — | unit | Done | [dps_csr_flow_sends_csr_and_stores_issued_chain](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1538) | | | Open is refused without a CSR-capable provider | — | unit | Done | [open_rejects_operational_cert_without_csr_provider](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L899) | | | Open is refused without a payload buffer | — | unit | Done | [open_rejects_operational_cert_without_payload_buffer](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1168) | | | The DPS username carries the CSR API version | The CSR flow needs a newer api-version than the azure-sdk-for-c default. | unit | Pending | *connection_client_test.c* | -| | The operational cert callback fires with the issued chain and store result | Fires after the store. | unit | Done | [dps_csr_flow_sends_csr_and_stores_issued_chain](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1532) | -| | A failed store still delivers the chain, with the error | The registration then fails. | unit | Done | [dps_store_failure_still_delivers_the_chain_with_the_error](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1565) | -| | A provider without a store hook reports not supported | The callback owns the chain; the hub connect proceeds. | unit | Done | [dps_without_a_store_hook_reports_not_supported_to_the_app](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1600) | -| | No store hook and no callback fails the registration | — | unit | Done | [dps_without_a_store_hook_or_callback_fails_the_registration](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1621) | +| | The operational cert callback fires with the issued chain and store result | Fires after the store. | unit | Done | [dps_csr_flow_sends_csr_and_stores_issued_chain](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1538) | +| | A failed store still delivers the chain, with the error | The registration then fails. | unit | Done | [dps_store_failure_still_delivers_the_chain_with_the_error](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1571) | +| | A provider without a store hook reports not supported | The callback owns the chain; the hub connect proceeds. | unit | Done | [dps_without_a_store_hook_reports_not_supported_to_the_app](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1606) | +| | A callback that unregisters itself still handles the chain | The registration proceeds. | unit | Done | [dps_a_callback_that_unregisters_itself_still_handles_the_chain](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1627) | +| | No store hook and no callback fails the registration | — | unit | Done | [dps_without_a_store_hook_or_callback_fails_the_registration](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1649) | | Hub-side renewal | A renewal publishes the issue-certificate topic | `$iothub/credentials/POST/issueCertificate/?$rid=`. | unit | Done | [send_csr_two_phase_delivers_issued_chain](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1189) | | | A 202 reports accepted without completing | — | unit | Done | [send_csr_two_phase_delivers_issued_chain](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1189) | | | A 200 delivers the issued chain | — | unit | Done | [send_csr_two_phase_delivers_issued_chain](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1189) | @@ -889,11 +891,11 @@ runtime renewal against the connected hub over `$iothub/credentials/...` per | | A 412 reports the service code | No matching request to replace. | unit | Done | [a_412_reports_the_service_code](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1581) | | | A 429 surfaces the retry-after hint | The response body carries `retryAfter`. | unit | Done | [a_429_surfaces_the_retry_after_hint](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1607) | | | A malformed issued chain reports a protocol error | 200 whose body has no `certificates` array. | unit | Done | [a_200_without_a_certificates_array_reports_a_failure](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1635) | -| | A provider-made CSR's chain is stored and used on the next connect | `send_csr(NULL)`: CSR from `get_csr()` (subject = device id); stored before the callback; session kept. | unit | Done | [a_provider_csr_chain_is_stored_and_used_on_the_next_connect](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2047) | -| | A failed store keeps the session and the previous credential | Reported in `store_status`. | unit | Done | [a_failed_store_keeps_the_session_and_the_previous_credential](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2074) | -| | An application CSR's chain is not stored | The application owns the key and the chain. | unit | Done | [an_application_csr_chain_is_not_stored](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2094) | -| | Send CSR without a CSR needs a provider that can renew | No provider, a v1 provider, or no `get_csr`: refused before publishing; slot stays free. | unit | Done | [send_csr_without_a_csr_needs_a_provider_that_can_renew](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2129) | -| | A failed get_csr fails send CSR | Nothing published. | unit | Done | [a_failed_get_csr_fails_send_csr](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2149) | +| | A provider-made CSR's chain is stored and used on the next connect | `send_csr(NULL)`: CSR from `get_csr()` (subject = device id); stored before the callback; session kept. | unit | Done | [a_provider_csr_chain_is_stored_and_used_on_the_next_connect](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2075) | +| | A failed store keeps the session and the previous credential | Reported in `store_status`. | unit | Done | [a_failed_store_keeps_the_session_and_the_previous_credential](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2102) | +| | An application CSR's chain is not stored | The application owns the key and the chain. | unit | Done | [an_application_csr_chain_is_not_stored](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2122) | +| | Send CSR without a CSR needs a provider that can renew | No provider, a v1 provider, or no `get_csr`: refused before publishing; slot stays free. | unit | Done | [send_csr_without_a_csr_needs_a_provider_that_can_renew](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2157) | +| | A failed get_csr fails send CSR | Nothing published. | unit | Done | [a_failed_get_csr_fails_send_csr](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2177) | | | A renewal with no response times out | `CSR_OP_TIMEOUT_MS` elapses. | unit | Pending | *connection_client_test.c* | | | A 202 extends the timeout deadline | A slow-but-alive signer is not abandoned. | unit | Pending | *connection_client_test.c* | | | Send CSR while disconnected is refused | — | unit | Done | [send_csr_while_disconnected_is_refused](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1661) | diff --git a/c/src/core/connection_client.c b/c/src/core/connection_client.c index bd81db5dd..93804b2d5 100644 --- a/c/src/core/connection_client.c +++ b/c/src/core/connection_client.c @@ -1617,15 +1617,17 @@ static az_iot_result dps_store_issued_cert(az_iot_connection_client* c, az_span AZ_IOT_LOG_ERRORF("dps: storing the issued certificate failed (%d)", (int)store_rc); } } - if (c->op_cert_cb) + /* Captured first: the callback may unregister itself. */ + az_iot_operational_cert_callback cb = c->op_cert_cb; + if (cb) { - c->op_cert_cb(&issued, store_rc, c->op_cert_cb_ctx); + cb(&issued, store_rc, c->op_cert_cb_ctx); } if (has_store) { return store_rc; } - return c->op_cert_cb ? AZ_IOT_OK : AZ_IOT_ERR_NOT_SUPPORTED; + return cb ? AZ_IOT_OK : AZ_IOT_ERR_NOT_SUPPORTED; } static void on_dps_mqtt_event(const az_iot_mqtt_event* evt, void* user_ctx) diff --git a/c/tests/unit/certificate_provider_managed_test.c b/c/tests/unit/certificate_provider_managed_test.c index d6bcfc172..30f090370 100644 --- a/c/tests/unit/certificate_provider_managed_test.c +++ b/c/tests/unit/certificate_provider_managed_test.c @@ -639,6 +639,37 @@ static void managed_store_refuses_a_chain_with_a_bad_trailing_entry(void** state remove_test_files(); } +/* An empty entry is not a certificate: a matching leaf followed by one is + * refused and the previous chain kept. */ +static void managed_store_refuses_a_chain_with_an_empty_entry(void** state) +{ + (void)state; + remove_test_files(); + az_iot_certificate_provider_managed_options opts = test_options(); + az_iot_certificate_provider_managed prov; + assert_int_equal(AZ_IOT_OK, az_iot_certificate_provider_managed_init(&prov, &opts)); + + char* good = make_cert_base64((EVP_PKEY*)prov.operational_key, 1); + az_span chain1[1] = { az_span_create_from_str(good) }; + az_iot_issued_certificate issued1 = { .certificates = chain1, .count = 1 }; + assert_int_equal(AZ_IOT_OK, prov.base.vtable->store_issued_certificate(&prov.base, &issued1)); + + char* next = make_cert_base64((EVP_PKEY*)prov.operational_key, 2); + az_span chain2[2] = { az_span_create_from_str(next), AZ_SPAN_EMPTY }; + az_iot_issued_certificate issued2 = { .certificates = chain2, .count = 2 }; + assert_int_equal( + AZ_IOT_ERR_INVALID_ARG, prov.base.vtable->store_issued_certificate(&prov.base, &issued2)); + + char* on_disk = read_chain_base64(OP_CERT); + assert_string_equal(on_disk, good); + az_iot_certificate_provider_managed_deinit(&prov); + + free(on_disk); + free(next); + free(good); + remove_test_files(); +} + /* A persisted chain whose trailing entry is truncated is not served after a * restart, even though its leaf is valid. */ static void managed_a_persisted_chain_with_a_truncated_entry_is_not_used(void** state) @@ -815,6 +846,7 @@ int main(void) cmocka_unit_test(managed_store_refuses_a_chain_for_another_key), cmocka_unit_test(managed_a_persisted_chain_for_another_key_is_not_used), cmocka_unit_test(managed_store_refuses_a_chain_with_a_bad_trailing_entry), + cmocka_unit_test(managed_store_refuses_a_chain_with_an_empty_entry), cmocka_unit_test(managed_a_persisted_chain_with_a_truncated_entry_is_not_used), #if !defined(_WIN32) cmocka_unit_test(managed_written_files_are_owner_only), diff --git a/c/tests/unit/connection_client_test.c b/c/tests/unit/connection_client_test.c index 1a880e88c..b195832b6 100644 --- a/c/tests/unit/connection_client_test.c +++ b/c/tests/unit/connection_client_test.c @@ -1428,6 +1428,8 @@ static size_t g_dps_op_cert_chain = 0; static az_iot_result g_dps_op_cert_store_result = AZ_IOT_OK; static int g_dps_store_calls_at_cb = -1; static fake_csr_provider* g_dps_prov = NULL; +/* When set, the callback unregisters itself from this client. */ +static az_iot_connection_client* g_dps_unregister_from = NULL; static void on_dps_op_cert( const az_iot_issued_certificate* issued, az_iot_result store_result, @@ -1438,6 +1440,10 @@ static void on_dps_op_cert( g_dps_op_cert_chain = issued->count; g_dps_op_cert_store_result = store_result; g_dps_store_calls_at_cb = g_dps_prov ? g_dps_prov->store_calls : -1; + if (g_dps_unregister_from) + { + (void)az_iot_connection_client_set_operational_cert_callback(g_dps_unregister_from, NULL, NULL); + } } /* Init a DPS client with request_operational_certificate over @p prov, drive it @@ -1616,6 +1622,28 @@ static void dps_without_a_store_hook_reports_not_supported_to_the_app(void** sta az_iot_connection_client_deinit(&client); } +/* The callback took the chain even if it unregisters itself while running, so + * the registration still proceeds to the hub. */ +static void dps_a_callback_that_unregisters_itself_still_handles_the_chain(void** state) +{ + (void)state; + + fake_csr_provider prov = { 0 }; + prov.base.vtable = &k_fake_csr_vtable_no_store; + az_iot_connection_client client; + uint8_t csr_buf[AZ_IOT_CSR_PAYLOAD_BUFFER_MIN]; + g_dps_unregister_from = &client; + drive_dps_csr_assignment(&client, csr_buf, sizeof(csr_buf), &prov, true); + g_dps_unregister_from = NULL; + + assert_int_equal(g_dps_op_cert_count, 1); + assert_int_equal( + az_iot_connection_client_get_state(&client, AZ_IOT_CONN_SCOPE_HUB), + AZ_IOT_CONN_STATE_CONNECTING); + + az_iot_connection_client_deinit(&client); +} + /* No store hook and no callback: nothing can take the chain, so the * registration fails. */ static void dps_without_a_store_hook_or_callback_fails_the_registration(void** state) @@ -2714,6 +2742,7 @@ int main(void) cmocka_unit_test(dps_csr_flow_sends_csr_and_stores_issued_chain), cmocka_unit_test(dps_store_failure_still_delivers_the_chain_with_the_error), cmocka_unit_test(dps_without_a_store_hook_reports_not_supported_to_the_app), + cmocka_unit_test(dps_a_callback_that_unregisters_itself_still_handles_the_chain), cmocka_unit_test(dps_without_a_store_hook_or_callback_fails_the_registration), cmocka_unit_test(open_rejects_operational_cert_without_payload_buffer), cmocka_unit_test_setup_teardown(send_csr_two_phase_delivers_issued_chain, setup, teardown), From 9d26f1a4ff9d6cab0d3c6af8333e528f444f89af Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Tue, 29 Sep 2026 00:48:59 +0000 Subject: [PATCH 09/17] [C] Coverage doc: re-point anchors into the changed test files Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- c/docs/test-coverage.md | 68 ++++++++++++++++++++--------------------- 1 file changed, 34 insertions(+), 34 deletions(-) diff --git a/c/docs/test-coverage.md b/c/docs/test-coverage.md index 932d27fdf..a74bd895f 100644 --- a/c/docs/test-coverage.md +++ b/c/docs/test-coverage.md @@ -183,22 +183,22 @@ Covers `az_iot_connection_client` lifecycle, CONNACK handling, reconnection, the | | All pending pubacks are completed | Every outstanding publish is reported, not just the first. | unit | Done | [all_pending_pubacks_are_completed_on_disconnect](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_reconnect_test.c#L590) | | | Deinit does not complete pending pubacks | The caller's context may already be gone. | unit | Done | [deinit_does_not_complete_pending_pubacks](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_reconnect_test.c#L641) | | | Keep alive drop is retried like any disconnect | Reaches the core as a plain DISCONNECTED. | unit | Done | [keep_alive_drop_is_retried_like_any_disconnect](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_reconnect_test.c#L165) | -| MQTTv5 presence (v5) | MQTTv5 hub births then connects | CONNACK → sub `dev/#` → SUBACK → birth → birth-ack → CONNECTED. | unit | Done | [hub_mqtt_v5_births_then_connects_on_birth_ack](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L632) | -| | MQTTv5 hub username carries nonce | CONNECT username `correlationId` equals the birth correlation data. | unit | Done | [hub_mqtt_v5_connect_username_carries_correlation_nonce](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L674) | -| | MQTTv5 hub birth reports session present | CONNACK session-present flag encoded in the birth payload. | unit | Done | [hub_mqtt_v5_birth_reports_session_present](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L777) | -| | MQTTv5 hub ignores mismatched birth ack | Stale nonce ignored, handshake stays in CONNECTING. | unit | Done | [hub_mqtt_v5_ignores_mismatched_birth_ack](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L708) | -| | MQTTv5 hub birth ack after close is ignored | A valid birth ack that lands after `close()` was requested must not announce CONNECTED. | unit | Done | [hub_mqtt_v5_birth_ack_after_close_is_ignored](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L739) | -| | MQTTv5 hub ignores wrong type ack | Right nonce, wrong `type` user property → ignored. | unit | Done | [hub_mqtt_v5_ignores_wrong_type_ack](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L825) | -| | MQTTv5 hub suback failure faults | `dev/#` SUBACK error → FAULTED. | unit | Done | [hub_mqtt_v5_suback_failure_faults](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L790) | -| | MQTTv5 hub birth ack timeout faults | No birth-ack within the timeout, no reconnect policy → FAULTED/TIMEOUT. | unit | Done | [hub_mqtt_v5_birth_ack_timeout_faults](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L873) | -| | MQTTv3 skips birth handshake | v3.1.1 contrast: CONNACK → CONNECTED with no presence traffic. | unit | Done | [mqtt_v3_connect_skips_birth_handshake](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L853) | -| | MQTTv5 hub birth publish failure faults | Adapter rejects the birth PUBLISH. | unit | Done | [hub_mqtt_v5_birth_publish_failure_faults](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1323) | -| | MQTTv5 hub birth timeout retries with new nonce | A stale ack must not satisfy the retry. | unit | Done | [hub_mqtt_v5_birth_timeout_retries_with_a_new_nonce](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1402) | -| | MQTTv5 hub without v5 factory not supported | No silent downgrade to an MQTTv3 session. | unit | Done | [hub_mqtt_v5_without_v5_factory_is_not_supported](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1302) | -| | MQTTv5 hub birth ack before suback ignored | No nonce exists yet to match against. | unit | Done | [hub_mqtt_v5_birth_ack_before_suback_is_ignored](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1348) | +| MQTTv5 presence (v5) | MQTTv5 hub births then connects | CONNACK → sub `dev/#` → SUBACK → birth → birth-ack → CONNECTED. | unit | Done | [hub_mqtt_v5_births_then_connects_on_birth_ack](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L640) | +| | MQTTv5 hub username carries nonce | CONNECT username `correlationId` equals the birth correlation data. | unit | Done | [hub_mqtt_v5_connect_username_carries_correlation_nonce](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L681) | +| | MQTTv5 hub birth reports session present | CONNACK session-present flag encoded in the birth payload. | unit | Done | [hub_mqtt_v5_birth_reports_session_present](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L784) | +| | MQTTv5 hub ignores mismatched birth ack | Stale nonce ignored, handshake stays in CONNECTING. | unit | Done | [hub_mqtt_v5_ignores_mismatched_birth_ack](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L715) | +| | MQTTv5 hub birth ack after close is ignored | A valid birth ack that lands after `close()` was requested must not announce CONNECTED. | unit | Done | [hub_mqtt_v5_birth_ack_after_close_is_ignored](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L746) | +| | MQTTv5 hub ignores wrong type ack | Right nonce, wrong `type` user property → ignored. | unit | Done | [hub_mqtt_v5_ignores_wrong_type_ack](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L860) | +| | MQTTv5 hub suback failure faults | `dev/#` SUBACK error → FAULTED. | unit | Done | [hub_mqtt_v5_suback_failure_faults](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L825) | +| | MQTTv5 hub birth ack timeout faults | No birth-ack within the timeout, no reconnect policy → FAULTED/TIMEOUT. | unit | Done | [hub_mqtt_v5_birth_ack_timeout_faults](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1095) | +| | MQTTv3 skips birth handshake | v3.1.1 contrast: CONNACK → CONNECTED with no presence traffic. | unit | Done | [mqtt_v3_connect_skips_birth_handshake](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L888) | +| | MQTTv5 hub birth publish failure faults | Adapter rejects the birth PUBLISH. | unit | Done | [hub_mqtt_v5_birth_publish_failure_faults](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2244) | +| | MQTTv5 hub birth timeout retries with new nonce | A stale ack must not satisfy the retry. | unit | Done | [hub_mqtt_v5_birth_timeout_retries_with_a_new_nonce](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2323) | +| | MQTTv5 hub without v5 factory not supported | No silent downgrade to an MQTTv3 session. | unit | Done | [hub_mqtt_v5_without_v5_factory_is_not_supported](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2223) | +| | MQTTv5 hub birth ack before suback ignored | No nonce exists yet to match against. | unit | Done | [hub_mqtt_v5_birth_ack_before_suback_is_ignored](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2269) | | DPS provisioning | DPS CSR flow stores issued chain | DPS register with CSR → ASSIGNED → operational chain stored. | unit | Done | [dps_csr_flow_sends_csr_and_stores_issued_chain](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1538) | -| | Open rejects operational cert without CSR provider | `request_operational_certificate` set but provider has no `get_csr`. | unit | Done | [open_rejects_operational_cert_without_csr_provider](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L899) | -| | Open rejects operational cert without payload buffer | `request_operational_certificate` set but `csr_payload_buffer` empty. | unit | Done | [open_rejects_operational_cert_without_payload_buffer](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1168) | +| | Open rejects operational cert without CSR provider | `request_operational_certificate` set but provider has no `get_csr`. | unit | Done | [open_rejects_operational_cert_without_csr_provider](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1317) | +| | Open rejects operational cert without payload buffer | `request_operational_certificate` set but `csr_payload_buffer` empty. | unit | Done | [open_rejects_operational_cert_without_payload_buffer](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1727) | | | DPS connects to the global endpoint | Default `global.azure-devices-provisioning.net`. | unit | Done | [dps_connects_to_the_global_endpoint_by_default](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_dps_test.c#L309) | | | DPS honors a custom global endpoint | `dps.global_endpoint` override. | unit | Done | [dps_honors_a_custom_global_endpoint](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_dps_test.c#L319) | | | DPS uses v3 1 1 even for an MQTTv5 hub | `connection_profile = AZ_IOT_CONNECTION_PROFILE_MQTT_V5`; the v5 factory is left untouched. | unit | Done | [dps_uses_v3_1_1_even_when_the_hub_is_mqtt_v5](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_dps_test.c#L386) | @@ -869,28 +869,28 @@ runtime renewal against the connected hub over `$iothub/credentials/...` per | | Deinit through the vtable destroys the provider | What a generic owner of an `az_iot_certificate_provider` calls. | unit | Done | [managed_deinit_through_the_vtable_destroys_the_provider](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L371) | | Issuance via DPS | The CSR rides the DPS registration body | — | unit | Done | [dps_csr_flow_sends_csr_and_stores_issued_chain](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1538) | | | The issued chain is handed to the provider | — | unit | Done | [dps_csr_flow_sends_csr_and_stores_issued_chain](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1538) | -| | Open is refused without a CSR-capable provider | — | unit | Done | [open_rejects_operational_cert_without_csr_provider](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L899) | -| | Open is refused without a payload buffer | — | unit | Done | [open_rejects_operational_cert_without_payload_buffer](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1168) | +| | Open is refused without a CSR-capable provider | — | unit | Done | [open_rejects_operational_cert_without_csr_provider](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1317) | +| | Open is refused without a payload buffer | — | unit | Done | [open_rejects_operational_cert_without_payload_buffer](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1727) | | | The DPS username carries the CSR API version | The CSR flow needs a newer api-version than the azure-sdk-for-c default. | unit | Pending | *connection_client_test.c* | | | The operational cert callback fires with the issued chain and store result | Fires after the store. | unit | Done | [dps_csr_flow_sends_csr_and_stores_issued_chain](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1538) | | | A failed store still delivers the chain, with the error | The registration then fails. | unit | Done | [dps_store_failure_still_delivers_the_chain_with_the_error](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1571) | | | A provider without a store hook reports not supported | The callback owns the chain; the hub connect proceeds. | unit | Done | [dps_without_a_store_hook_reports_not_supported_to_the_app](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1606) | | | A callback that unregisters itself still handles the chain | The registration proceeds. | unit | Done | [dps_a_callback_that_unregisters_itself_still_handles_the_chain](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1627) | | | No store hook and no callback fails the registration | — | unit | Done | [dps_without_a_store_hook_or_callback_fails_the_registration](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1649) | -| Hub-side renewal | A renewal publishes the issue-certificate topic | `$iothub/credentials/POST/issueCertificate/?$rid=`. | unit | Done | [send_csr_two_phase_delivers_issued_chain](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1189) | -| | A 202 reports accepted without completing | — | unit | Done | [send_csr_two_phase_delivers_issued_chain](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1189) | -| | A 200 delivers the issued chain | — | unit | Done | [send_csr_two_phase_delivers_issued_chain](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1189) | -| | An error status reports the service code | 409 conflict with an `errorCode` body. | unit | Done | [send_csr_error_reports_service_code](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1247) | -| | Only one renewal is in flight at a time | A second `send_csr()` is refused while one is open. | unit | Done | [send_csr_cancel_frees_slot](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1272) | -| | Cancel frees the slot for a new renewal | — | unit | Done | [send_csr_cancel_frees_slot](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1272) | -| | The renewal subscribes the credentials response filter | `$iothub/credentials/res/#`. | unit | Done | [send_csr_subscribes_the_credentials_response_filter](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1461) | -| | The request body carries the device id and CSR | — | unit | Done | [send_csr_two_phase_delivers_issued_chain](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1189) | -| | The optional replace field is emitted only when supplied | `"replace":"*"`. | unit | Done | [send_csr_emits_the_replace_field_only_when_supplied](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1501) | -| | A response for a different rid is ignored | — | unit | Done | [a_credentials_response_for_a_different_rid_is_ignored](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1532) | -| | A 400 reports the service code | Invalid request payload. | unit | Done | [a_400_reports_the_service_code](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1555) | -| | A 412 reports the service code | No matching request to replace. | unit | Done | [a_412_reports_the_service_code](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1581) | -| | A 429 surfaces the retry-after hint | The response body carries `retryAfter`. | unit | Done | [a_429_surfaces_the_retry_after_hint](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1607) | -| | A malformed issued chain reports a protocol error | 200 whose body has no `certificates` array. | unit | Done | [a_200_without_a_certificates_array_reports_a_failure](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1635) | +| Hub-side renewal | A renewal publishes the issue-certificate topic | `$iothub/credentials/POST/issueCertificate/?$rid=`. | unit | Done | [send_csr_two_phase_delivers_issued_chain](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1748) | +| | A 202 reports accepted without completing | — | unit | Done | [send_csr_two_phase_delivers_issued_chain](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1748) | +| | A 200 delivers the issued chain | — | unit | Done | [send_csr_two_phase_delivers_issued_chain](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1748) | +| | An error status reports the service code | 409 conflict with an `errorCode` body. | unit | Done | [send_csr_error_reports_service_code](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1806) | +| | Only one renewal is in flight at a time | A second `send_csr()` is refused while one is open. | unit | Done | [send_csr_cancel_frees_slot](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2193) | +| | Cancel frees the slot for a new renewal | — | unit | Done | [send_csr_cancel_frees_slot](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2193) | +| | The renewal subscribes the credentials response filter | `$iothub/credentials/res/#`. | unit | Done | [send_csr_subscribes_the_credentials_response_filter](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2382) | +| | The request body carries the device id and CSR | — | unit | Done | [send_csr_two_phase_delivers_issued_chain](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1748) | +| | The optional replace field is emitted only when supplied | `"replace":"*"`. | unit | Done | [send_csr_emits_the_replace_field_only_when_supplied](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2422) | +| | A response for a different rid is ignored | — | unit | Done | [a_credentials_response_for_a_different_rid_is_ignored](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2453) | +| | A 400 reports the service code | Invalid request payload. | unit | Done | [a_400_reports_the_service_code](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2476) | +| | A 412 reports the service code | No matching request to replace. | unit | Done | [a_412_reports_the_service_code](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2502) | +| | A 429 surfaces the retry-after hint | The response body carries `retryAfter`. | unit | Done | [a_429_surfaces_the_retry_after_hint](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2528) | +| | A malformed issued chain reports a protocol error | 200 whose body has no `certificates` array. | unit | Done | [a_200_without_a_certificates_array_reports_a_failure](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2556) | | | A provider-made CSR's chain is stored and used on the next connect | `send_csr(NULL)`: CSR from `get_csr()` (subject = device id); stored before the callback; session kept. | unit | Done | [a_provider_csr_chain_is_stored_and_used_on_the_next_connect](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2075) | | | A failed store keeps the session and the previous credential | Reported in `store_status`. | unit | Done | [a_failed_store_keeps_the_session_and_the_previous_credential](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2102) | | | An application CSR's chain is not stored | The application owns the key and the chain. | unit | Done | [an_application_csr_chain_is_not_stored](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2122) | @@ -898,9 +898,9 @@ runtime renewal against the connected hub over `$iothub/credentials/...` per | | A failed get_csr fails send CSR | Nothing published. | unit | Done | [a_failed_get_csr_fails_send_csr](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2177) | | | A renewal with no response times out | `CSR_OP_TIMEOUT_MS` elapses. | unit | Pending | *connection_client_test.c* | | | A 202 extends the timeout deadline | A slow-but-alive signer is not abandoned. | unit | Pending | *connection_client_test.c* | -| | Send CSR while disconnected is refused | — | unit | Done | [send_csr_while_disconnected_is_refused](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1661) | +| | Send CSR while disconnected is refused | — | unit | Done | [send_csr_while_disconnected_is_refused](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2582) | | | A renewal open across a reconnect is completed or failed | Same family as C-3 / D-3. | unit | Pending | *connection_client_test.c* | -| | A CSR larger than the service cap is refused | `CSR_MAX_BASE64` is 8 KB. | unit | Done | [a_csr_larger_than_the_service_cap_is_refused](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1681) | +| | A CSR larger than the service cap is refused | `CSR_MAX_BASE64` is 8 KB. | unit | Done | [a_csr_larger_than_the_service_cap_is_refused](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2602) | | | Send CSR rejects null arguments | — | unit | Pending | *connection_client_test.c* | | | Send CSR on an MQTTv5 session is not supported | The MQTTv5 renewal topic space is not defined yet, so the call is refused rather than publishing to an MQTTv3 topic from a v5 session. | unit | Pending | *connection_client_test.c* | | | Send CSR without a payload buffer reports not enough space | — | unit | Pending | *connection_client_test.c* | @@ -1030,7 +1030,7 @@ own, so they are unit-tested only. | | Stderr sink honours its minimum level | Below the threshold is dropped before the sink is reached. | unit | Done | [stderr_sink_honours_its_minimum_level](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/diagnostics_test.c#L308) | | | Gen request id fills a bounded string | NUL-terminated inside the buffer, printable throughout. | unit | Done | [gen_request_id_fills_a_bounded_string](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/diagnostics_test.c#L322) | | | Gen request id advances the rng | Two ids from one state differ, or a renewal retry would reuse an id the service already answered. | unit | Done | [gen_request_id_advances_the_rng](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/diagnostics_test.c#L346) | -| Certificate helpers | A generated request id is fresh each time | Repeating one would make the service treat a new renewal as a duplicate. | unit | Done | [cert_util_generates_a_distinct_request_id](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1711) | +| Certificate helpers | A generated request id is fresh each time | Repeating one would make the service treat a new renewal as a duplicate. | unit | Done | [cert_util_generates_a_distinct_request_id](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2632) | ## Test infrastructure From 104e6e6819473e868d901c3f005d1051d2dfaf9f Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Tue, 29 Sep 2026 01:48:02 +0000 Subject: [PATCH 10/17] [C] Managed provider: exact 0600 files; refuse text outside the chain - fchmod(0600) after mkstemp(), whose mode is reduced by the umask; a 0777 umask produced unreadable key/chain files. - A chain is accepted only if it holds nothing but PEM certificate blocks and whitespace; PEM_read_bio_X509() skips other text, so a valid chain followed by it was accepted. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../az_iot_certificate_provider_managed.c | 119 ++++++++++++++---- c/docs/test-coverage.md | 9 +- .../unit/certificate_provider_managed_test.c | 106 +++++++++++++--- 3 files changed, 190 insertions(+), 44 deletions(-) diff --git a/c/adapters/cert_openssl/az_iot_certificate_provider_managed.c b/c/adapters/cert_openssl/az_iot_certificate_provider_managed.c index 63fb3179b..bc2de1512 100644 --- a/c/adapters/cert_openssl/az_iot_certificate_provider_managed.c +++ b/c/adapters/cert_openssl/az_iot_certificate_provider_managed.c @@ -15,6 +15,7 @@ #include "az_iot_certificate_provider_managed.h" +#include #include #include #include @@ -29,6 +30,7 @@ #include #else #include +#include #include #endif @@ -208,12 +210,19 @@ static az_iot_result write_file_private(const char* path, const char* data, size } memcpy(tmp, path, path_len); memcpy(tmp + path_len, MANAGED_TMP_TEMPLATE, sizeof(MANAGED_TMP_TEMPLATE)); - int fd = mkstemp(tmp); /* O_CREAT|O_EXCL, mode 0600 */ + int fd = mkstemp(tmp); /* O_CREAT|O_EXCL; its 0600 is reduced by the umask */ if (fd < 0) { free(tmp); return AZ_IOT_ERR_INTERNAL; } + if (fchmod(fd, S_IRUSR | S_IWUSR) != 0) /* exactly 0600, whatever the umask */ + { + (void)close(fd); + (void)unlink(tmp); + free(tmp); + return AZ_IOT_ERR_INTERNAL; + } bool ok = true; size_t off = 0; while (ok && off < len) @@ -270,19 +279,78 @@ static az_iot_result write_key_file(const char* path, EVP_PKEY* key) } /** - * @brief True when @p b holds at least one PEM certificate, every one parses, - * and the first (the leaf) certifies @p key. + * @brief Count the PEM certificate blocks in @p data, requiring that nothing + * but whitespace lies outside them. * - * Reading stops at the first object that is not a certificate; the chain is - * accepted only if that is the end of the input, so a malformed or truncated - * entry anywhere in the chain rejects it. + * PEM_read_bio_X509() skips any text before a BEGIN line, so on its own it + * would accept a chain followed by unrelated bytes. + * + * @return The number of blocks, or 0 if there is other text or an unclosed + * block. + */ +static size_t count_pem_certificates(const char* data, size_t len) +{ + static const char begin[] = "-----BEGIN CERTIFICATE-----"; + static const char end[] = "-----END CERTIFICATE-----"; + size_t blocks = 0; + bool inside = false; + size_t pos = 0; + while (pos < len) + { + size_t eol = pos; + while (eol < len && data[eol] != '\n') + { + eol++; + } + size_t line_end = eol; + while ( + line_end > pos + && (data[line_end - 1] == '\r' || data[line_end - 1] == ' ' || data[line_end - 1] == '\t')) + { + line_end--; + } + size_t line_len = line_end - pos; + const char* line = data + pos; + if (inside) + { + if (line_len == sizeof(end) - 1 && memcmp(line, end, line_len) == 0) + { + inside = false; + blocks++; + } + } + else if (line_len == sizeof(begin) - 1 && memcmp(line, begin, line_len) == 0) + { + inside = true; + } + else + { + for (size_t i = 0; i < line_len; ++i) + { + if (line[i] != ' ' && line[i] != '\t') + { + return 0; + } + } + } + pos = eol + 1; + } + return inside ? 0 : blocks; +} + +/** + * @brief True when @p data is a PEM chain of at least one certificate with + * nothing else in it, every certificate parses, and the first (the leaf) + * certifies @p key. * * @param[out] out_count Certificates parsed; may be NULL. */ -static bool chain_matches_key(BIO* b, EVP_PKEY* key, size_t* out_count) +static bool chain_matches_key(const char* data, size_t len, EVP_PKEY* key, size_t* out_count) { size_t count = 0; - bool ok = key != NULL; + size_t blocks = (data && len <= INT_MAX) ? count_pem_certificates(data, len) : 0; + BIO* b = blocks > 0 ? BIO_new_mem_buf(data, (int)len) : NULL; + bool ok = b != NULL && key != NULL; X509* cert = NULL; while (ok && (cert = PEM_read_bio_X509(b, NULL, NULL, NULL)) != NULL) { @@ -293,17 +361,13 @@ static bool chain_matches_key(BIO* b, EVP_PKEY* key, size_t* out_count) X509_free(cert); count++; } - unsigned long err = ERR_peek_last_error(); - if (ok && !(ERR_GET_LIB(err) == ERR_LIB_PEM && ERR_GET_REASON(err) == PEM_R_NO_START_LINE)) - { - ok = false; - } + BIO_free(b); ERR_clear_error(); if (out_count) { *out_count = count; } - return ok && count > 0; + return ok && count == blocks; } /** @@ -315,14 +379,24 @@ static bool chain_matches_key(BIO* b, EVP_PKEY* key, size_t* out_count) */ static bool operational_cert_is_valid(const char* path, EVP_PKEY* key) { - BIO* b = BIO_new_file(path, "rb"); - if (!b) + BIO* in = BIO_new_file(path, "rb"); + BIO* mem = in ? BIO_new(BIO_s_mem()) : NULL; + bool ok = mem != NULL; + char buf[1024]; + int n = 0; + while (ok && (n = BIO_read(in, buf, (int)sizeof(buf))) > 0) { - ERR_clear_error(); - return false; + ok = BIO_write(mem, buf, n) == n; } - bool ok = chain_matches_key(b, key, NULL); - BIO_free(b); + if (ok) + { + char* data = NULL; + long len = BIO_get_mem_data(mem, &data); + ok = n == 0 && len > 0 && chain_matches_key(data, (size_t)len, key, NULL); + } + BIO_free(mem); + BIO_free(in); + ERR_clear_error(); return ok; } @@ -627,14 +701,13 @@ static az_iot_result managed_store( { char* data = NULL; long data_len = BIO_get_mem_data(mem, &data); - BIO* check = (data_len > 0) ? BIO_new_mem_buf(data, (int)data_len) : NULL; size_t parsed = 0; - if (!check || !chain_matches_key(check, (EVP_PKEY*)m->operational_key, &parsed) + if (data_len <= 0 + || !chain_matches_key(data, (size_t)data_len, (EVP_PKEY*)m->operational_key, &parsed) || parsed != written) { rc = AZ_IOT_ERR_INVALID_ARG; } - BIO_free(check); } if (rc == AZ_IOT_OK) { diff --git a/c/docs/test-coverage.md b/c/docs/test-coverage.md index a74bd895f..f7eca4f8f 100644 --- a/c/docs/test-coverage.md +++ b/c/docs/test-coverage.md @@ -852,14 +852,15 @@ runtime renewal against the connected hub over `$iothub/credentials/...` per | | A stored chain that is not a certificate is refused | The store validates the chain before replacing the file; nothing is written and no operational identity is claimed. | unit | Done | [managed_a_stored_chain_that_is_not_a_certificate_is_refused](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L508) | | | A failed store keeps the previous certificate | All-or-nothing, in-process and after a restart, so a failed renewal cannot break the next connect. | unit | Done | [managed_a_failed_store_keeps_the_previous_certificate](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L538) | | | A chain for another key is refused | The leaf must certify the operational key; the previous chain is kept. | unit | Done | [managed_store_refuses_a_chain_for_another_key](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L576) | -| | A persisted chain for another key is not used | After the key is replaced, the stored chain no longer certifies it. | unit | Done | [managed_a_persisted_chain_for_another_key_is_not_used](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L711) | +| | A persisted chain for another key is not used | After the key is replaced, the stored chain no longer certifies it. | unit | Done | [managed_a_persisted_chain_for_another_key_is_not_used](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L773) | | | A chain with a bad trailing entry is refused | Every entry must parse; the previous chain is kept. | unit | Done | [managed_store_refuses_a_chain_with_a_bad_trailing_entry](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L611) | | | A chain with an empty entry is refused | — | unit | Done | [managed_store_refuses_a_chain_with_an_empty_entry](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L644) | | | A persisted chain with a truncated entry is not used | — | unit | Done | [managed_a_persisted_chain_with_a_truncated_entry_is_not_used](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L675) | -| | Written files are owner-only | Key and chain are 0600 whatever the umask. POSIX only; the Windows owner-only DACL is not tested. | unit | Done | [managed_written_files_are_owner_only](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L751) | -| | A link at a destination is replaced, not followed | Key and chain. POSIX only. | unit | Done | [managed_writes_replace_a_link_instead_of_following_it](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L774) | +| | A chain followed by other text is refused | In an issued entry or appended to the file. | unit | Done | [managed_a_chain_followed_by_other_text_is_refused](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L723) | +| | Written files are owner-only | Key and chain are exactly 0600 whatever the umask, including 0777, and reload. POSIX only; the Windows owner-only DACL is not tested. | unit | Done | [managed_written_files_are_owner_only](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L814) | +| | A link at a destination is replaced, not followed | Key and chain. POSIX only. | unit | Done | [managed_writes_replace_a_link_instead_of_following_it](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L845) | | | Store overwrites a previously issued chain | Renewal replaces rather than appends. | unit | Done | [managed_store_overwrites_a_previously_issued_chain](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L477) | -| | The sign hook is not offered by either bundled provider | Neither provider sets `sign`, so the connect path must keep checking it for NULL. Pinned so that adding an implementation is a deliberate act. | unit | Done | [the_sign_hook_is_not_offered_by_this_provider](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L816) | +| | The sign hook is not offered by either bundled provider | Neither provider sets `sign`, so the connect path must keep checking it for NULL. Pinned so that adding an implementation is a deliberate act. | unit | Done | [the_sign_hook_is_not_offered_by_this_provider](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L887) | | | An operational load with no stored chain returns not found | The connect path uses this code to decide it must still enroll. Belongs to the managed provider: the PEM loader ignores the role. | unit | Done | [managed_operational_load_without_a_stored_chain_is_not_found](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L276) | | | Load rejects null arguments | — | unit | Done | [managed_load_rejects_null_arguments](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L255) | | | Release leaves the material usable | The paths are borrowed from the provider struct, so freeing them here would dangle what the connection is still using. | unit | Done | [managed_release_leaves_the_material_usable](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L296) | diff --git a/c/tests/unit/certificate_provider_managed_test.c b/c/tests/unit/certificate_provider_managed_test.c index 30f090370..e0e094b90 100644 --- a/c/tests/unit/certificate_provider_managed_test.c +++ b/c/tests/unit/certificate_provider_managed_test.c @@ -706,6 +706,68 @@ static void managed_a_persisted_chain_with_a_truncated_entry_is_not_used(void** remove_test_files(); } +/* Base64 of @p text (heap; caller frees). */ +static char* base64_of(const char* text) +{ + size_t n = strlen(text); + char* out = malloc(((n + 2) / 3) * 4 + 1); + assert_non_null(out); + int len = EVP_EncodeBlock((unsigned char*)out, (const unsigned char*)text, (int)n); + assert_true(len > 0); + out[len] = '\0'; + return out; +} + +/* Text after the chain that is not a certificate is refused, whether it rides + * in an issued entry or is appended to the persisted file. */ +static void managed_a_chain_followed_by_other_text_is_refused(void** state) +{ + (void)state; + remove_test_files(); + az_iot_certificate_provider_managed_options opts = test_options(); + az_iot_certificate_provider_managed prov; + assert_int_equal(AZ_IOT_OK, az_iot_certificate_provider_managed_init(&prov, &opts)); + char* good = make_cert_base64((EVP_PKEY*)prov.operational_key, 1); + az_span chain1[1] = { az_span_create_from_str(good) }; + az_iot_issued_certificate issued1 = { .certificates = chain1, .count = 1 }; + assert_int_equal(AZ_IOT_OK, prov.base.vtable->store_issued_certificate(&prov.base, &issued1)); + + /* An entry that is already PEM is written through as is. */ + char* next = make_cert_base64((EVP_PKEY*)prov.operational_key, 2); + size_t pem_cap = strlen(next) + 128; + char* pem = malloc(pem_cap); + assert_non_null(pem); + (void)snprintf( + pem, + pem_cap, + "-----BEGIN CERTIFICATE-----\n%s\n-----END CERTIFICATE-----\nnot a certificate\n", + next); + char* entry = base64_of(pem); + az_span chain2[1] = { az_span_create_from_str(entry) }; + az_iot_issued_certificate issued2 = { .certificates = chain2, .count = 1 }; + assert_int_equal( + AZ_IOT_ERR_INVALID_ARG, prov.base.vtable->store_issued_certificate(&prov.base, &issued2)); + char* on_disk = read_chain_base64(OP_CERT); + assert_string_equal(on_disk, good); + az_iot_certificate_provider_managed_deinit(&prov); + + FILE* fp = fopen(OP_CERT, "ab"); + assert_non_null(fp); + assert_true(fputs("not a certificate\n", fp) >= 0); + assert_int_equal(0, fclose(fp)); + az_iot_certificate_provider_managed prov2; + assert_int_equal(AZ_IOT_OK, az_iot_certificate_provider_managed_init(&prov2, &opts)); + assert_false(prov2.has_operational); + az_iot_certificate_provider_managed_deinit(&prov2); + + free(on_disk); + free(entry); + free(pem); + free(next); + free(good); + remove_test_files(); +} + /* A chain on disk that no longer matches the key (the key was replaced) is not * served as the operational identity after a restart. */ static void managed_a_persisted_chain_for_another_key_is_not_used(void** state) @@ -747,26 +809,35 @@ static mode_t file_mode(const char* path) return st.st_mode; } -/* The key and chain are readable only by their owner, whatever the umask. */ +/* The key and chain are exactly 0600 whatever the umask, including one that + * removes the owner's own bits, and reload after a restart. */ static void managed_written_files_are_owner_only(void** state) { (void)state; - remove_test_files(); - mode_t old_umask = umask(022); - az_iot_certificate_provider_managed_options opts = test_options(); - az_iot_certificate_provider_managed prov; - assert_int_equal(AZ_IOT_OK, az_iot_certificate_provider_managed_init(&prov, &opts)); - assert_int_equal(0600, file_mode(OP_KEY) & 0777); - - char* good = make_cert_base64((EVP_PKEY*)prov.operational_key, 1); - az_span chain[1] = { az_span_create_from_str(good) }; - az_iot_issued_certificate issued = { .certificates = chain, .count = 1 }; - assert_int_equal(AZ_IOT_OK, prov.base.vtable->store_issued_certificate(&prov.base, &issued)); - assert_int_equal(0600, file_mode(OP_CERT) & 0777); - az_iot_certificate_provider_managed_deinit(&prov); - - (void)umask(old_umask); - free(good); + static const mode_t masks[] = { 022, 0777 }; + for (size_t i = 0; i < sizeof(masks) / sizeof(masks[0]); ++i) + { + remove_test_files(); + mode_t old_umask = umask(masks[i]); + az_iot_certificate_provider_managed_options opts = test_options(); + az_iot_certificate_provider_managed prov; + assert_int_equal(AZ_IOT_OK, az_iot_certificate_provider_managed_init(&prov, &opts)); + assert_int_equal(0600, file_mode(OP_KEY) & 0777); + + char* good = make_cert_base64((EVP_PKEY*)prov.operational_key, 1); + az_span chain[1] = { az_span_create_from_str(good) }; + az_iot_issued_certificate issued = { .certificates = chain, .count = 1 }; + assert_int_equal(AZ_IOT_OK, prov.base.vtable->store_issued_certificate(&prov.base, &issued)); + assert_int_equal(0600, file_mode(OP_CERT) & 0777); + az_iot_certificate_provider_managed_deinit(&prov); + (void)umask(old_umask); + + az_iot_certificate_provider_managed prov2; + assert_int_equal(AZ_IOT_OK, az_iot_certificate_provider_managed_init(&prov2, &opts)); + assert_true(prov2.has_operational); + az_iot_certificate_provider_managed_deinit(&prov2); + free(good); + } remove_test_files(); } @@ -848,6 +919,7 @@ int main(void) cmocka_unit_test(managed_store_refuses_a_chain_with_a_bad_trailing_entry), cmocka_unit_test(managed_store_refuses_a_chain_with_an_empty_entry), cmocka_unit_test(managed_a_persisted_chain_with_a_truncated_entry_is_not_used), + cmocka_unit_test(managed_a_chain_followed_by_other_text_is_refused), #if !defined(_WIN32) cmocka_unit_test(managed_written_files_are_owner_only), cmocka_unit_test(managed_writes_replace_a_link_instead_of_following_it), From 697b484e48f6cd2428eed0aa1e5d2c492a2c85ed Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Tue, 29 Sep 2026 04:16:57 +0000 Subject: [PATCH 11/17] [C] Require the provider to store DPS-issued chains; Windows DACL test - open() with dps.request_operational_certificate now requires a provider with get_csr, release_csr and store_issued_certificate. The issued chain is always stored in the provider (failure fails the registration); the callback-only path is removed. - send_csr() docs list the exact hooks behind AZ_IOT_ERR_NOT_SUPPORTED. - Sample provider writes through a uniquely named, exclusively created temp file (0600 on POSIX) instead of a fixed ".tmp". - Windows unit test checks the key and chain DACLs (protected; Owner Rights and SYSTEM only). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- c/docs/test-coverage.md | 112 +++++++++--------- c/inc/azure/iot/az_iot_connection_client.h | 21 ++-- c/samples/common/sample_cert_provider.c | 104 +++++++++++++--- c/src/core/connection_client.c | 38 +++--- c/tests/CMakeLists.txt | 4 + .../unit/certificate_provider_managed_test.c | 82 +++++++++++++ c/tests/unit/connection_client_test.c | 98 ++++++++------- 7 files changed, 303 insertions(+), 156 deletions(-) diff --git a/c/docs/test-coverage.md b/c/docs/test-coverage.md index f7eca4f8f..ead860464 100644 --- a/c/docs/test-coverage.md +++ b/c/docs/test-coverage.md @@ -192,13 +192,13 @@ Covers `az_iot_connection_client` lifecycle, CONNACK handling, reconnection, the | | MQTTv5 hub suback failure faults | `dev/#` SUBACK error → FAULTED. | unit | Done | [hub_mqtt_v5_suback_failure_faults](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L825) | | | MQTTv5 hub birth ack timeout faults | No birth-ack within the timeout, no reconnect policy → FAULTED/TIMEOUT. | unit | Done | [hub_mqtt_v5_birth_ack_timeout_faults](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1095) | | | MQTTv3 skips birth handshake | v3.1.1 contrast: CONNACK → CONNECTED with no presence traffic. | unit | Done | [mqtt_v3_connect_skips_birth_handshake](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L888) | -| | MQTTv5 hub birth publish failure faults | Adapter rejects the birth PUBLISH. | unit | Done | [hub_mqtt_v5_birth_publish_failure_faults](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2244) | -| | MQTTv5 hub birth timeout retries with new nonce | A stale ack must not satisfy the retry. | unit | Done | [hub_mqtt_v5_birth_timeout_retries_with_a_new_nonce](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2323) | -| | MQTTv5 hub without v5 factory not supported | No silent downgrade to an MQTTv3 session. | unit | Done | [hub_mqtt_v5_without_v5_factory_is_not_supported](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2223) | -| | MQTTv5 hub birth ack before suback ignored | No nonce exists yet to match against. | unit | Done | [hub_mqtt_v5_birth_ack_before_suback_is_ignored](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2269) | +| | MQTTv5 hub birth publish failure faults | Adapter rejects the birth PUBLISH. | unit | Done | [hub_mqtt_v5_birth_publish_failure_faults](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2243) | +| | MQTTv5 hub birth timeout retries with new nonce | A stale ack must not satisfy the retry. | unit | Done | [hub_mqtt_v5_birth_timeout_retries_with_a_new_nonce](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2322) | +| | MQTTv5 hub without v5 factory not supported | No silent downgrade to an MQTTv3 session. | unit | Done | [hub_mqtt_v5_without_v5_factory_is_not_supported](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2222) | +| | MQTTv5 hub birth ack before suback ignored | No nonce exists yet to match against. | unit | Done | [hub_mqtt_v5_birth_ack_before_suback_is_ignored](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2268) | | DPS provisioning | DPS CSR flow stores issued chain | DPS register with CSR → ASSIGNED → operational chain stored. | unit | Done | [dps_csr_flow_sends_csr_and_stores_issued_chain](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1538) | | | Open rejects operational cert without CSR provider | `request_operational_certificate` set but provider has no `get_csr`. | unit | Done | [open_rejects_operational_cert_without_csr_provider](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1317) | -| | Open rejects operational cert without payload buffer | `request_operational_certificate` set but `csr_payload_buffer` empty. | unit | Done | [open_rejects_operational_cert_without_payload_buffer](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1727) | +| | Open rejects operational cert without payload buffer | `request_operational_certificate` set but `csr_payload_buffer` empty. | unit | Done | [open_rejects_operational_cert_without_payload_buffer](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1726) | | | DPS connects to the global endpoint | Default `global.azure-devices-provisioning.net`. | unit | Done | [dps_connects_to_the_global_endpoint_by_default](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_dps_test.c#L309) | | | DPS honors a custom global endpoint | `dps.global_endpoint` override. | unit | Done | [dps_honors_a_custom_global_endpoint](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_dps_test.c#L319) | | | DPS uses v3 1 1 even for an MQTTv5 hub | `connection_profile = AZ_IOT_CONNECTION_PROFILE_MQTT_V5`; the v5 factory is left untouched. | unit | Done | [dps_uses_v3_1_1_even_when_the_hub_is_mqtt_v5](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_dps_test.c#L386) | @@ -842,66 +842,66 @@ runtime renewal against the connected hub over `$iothub/credentials/...` per | | An operational load returns the same static material | This provider holds one identity and ignores the role; the connect path is what falls back to bootstrap. | unit | Done | [test_load_of_an_operational_credential_returns_the_static_material](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_pem_test.c#L247) | | | Deinit through the vtable destroys the provider | — | unit | Done | [test_deinit_through_the_vtable_destroys_the_provider](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_pem_test.c#L296) | | | Init fails when a named CA cannot be read | The CA path is optional, but connecting without the trust anchor the operator asked for is worse than not connecting. | unit | Done | [test_create_fails_when_the_named_ca_is_missing](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_pem_test.c#L331) | -| Managed provider | Init generates a key | — | unit | Done | [managed_init_generates_key_and_valid_csr](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L105) | -| | Init emits a parseable CSR | — | unit | Done | [managed_init_generates_key_and_valid_csr](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L105) | -| | The CSR subject carries the registration id | — | unit | Done | [managed_init_generates_key_and_valid_csr](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L105) | -| | Init rejects bad args | — | unit | Done | [managed_init_rejects_bad_args](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L221) | -| | Store persists the issued chain | — | unit | Done | [managed_store_persists_and_survives_restart](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L166) | -| | The persisted chain reloads after a restart | — | unit | Done | [managed_store_persists_and_survives_restart](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L166) | +| Managed provider | Init generates a key | — | unit | Done | [managed_init_generates_key_and_valid_csr](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L106) | +| | Init emits a parseable CSR | — | unit | Done | [managed_init_generates_key_and_valid_csr](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L106) | +| | The CSR subject carries the registration id | — | unit | Done | [managed_init_generates_key_and_valid_csr](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L106) | +| | Init rejects bad args | — | unit | Done | [managed_init_rejects_bad_args](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L222) | +| | Store persists the issued chain | — | unit | Done | [managed_store_persists_and_survives_restart](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L167) | +| | The persisted chain reloads after a restart | — | unit | Done | [managed_store_persists_and_survives_restart](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L167) | | | A CSR buffer that is too small is reported | — | unit | Pending | *certificate_provider_managed_test.c* | -| | A stored chain that is not a certificate is refused | The store validates the chain before replacing the file; nothing is written and no operational identity is claimed. | unit | Done | [managed_a_stored_chain_that_is_not_a_certificate_is_refused](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L508) | -| | A failed store keeps the previous certificate | All-or-nothing, in-process and after a restart, so a failed renewal cannot break the next connect. | unit | Done | [managed_a_failed_store_keeps_the_previous_certificate](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L538) | -| | A chain for another key is refused | The leaf must certify the operational key; the previous chain is kept. | unit | Done | [managed_store_refuses_a_chain_for_another_key](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L576) | -| | A persisted chain for another key is not used | After the key is replaced, the stored chain no longer certifies it. | unit | Done | [managed_a_persisted_chain_for_another_key_is_not_used](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L773) | -| | A chain with a bad trailing entry is refused | Every entry must parse; the previous chain is kept. | unit | Done | [managed_store_refuses_a_chain_with_a_bad_trailing_entry](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L611) | -| | A chain with an empty entry is refused | — | unit | Done | [managed_store_refuses_a_chain_with_an_empty_entry](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L644) | -| | A persisted chain with a truncated entry is not used | — | unit | Done | [managed_a_persisted_chain_with_a_truncated_entry_is_not_used](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L675) | -| | A chain followed by other text is refused | In an issued entry or appended to the file. | unit | Done | [managed_a_chain_followed_by_other_text_is_refused](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L723) | -| | Written files are owner-only | Key and chain are exactly 0600 whatever the umask, including 0777, and reload. POSIX only; the Windows owner-only DACL is not tested. | unit | Done | [managed_written_files_are_owner_only](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L814) | -| | A link at a destination is replaced, not followed | Key and chain. POSIX only. | unit | Done | [managed_writes_replace_a_link_instead_of_following_it](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L845) | -| | Store overwrites a previously issued chain | Renewal replaces rather than appends. | unit | Done | [managed_store_overwrites_a_previously_issued_chain](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L477) | -| | The sign hook is not offered by either bundled provider | Neither provider sets `sign`, so the connect path must keep checking it for NULL. Pinned so that adding an implementation is a deliberate act. | unit | Done | [the_sign_hook_is_not_offered_by_this_provider](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L887) | -| | An operational load with no stored chain returns not found | The connect path uses this code to decide it must still enroll. Belongs to the managed provider: the PEM loader ignores the role. | unit | Done | [managed_operational_load_without_a_stored_chain_is_not_found](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L276) | -| | Load rejects null arguments | — | unit | Done | [managed_load_rejects_null_arguments](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L255) | -| | Release leaves the material usable | The paths are borrowed from the provider struct, so freeing them here would dangle what the connection is still using. | unit | Done | [managed_release_leaves_the_material_usable](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L296) | -| | Get CSR rejects null arguments | — | unit | Done | [managed_get_csr_rejects_null_arguments](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L318) | -| | Store rejects an issuance with no certificates | Accepting an empty chain would flip the operational flag with nothing on disk. | unit | Done | [managed_store_rejects_null_arguments](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L335) | -| | Deinit tolerates null | — | unit | Done | [managed_deinit_tolerates_null](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L363) | -| | Deinit through the vtable destroys the provider | What a generic owner of an `az_iot_certificate_provider` calls. | unit | Done | [managed_deinit_through_the_vtable_destroys_the_provider](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L371) | +| | A stored chain that is not a certificate is refused | The store validates the chain before replacing the file; nothing is written and no operational identity is claimed. | unit | Done | [managed_a_stored_chain_that_is_not_a_certificate_is_refused](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L509) | +| | A failed store keeps the previous certificate | All-or-nothing, in-process and after a restart, so a failed renewal cannot break the next connect. | unit | Done | [managed_a_failed_store_keeps_the_previous_certificate](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L539) | +| | A chain for another key is refused | The leaf must certify the operational key; the previous chain is kept. | unit | Done | [managed_store_refuses_a_chain_for_another_key](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L577) | +| | A persisted chain for another key is not used | After the key is replaced, the stored chain no longer certifies it. | unit | Done | [managed_a_persisted_chain_for_another_key_is_not_used](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L774) | +| | A chain with a bad trailing entry is refused | Every entry must parse; the previous chain is kept. | unit | Done | [managed_store_refuses_a_chain_with_a_bad_trailing_entry](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L612) | +| | A chain with an empty entry is refused | — | unit | Done | [managed_store_refuses_a_chain_with_an_empty_entry](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L645) | +| | A persisted chain with a truncated entry is not used | — | unit | Done | [managed_a_persisted_chain_with_a_truncated_entry_is_not_used](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L676) | +| | A chain followed by other text is refused | In an issued entry or appended to the file. | unit | Done | [managed_a_chain_followed_by_other_text_is_refused](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L724) | +| | Written files are owner-only | Key and chain are exactly 0600 whatever the umask, including 0777, and reload. POSIX. | unit | Done | [managed_written_files_are_owner_only](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L893) | +| | Written files have an owner-only DACL | Protected DACL: Owner Rights and SYSTEM only; reloads. Windows only. | unit | Done | [managed_written_files_have_an_owner_only_dacl](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L855) | +| | A link at a destination is replaced, not followed | Key and chain. POSIX only. | unit | Done | [managed_writes_replace_a_link_instead_of_following_it](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L924) | +| | Store overwrites a previously issued chain | Renewal replaces rather than appends. | unit | Done | [managed_store_overwrites_a_previously_issued_chain](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L478) | +| | The sign hook is not offered by either bundled provider | Neither provider sets `sign`, so the connect path must keep checking it for NULL. Pinned so that adding an implementation is a deliberate act. | unit | Done | [the_sign_hook_is_not_offered_by_this_provider](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L966) | +| | An operational load with no stored chain returns not found | The connect path uses this code to decide it must still enroll. Belongs to the managed provider: the PEM loader ignores the role. | unit | Done | [managed_operational_load_without_a_stored_chain_is_not_found](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L277) | +| | Load rejects null arguments | — | unit | Done | [managed_load_rejects_null_arguments](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L256) | +| | Release leaves the material usable | The paths are borrowed from the provider struct, so freeing them here would dangle what the connection is still using. | unit | Done | [managed_release_leaves_the_material_usable](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L297) | +| | Get CSR rejects null arguments | — | unit | Done | [managed_get_csr_rejects_null_arguments](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L319) | +| | Store rejects an issuance with no certificates | Accepting an empty chain would flip the operational flag with nothing on disk. | unit | Done | [managed_store_rejects_null_arguments](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L336) | +| | Deinit tolerates null | — | unit | Done | [managed_deinit_tolerates_null](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L364) | +| | Deinit through the vtable destroys the provider | What a generic owner of an `az_iot_certificate_provider` calls. | unit | Done | [managed_deinit_through_the_vtable_destroys_the_provider](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L372) | | Issuance via DPS | The CSR rides the DPS registration body | — | unit | Done | [dps_csr_flow_sends_csr_and_stores_issued_chain](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1538) | | | The issued chain is handed to the provider | — | unit | Done | [dps_csr_flow_sends_csr_and_stores_issued_chain](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1538) | | | Open is refused without a CSR-capable provider | — | unit | Done | [open_rejects_operational_cert_without_csr_provider](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1317) | -| | Open is refused without a payload buffer | — | unit | Done | [open_rejects_operational_cert_without_payload_buffer](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1727) | +| | Open is refused without a payload buffer | — | unit | Done | [open_rejects_operational_cert_without_payload_buffer](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1726) | | | The DPS username carries the CSR API version | The CSR flow needs a newer api-version than the azure-sdk-for-c default. | unit | Pending | *connection_client_test.c* | | | The operational cert callback fires with the issued chain and store result | Fires after the store. | unit | Done | [dps_csr_flow_sends_csr_and_stores_issued_chain](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1538) | | | A failed store still delivers the chain, with the error | The registration then fails. | unit | Done | [dps_store_failure_still_delivers_the_chain_with_the_error](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1571) | -| | A provider without a store hook reports not supported | The callback owns the chain; the hub connect proceeds. | unit | Done | [dps_without_a_store_hook_reports_not_supported_to_the_app](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1606) | -| | A callback that unregisters itself still handles the chain | The registration proceeds. | unit | Done | [dps_a_callback_that_unregisters_itself_still_handles_the_chain](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1627) | -| | No store hook and no callback fails the registration | — | unit | Done | [dps_without_a_store_hook_or_callback_fails_the_registration](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1649) | -| Hub-side renewal | A renewal publishes the issue-certificate topic | `$iothub/credentials/POST/issueCertificate/?$rid=`. | unit | Done | [send_csr_two_phase_delivers_issued_chain](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1748) | -| | A 202 reports accepted without completing | — | unit | Done | [send_csr_two_phase_delivers_issued_chain](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1748) | -| | A 200 delivers the issued chain | — | unit | Done | [send_csr_two_phase_delivers_issued_chain](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1748) | -| | An error status reports the service code | 409 conflict with an `errorCode` body. | unit | Done | [send_csr_error_reports_service_code](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1806) | -| | Only one renewal is in flight at a time | A second `send_csr()` is refused while one is open. | unit | Done | [send_csr_cancel_frees_slot](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2193) | -| | Cancel frees the slot for a new renewal | — | unit | Done | [send_csr_cancel_frees_slot](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2193) | -| | The renewal subscribes the credentials response filter | `$iothub/credentials/res/#`. | unit | Done | [send_csr_subscribes_the_credentials_response_filter](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2382) | -| | The request body carries the device id and CSR | — | unit | Done | [send_csr_two_phase_delivers_issued_chain](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1748) | -| | The optional replace field is emitted only when supplied | `"replace":"*"`. | unit | Done | [send_csr_emits_the_replace_field_only_when_supplied](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2422) | -| | A response for a different rid is ignored | — | unit | Done | [a_credentials_response_for_a_different_rid_is_ignored](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2453) | -| | A 400 reports the service code | Invalid request payload. | unit | Done | [a_400_reports_the_service_code](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2476) | -| | A 412 reports the service code | No matching request to replace. | unit | Done | [a_412_reports_the_service_code](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2502) | -| | A 429 surfaces the retry-after hint | The response body carries `retryAfter`. | unit | Done | [a_429_surfaces_the_retry_after_hint](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2528) | -| | A malformed issued chain reports a protocol error | 200 whose body has no `certificates` array. | unit | Done | [a_200_without_a_certificates_array_reports_a_failure](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2556) | -| | A provider-made CSR's chain is stored and used on the next connect | `send_csr(NULL)`: CSR from `get_csr()` (subject = device id); stored before the callback; session kept. | unit | Done | [a_provider_csr_chain_is_stored_and_used_on_the_next_connect](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2075) | -| | A failed store keeps the session and the previous credential | Reported in `store_status`. | unit | Done | [a_failed_store_keeps_the_session_and_the_previous_credential](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2102) | -| | An application CSR's chain is not stored | The application owns the key and the chain. | unit | Done | [an_application_csr_chain_is_not_stored](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2122) | -| | Send CSR without a CSR needs a provider that can renew | No provider, a v1 provider, or no `get_csr`: refused before publishing; slot stays free. | unit | Done | [send_csr_without_a_csr_needs_a_provider_that_can_renew](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2157) | -| | A failed get_csr fails send CSR | Nothing published. | unit | Done | [a_failed_get_csr_fails_send_csr](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2177) | +| | A callback that unregisters itself still handles the chain | The chain is stored and the registration proceeds. | unit | Done | [dps_a_callback_that_unregisters_itself_still_handles_the_chain](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1644) | +| | Open is refused without release_csr or store_issued_certificate | DPS enrollment needs the provider to store the chain. | unit | Done | [open_rejects_operational_cert_without_release_or_store](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1597) | +| Hub-side renewal | A renewal publishes the issue-certificate topic | `$iothub/credentials/POST/issueCertificate/?$rid=`. | unit | Done | [send_csr_two_phase_delivers_issued_chain](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1747) | +| | A 202 reports accepted without completing | — | unit | Done | [send_csr_two_phase_delivers_issued_chain](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1747) | +| | A 200 delivers the issued chain | — | unit | Done | [send_csr_two_phase_delivers_issued_chain](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1747) | +| | An error status reports the service code | 409 conflict with an `errorCode` body. | unit | Done | [send_csr_error_reports_service_code](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1805) | +| | Only one renewal is in flight at a time | A second `send_csr()` is refused while one is open. | unit | Done | [send_csr_cancel_frees_slot](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2192) | +| | Cancel frees the slot for a new renewal | — | unit | Done | [send_csr_cancel_frees_slot](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2192) | +| | The renewal subscribes the credentials response filter | `$iothub/credentials/res/#`. | unit | Done | [send_csr_subscribes_the_credentials_response_filter](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2381) | +| | The request body carries the device id and CSR | — | unit | Done | [send_csr_two_phase_delivers_issued_chain](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1747) | +| | The optional replace field is emitted only when supplied | `"replace":"*"`. | unit | Done | [send_csr_emits_the_replace_field_only_when_supplied](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2421) | +| | A response for a different rid is ignored | — | unit | Done | [a_credentials_response_for_a_different_rid_is_ignored](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2452) | +| | A 400 reports the service code | Invalid request payload. | unit | Done | [a_400_reports_the_service_code](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2475) | +| | A 412 reports the service code | No matching request to replace. | unit | Done | [a_412_reports_the_service_code](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2501) | +| | A 429 surfaces the retry-after hint | The response body carries `retryAfter`. | unit | Done | [a_429_surfaces_the_retry_after_hint](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2527) | +| | A malformed issued chain reports a protocol error | 200 whose body has no `certificates` array. | unit | Done | [a_200_without_a_certificates_array_reports_a_failure](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2555) | +| | A provider-made CSR's chain is stored and used on the next connect | `send_csr(NULL)`: CSR from `get_csr()` (subject = device id); stored before the callback; session kept. | unit | Done | [a_provider_csr_chain_is_stored_and_used_on_the_next_connect](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2074) | +| | A failed store keeps the session and the previous credential | Reported in `store_status`. | unit | Done | [a_failed_store_keeps_the_session_and_the_previous_credential](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2101) | +| | An application CSR's chain is not stored | The application owns the key and the chain. | unit | Done | [an_application_csr_chain_is_not_stored](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2121) | +| | Send CSR without a CSR needs a provider that can renew | No provider, a v1 provider, or no `get_csr`: refused before publishing; slot stays free. | unit | Done | [send_csr_without_a_csr_needs_a_provider_that_can_renew](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2156) | +| | A failed get_csr fails send CSR | Nothing published. | unit | Done | [a_failed_get_csr_fails_send_csr](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2176) | | | A renewal with no response times out | `CSR_OP_TIMEOUT_MS` elapses. | unit | Pending | *connection_client_test.c* | | | A 202 extends the timeout deadline | A slow-but-alive signer is not abandoned. | unit | Pending | *connection_client_test.c* | -| | Send CSR while disconnected is refused | — | unit | Done | [send_csr_while_disconnected_is_refused](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2582) | +| | Send CSR while disconnected is refused | — | unit | Done | [send_csr_while_disconnected_is_refused](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2581) | | | A renewal open across a reconnect is completed or failed | Same family as C-3 / D-3. | unit | Pending | *connection_client_test.c* | -| | A CSR larger than the service cap is refused | `CSR_MAX_BASE64` is 8 KB. | unit | Done | [a_csr_larger_than_the_service_cap_is_refused](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2602) | +| | A CSR larger than the service cap is refused | `CSR_MAX_BASE64` is 8 KB. | unit | Done | [a_csr_larger_than_the_service_cap_is_refused](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2601) | | | Send CSR rejects null arguments | — | unit | Pending | *connection_client_test.c* | | | Send CSR on an MQTTv5 session is not supported | The MQTTv5 renewal topic space is not defined yet, so the call is refused rather than publishing to an MQTTv3 topic from a v5 session. | unit | Pending | *connection_client_test.c* | | | Send CSR without a payload buffer reports not enough space | — | unit | Pending | *connection_client_test.c* | @@ -1031,7 +1031,7 @@ own, so they are unit-tested only. | | Stderr sink honours its minimum level | Below the threshold is dropped before the sink is reached. | unit | Done | [stderr_sink_honours_its_minimum_level](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/diagnostics_test.c#L308) | | | Gen request id fills a bounded string | NUL-terminated inside the buffer, printable throughout. | unit | Done | [gen_request_id_fills_a_bounded_string](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/diagnostics_test.c#L322) | | | Gen request id advances the rng | Two ids from one state differ, or a renewal retry would reuse an id the service already answered. | unit | Done | [gen_request_id_advances_the_rng](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/diagnostics_test.c#L346) | -| Certificate helpers | A generated request id is fresh each time | Repeating one would make the service treat a new renewal as a duplicate. | unit | Done | [cert_util_generates_a_distinct_request_id](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2632) | +| Certificate helpers | A generated request id is fresh each time | Repeating one would make the service treat a new renewal as a duplicate. | unit | Done | [cert_util_generates_a_distinct_request_id](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2631) | ## Test infrastructure diff --git a/c/inc/azure/iot/az_iot_connection_client.h b/c/inc/azure/iot/az_iot_connection_client.h index 053d549e1..65f56037d 100644 --- a/c/inc/azure/iot/az_iot_connection_client.h +++ b/c/inc/azure/iot/az_iot_connection_client.h @@ -337,8 +337,12 @@ extern "C" * from the certificate_provider during DPS * registration and connect to the assigned * hub with the issued operational cert. - * Requires a provider whose vtable exposes - * get_csr (version >= 2). */ + * Requires a provider whose vtable + * (version >= 2) has get_csr, + * release_csr and + * store_issued_certificate; open() + * returns AZ_IOT_ERR_NOT_SUPPORTED + * otherwise. */ /* Consecutive failed hub connect attempts after which the assignment is * treated as stale and re-provisioning is forced. Defaults to @@ -750,9 +754,9 @@ extern "C" * whether or not that succeeded. * * @param issued Issued chain, leaf first. Valid only during the callback. - * @param store_result AZ_IOT_OK: stored in the provider. AZ_IOT_ERR_NOT_SUPPORTED: - * the provider has no store_issued_certificate(). Other: the provider's failure; - * the registration then fails, as any DPS failure does (see reconnection_policy). + * @param store_result AZ_IOT_OK: stored in the provider. Other: the provider's + * failure; the registration then fails, as any DPS failure does (see + * reconnection_policy). * @param user_ctx Context given at registration. */ typedef void (*az_iot_operational_cert_callback)( @@ -1467,7 +1471,7 @@ extern "C" * get_csr() (subject = device id) and, on ISSUED, stores the chain with its * store_issued_certificate() before @p cb (result in evt->store_status). The * following hub connect uses it; if the store fails, the previous credential - * stays in use. The provider must implement both hooks (vtable v2). + * stays in use. * - @p csr non-NULL: the application made the CSR and owns the key, so the * client never stores the chain (store_status AZ_IOT_ERR_NOT_SUPPORTED). * @@ -1476,8 +1480,9 @@ extern "C" * @p csr NULL, a resubmit carries a new CSR from get_csr(). * @param replace NULL, or "*" / a request id to supersede an active hub-side op. * @return AZ_IOT_ERR_BUSY while an operation is in flight; - * AZ_IOT_ERR_NOT_SUPPORTED when @p csr is NULL and the provider cannot produce - * and store a CSR, or on an MQTTv5 session. + * AZ_IOT_ERR_NOT_SUPPORTED when @p csr is NULL and the provider's vtable is + * below version 2 or lacks get_csr, release_csr or store_issued_certificate, + * or on an MQTTv5 session. */ AZ_NODISCARD az_iot_result az_iot_connection_client_send_csr( az_iot_connection_client* client, diff --git a/c/samples/common/sample_cert_provider.c b/c/samples/common/sample_cert_provider.c index 863c558eb..a5a9826f4 100644 --- a/c/samples/common/sample_cert_provider.c +++ b/c/samples/common/sample_cert_provider.c @@ -8,10 +8,14 @@ #ifndef _CRT_SECURE_NO_WARNINGS #define _CRT_SECURE_NO_WARNINGS /* this sample uses fopen for cert persistence */ #endif +#if !defined(_WIN32) +#define _POSIX_C_SOURCE 200809L /* mkstemp, fdopen, fchmod */ +#endif #include "sample_cert_provider.h" #include "sample_csr_backend.h" +#include #include #include #include @@ -20,16 +24,89 @@ #ifndef WIN32_LEAN_AND_MEAN #define WIN32_LEAN_AND_MEAN #endif +#include +#include #include +#else +#include +#include #endif /* PEM framing written around each base64 DER certificate the service issues. */ #define PEM_CERT_BEGIN "-----BEGIN CERTIFICATE-----\n" #define PEM_CERT_END "\n-----END CERTIFICATE-----\n" -/* Suffix of the temporary file a new chain is written to before it replaces - * the operational cert file. */ -#define SAMPLE_TMP_SUFFIX ".tmp" +/* Room for the unique suffix appended to the destination path for the + * temporary file: ".XXXXXX" (POSIX) or ".<8 hex>.tmp" (Windows), plus NUL. */ +#define SAMPLE_TMP_SUFFIX_MAX 16 + +/* Create a new, uniquely named file next to @p path for writing, failing + * rather than opening anything already there (a file or a link). Returns the + * stream and its path in *out_tmp (heap), or NULL. */ +static FILE* sample_create_unique(const char* path, char** out_tmp) +{ + size_t cap = strlen(path) + SAMPLE_TMP_SUFFIX_MAX; + char* tmp = (char*)malloc(cap); + FILE* f = NULL; + *out_tmp = NULL; + if (!tmp) + { + return NULL; + } +#ifdef _WIN32 + unsigned long seed = GetTickCount() ^ ((unsigned long)GetCurrentProcessId() << 16); + for (int i = 0; i < 16 && !f; ++i) + { + seed = seed * 1103515245UL + 12345UL; + (void)snprintf(tmp, cap, "%s.%08lx.tmp", path, seed & 0xFFFFFFFFUL); + HANDLE h = CreateFileA(tmp, GENERIC_WRITE, 0, NULL, CREATE_NEW, FILE_ATTRIBUTE_NORMAL, NULL); + if (h == INVALID_HANDLE_VALUE) + { + if (GetLastError() != ERROR_FILE_EXISTS) + { + break; + } + continue; + } + int fd = _open_osfhandle((intptr_t)h, _O_WRONLY | _O_BINARY); + if (fd < 0) + { + CloseHandle(h); + (void)DeleteFileA(tmp); + break; + } + f = _fdopen(fd, "wb"); + if (!f) + { + _close(fd); + (void)DeleteFileA(tmp); + break; + } + } +#else + (void)snprintf(tmp, cap, "%s.XXXXXX", path); + int fd = mkstemp(tmp); /* O_CREAT|O_EXCL */ + if (fd >= 0) + { + if (fchmod(fd, S_IRUSR | S_IWUSR) == 0) + { + f = fdopen(fd, "wb"); + } + if (!f) + { + (void)close(fd); + (void)unlink(tmp); + } + } +#endif + if (!f) + { + free(tmp); + return NULL; + } + *out_tmp = tmp; + return f; +} /* Replace @p to with @p from in one step. Returns nonzero on success. */ static int sample_replace_file(const char* from, const char* to) @@ -153,23 +230,14 @@ static az_iot_result provider_store( return AZ_IOT_ERR_INVALID_ARG; } - /* All-or-nothing: write a temporary file next to the destination, then - * replace the destination with it, so a failure keeps the previous chain. - * A production provider should also create it owner-only under a unique - * name (see the managed provider). */ - size_t path_len = strlen(p->operational_cert_path); - char* tmp = (char*)malloc(path_len + sizeof(SAMPLE_TMP_SUFFIX)); - if (!tmp) - { - return AZ_IOT_ERR_OUT_OF_MEMORY; - } - memcpy(tmp, p->operational_cert_path, path_len); - memcpy(tmp + path_len, SAMPLE_TMP_SUFFIX, sizeof(SAMPLE_TMP_SUFFIX)); - - FILE* f = fopen(tmp, "wb"); + /* All-or-nothing: write a new, uniquely named file next to the destination, + * then replace the destination with it, so a failure keeps the previous + * chain. A production provider should also restrict its ACL on Windows + * (see the managed provider). */ + char* tmp = NULL; + FILE* f = sample_create_unique(p->operational_cert_path, &tmp); if (!f) { - free(tmp); return AZ_IOT_ERR_INTERNAL; } diff --git a/c/src/core/connection_client.c b/c/src/core/connection_client.c index 93804b2d5..335d390f1 100644 --- a/c/src/core/connection_client.c +++ b/c/src/core/connection_client.c @@ -1604,30 +1604,18 @@ static az_iot_result dps_store_issued_cert(az_iot_connection_client* c, az_span issued.certificates = certs; issued.count = count; - /* Persist via the provider (if capable), then always notify the app with the - * outcome (D4). At least one of the two must exist to take the chain. */ - bool has_store = p && p->vtable && p->vtable->version >= CERT_PROVIDER_VTABLE_V2 - && p->vtable->store_issued_certificate; - az_iot_result store_rc = AZ_IOT_ERR_NOT_SUPPORTED; - if (has_store) + /* Store in the provider (open() guarantees it can), then notify the app with + * the outcome (D4). A failed store fails the registration. */ + az_iot_result store_rc = p->vtable->store_issued_certificate(p, &issued); + if (store_rc != AZ_IOT_OK) { - store_rc = p->vtable->store_issued_certificate(p, &issued); - if (store_rc != AZ_IOT_OK) - { - AZ_IOT_LOG_ERRORF("dps: storing the issued certificate failed (%d)", (int)store_rc); - } - } - /* Captured first: the callback may unregister itself. */ - az_iot_operational_cert_callback cb = c->op_cert_cb; - if (cb) - { - cb(&issued, store_rc, c->op_cert_cb_ctx); + AZ_IOT_LOG_ERRORF("dps: storing the issued certificate failed (%d)", (int)store_rc); } - if (has_store) + if (c->op_cert_cb) { - return store_rc; + c->op_cert_cb(&issued, store_rc, c->op_cert_cb_ctx); } - return cb ? AZ_IOT_OK : AZ_IOT_ERR_NOT_SUPPORTED; + return store_rc; } static void on_dps_mqtt_event(const az_iot_mqtt_event* evt, void* user_ctx) @@ -3762,15 +3750,17 @@ az_iot_result az_iot_connection_client_open(az_iot_connection_client* client) } /* CSR-based operational-cert enrollment (D2) requires a certificate_provider - * whose vtable exposes get_csr (ABI version >= 2). Fail fast otherwise. */ + * that makes the CSR and stores the issued chain: the chain is never kept by + * the client. Fail fast otherwise. */ if (client->opts.dps.request_operational_certificate) { az_iot_certificate_provider* p = client->opts.certificate_provider; if (!p || !p->vtable || p->vtable->version < CERT_PROVIDER_VTABLE_V2 - || p->vtable->get_csr == NULL) + || p->vtable->get_csr == NULL || p->vtable->release_csr == NULL + || p->vtable->store_issued_certificate == NULL) { - AZ_IOT_LOG_ERROR("connection_client_open: request_operational_certificate set but provider " - "does not support CSR enrollment"); + AZ_IOT_LOG_ERROR("connection_client_open: request_operational_certificate requires a " + "provider with get_csr, release_csr and store_issued_certificate"); return AZ_IOT_ERR_NOT_SUPPORTED; } if (az_span_size(client->opts.csr_payload_buffer) <= 0) diff --git a/c/tests/CMakeLists.txt b/c/tests/CMakeLists.txt index 56add4cf9..608e368a1 100644 --- a/c/tests/CMakeLists.txt +++ b/c/tests/CMakeLists.txt @@ -454,6 +454,10 @@ if(TARGET az_iot_certificate_provider_managed) unit/certificate_provider_managed_test.c) target_link_libraries(az_iot_tests_certificate_provider_managed PRIVATE az_iot_certificate_provider_managed OpenSSL::Crypto) + if(WIN32) + # GetNamedSecurityInfoA and friends, to check the written files' DACLs. + target_link_libraries(az_iot_tests_certificate_provider_managed PRIVATE advapi32) + endif() endif() # --------------------------------------------------------------------------- diff --git a/c/tests/unit/certificate_provider_managed_test.c b/c/tests/unit/certificate_provider_managed_test.c index e0e094b90..4b9b470a2 100644 --- a/c/tests/unit/certificate_provider_managed_test.c +++ b/c/tests/unit/certificate_provider_managed_test.c @@ -23,6 +23,7 @@ #define WIN32_LEAN_AND_MEAN #endif #include +#include #else #include #include @@ -798,6 +799,84 @@ static void managed_a_persisted_chain_for_another_key_is_not_used(void** state) remove_test_files(); } +#if defined(_WIN32) +/* True when @p path has a protected DACL (nothing inherited) granting full + * access to exactly Owner Rights and SYSTEM, and nothing else. */ +static bool has_owner_only_dacl(const char* path) +{ + PACL dacl = NULL; + PSECURITY_DESCRIPTOR sd = NULL; + if (GetNamedSecurityInfoA( + path, SE_FILE_OBJECT, DACL_SECURITY_INFORMATION, NULL, NULL, &dacl, NULL, &sd) + != ERROR_SUCCESS) + { + return false; + } + SECURITY_DESCRIPTOR_CONTROL control = 0; + DWORD revision = 0; + ACL_SIZE_INFORMATION info; + bool ok = dacl != NULL && GetSecurityDescriptorControl(sd, &control, &revision) + && (control & SE_DACL_PROTECTED) != 0 + && GetAclInformation(dacl, &info, (DWORD)sizeof(info), AclSizeInformation) + && info.AceCount == 2; + + BYTE owner_rights[SECURITY_MAX_SID_SIZE]; + BYTE system[SECURITY_MAX_SID_SIZE]; + DWORD n1 = (DWORD)sizeof(owner_rights); + DWORD n2 = (DWORD)sizeof(system); + ok = ok && CreateWellKnownSid(WinCreatorOwnerRightsSid, NULL, owner_rights, &n1) + && CreateWellKnownSid(WinLocalSystemSid, NULL, system, &n2); + bool saw_owner = false; + bool saw_system = false; + for (DWORD i = 0; ok && i < info.AceCount; ++i) + { + ACCESS_ALLOWED_ACE* ace = NULL; + ok = GetAce(dacl, i, (LPVOID*)&ace) && ace->Header.AceType == ACCESS_ALLOWED_ACE_TYPE + && (ace->Mask & FILE_ALL_ACCESS) == FILE_ALL_ACCESS; + PSID sid = ok ? (PSID)&ace->SidStart : NULL; + if (ok && EqualSid(sid, owner_rights)) + { + saw_owner = true; + } + else if (ok && EqualSid(sid, system)) + { + saw_system = true; + } + else + { + ok = false; + } + } + LocalFree(sd); + return ok && saw_owner && saw_system; +} + +/* The key and chain carry an owner-only DACL, and reload after a restart. */ +static void managed_written_files_have_an_owner_only_dacl(void** state) +{ + (void)state; + remove_test_files(); + az_iot_certificate_provider_managed_options opts = test_options(); + az_iot_certificate_provider_managed prov; + assert_int_equal(AZ_IOT_OK, az_iot_certificate_provider_managed_init(&prov, &opts)); + assert_true(has_owner_only_dacl(OP_KEY)); + + char* good = make_cert_base64((EVP_PKEY*)prov.operational_key, 1); + az_span chain[1] = { az_span_create_from_str(good) }; + az_iot_issued_certificate issued = { .certificates = chain, .count = 1 }; + assert_int_equal(AZ_IOT_OK, prov.base.vtable->store_issued_certificate(&prov.base, &issued)); + assert_true(has_owner_only_dacl(OP_CERT)); + az_iot_certificate_provider_managed_deinit(&prov); + + az_iot_certificate_provider_managed prov2; + assert_int_equal(AZ_IOT_OK, az_iot_certificate_provider_managed_init(&prov2, &opts)); + assert_true(prov2.has_operational); + az_iot_certificate_provider_managed_deinit(&prov2); + free(good); + remove_test_files(); +} +#endif + #if !defined(_WIN32) #define LINK_TARGET "az_iot_managed_test_link_target.pem" #define LINK_TARGET_KEY "az_iot_managed_test_link_target_key.pem" @@ -920,6 +999,9 @@ int main(void) cmocka_unit_test(managed_store_refuses_a_chain_with_an_empty_entry), cmocka_unit_test(managed_a_persisted_chain_with_a_truncated_entry_is_not_used), cmocka_unit_test(managed_a_chain_followed_by_other_text_is_refused), +#if defined(_WIN32) + cmocka_unit_test(managed_written_files_have_an_owner_only_dacl), +#endif #if !defined(_WIN32) cmocka_unit_test(managed_written_files_are_owner_only), cmocka_unit_test(managed_writes_replace_a_link_instead_of_following_it), diff --git a/c/tests/unit/connection_client_test.c b/c/tests/unit/connection_client_test.c index b195832b6..ccb93be44 100644 --- a/c/tests/unit/connection_client_test.c +++ b/c/tests/unit/connection_client_test.c @@ -1592,44 +1592,61 @@ static void dps_store_failure_still_delivers_the_chain_with_the_error(void** sta az_iot_connection_client_deinit(&client); } -static const az_iot_certificate_provider_vtable k_fake_csr_vtable_no_store = { - .version = AZ_IOT_CERTIFICATE_PROVIDER_VTABLE_VERSION, - .load = fake_csr_load, - .release = fake_csr_release, - .deinit = fake_csr_destroy, - .get_csr = fake_get_csr, - .release_csr = fake_release_csr, -}; - -/* A provider without a store hook: the app callback owns the chain, and is told - * nothing was stored; the registration proceeds to the hub. */ -static void dps_without_a_store_hook_reports_not_supported_to_the_app(void** state) +/* DPS enrollment needs a provider that also releases the CSR and stores the + * issued chain: the client never keeps the chain itself. */ +static void open_rejects_operational_cert_without_release_or_store(void** state) { (void)state; - - fake_csr_provider prov = { 0 }; - prov.base.vtable = &k_fake_csr_vtable_no_store; - az_iot_connection_client client; - uint8_t csr_buf[AZ_IOT_CSR_PAYLOAD_BUFFER_MIN]; - drive_dps_csr_assignment(&client, csr_buf, sizeof(csr_buf), &prov, true); - - assert_int_equal(g_dps_op_cert_count, 1); - assert_int_equal(g_dps_op_cert_store_result, AZ_IOT_ERR_NOT_SUPPORTED); - assert_int_equal( - az_iot_connection_client_get_state(&client, AZ_IOT_CONN_SCOPE_HUB), - AZ_IOT_CONN_STATE_CONNECTING); - - az_iot_connection_client_deinit(&client); + static const az_iot_certificate_provider_vtable no_store = { + .version = AZ_IOT_CERTIFICATE_PROVIDER_VTABLE_VERSION, + .load = fake_csr_load, + .release = fake_csr_release, + .deinit = fake_csr_destroy, + .get_csr = fake_get_csr, + .release_csr = fake_release_csr, + }; + static const az_iot_certificate_provider_vtable no_release_csr = { + .version = AZ_IOT_CERTIFICATE_PROVIDER_VTABLE_VERSION, + .load = fake_csr_load, + .release = fake_csr_release, + .deinit = fake_csr_destroy, + .get_csr = fake_get_csr, + .store_issued_certificate = fake_store, + }; + const az_iot_certificate_provider_vtable* vtables[] = { &no_store, &no_release_csr }; + for (size_t i = 0; i < sizeof(vtables) / sizeof(vtables[0]); ++i) + { + fake_csr_provider prov = { 0 }; + prov.base.vtable = vtables[i]; + uint8_t csr_buf[AZ_IOT_CSR_PAYLOAD_BUFFER_MIN]; + az_iot_connection_client_options opts = { 0 }; + opts.client_id = "ut-device"; + opts.dps.id_scope = "0ne00000000"; + opts.dps.registration_id = "ut-device"; + opts.dps.request_operational_certificate = true; + opts.certificate_provider = &prov.base; + opts.csr_payload_buffer = az_span_create(csr_buf, sizeof(csr_buf)); + az_iot_connection_client client; + assert_int_equal(az_iot_connection_client_init(&client, &opts), AZ_IOT_OK); + /* A registered factory, so the refusal can only come from the provider check. */ + az_iot_mqtt_factory* factory = az_iot_mock_mqtt_factory_create(AZ_IOT_MQTT_VERSION_3_1_1); + assert_non_null(factory); + assert_int_equal(az_iot_connection_client_register_mqtt_factory(&client, factory), AZ_IOT_OK); + assert_int_equal(az_iot_connection_client_open(&client), AZ_IOT_ERR_NOT_SUPPORTED); + assert_int_equal(prov.get_csr_calls, 0); + assert_null(az_iot_mock_mqtt_factory_last_client(factory)); + az_iot_connection_client_deinit(&client); + } } -/* The callback took the chain even if it unregisters itself while running, so - * the registration still proceeds to the hub. */ +/* The callback may unregister itself while running; the chain is still stored + * and the registration proceeds to the hub. */ static void dps_a_callback_that_unregisters_itself_still_handles_the_chain(void** state) { (void)state; fake_csr_provider prov = { 0 }; - prov.base.vtable = &k_fake_csr_vtable_no_store; + prov.base.vtable = &k_fake_csr_vtable; az_iot_connection_client client; uint8_t csr_buf[AZ_IOT_CSR_PAYLOAD_BUFFER_MIN]; g_dps_unregister_from = &client; @@ -1637,6 +1654,7 @@ static void dps_a_callback_that_unregisters_itself_still_handles_the_chain(void* g_dps_unregister_from = NULL; assert_int_equal(g_dps_op_cert_count, 1); + assert_int_equal(prov.store_calls, 1); assert_int_equal( az_iot_connection_client_get_state(&client, AZ_IOT_CONN_SCOPE_HUB), AZ_IOT_CONN_STATE_CONNECTING); @@ -1644,25 +1662,6 @@ static void dps_a_callback_that_unregisters_itself_still_handles_the_chain(void* az_iot_connection_client_deinit(&client); } -/* No store hook and no callback: nothing can take the chain, so the - * registration fails. */ -static void dps_without_a_store_hook_or_callback_fails_the_registration(void** state) -{ - (void)state; - - fake_csr_provider prov = { 0 }; - prov.base.vtable = &k_fake_csr_vtable_no_store; - az_iot_connection_client client; - uint8_t csr_buf[AZ_IOT_CSR_PAYLOAD_BUFFER_MIN]; - drive_dps_csr_assignment(&client, csr_buf, sizeof(csr_buf), &prov, false); - - assert_int_equal(g_dps_op_cert_count, 0); - assert_int_equal( - az_iot_connection_client_get_state(&client, AZ_IOT_CONN_SCOPE_HUB), AZ_IOT_CONN_STATE_IDLE); - - az_iot_connection_client_deinit(&client); -} - /* ---- Runtime Hub-side CSR renewal (increment 4) ---- */ typedef struct csr_test_ctx @@ -2741,9 +2740,8 @@ int main(void) cmocka_unit_test(open_rejects_operational_cert_without_csr_provider), cmocka_unit_test(dps_csr_flow_sends_csr_and_stores_issued_chain), cmocka_unit_test(dps_store_failure_still_delivers_the_chain_with_the_error), - cmocka_unit_test(dps_without_a_store_hook_reports_not_supported_to_the_app), cmocka_unit_test(dps_a_callback_that_unregisters_itself_still_handles_the_chain), - cmocka_unit_test(dps_without_a_store_hook_or_callback_fails_the_registration), + cmocka_unit_test(open_rejects_operational_cert_without_release_or_store), cmocka_unit_test(open_rejects_operational_cert_without_payload_buffer), cmocka_unit_test_setup_teardown(send_csr_two_phase_delivers_issued_chain, setup, teardown), cmocka_unit_test_setup_teardown(send_csr_error_reports_service_code, setup, teardown), From df3b8eb3d2452b6787d6ba7e1c503916bead621f Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Tue, 29 Sep 2026 04:27:35 +0000 Subject: [PATCH 12/17] [C] Drop certificate provider vtable versioning; fix MSVC test build - Remove az_iot_certificate_provider_vtable::version and AZ_IOT_CERTIFICATE_PROVIDER_VTABLE_VERSION; the client gates optional hooks on NULL only. Providers, samples, tests and docs updated; the v1-provider tests are removed. - Windows DACL test: rename a local that shadowed system() (MSVC C4459 under /W4 /WX) and initialize the ACL size info. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../az_iot_certificate_provider_managed.c | 1 - c/docs/eng/certificate-management.md | 23 ++---- c/docs/test-coverage.md | 5 +- c/inc/azure/iot/az_iot_certificate_provider.h | 21 ++--- c/inc/azure/iot/az_iot_connection_client.h | 19 ++--- .../custom_provider_template/main.c | 1 - c/samples/authentication/hsm_pkcs11/main.c | 1 - .../authentication/hsm_sign_callback/main.c | 4 +- c/samples/common/sample_cert_provider.c | 1 - .../esp32/main/az_iot_cert_embedded.c | 1 - c/src/core/certificate_provider_pem.c | 1 - c/src/core/connection_client.c | 31 ++----- c/tests/e2e/tests/e2e_custody_test.c | 1 - .../unit/certificate_provider_managed_test.c | 11 ++- c/tests/unit/connection_client_test.c | 28 ++----- c/tests/unit/connection_dps_payload_test.c | 1 - c/tests/unit/connection_key_custody_test.c | 82 ++++++------------- 17 files changed, 75 insertions(+), 157 deletions(-) diff --git a/c/adapters/cert_openssl/az_iot_certificate_provider_managed.c b/c/adapters/cert_openssl/az_iot_certificate_provider_managed.c index bc2de1512..49057a649 100644 --- a/c/adapters/cert_openssl/az_iot_certificate_provider_managed.c +++ b/c/adapters/cert_openssl/az_iot_certificate_provider_managed.c @@ -728,7 +728,6 @@ static void managed_deinit_vtable(az_iot_certificate_provider* self) } static const az_iot_certificate_provider_vtable s_managed_vtable = { - .version = AZ_IOT_CERTIFICATE_PROVIDER_VTABLE_VERSION, .load = managed_load, .release = managed_release, .deinit = managed_deinit_vtable, diff --git a/c/docs/eng/certificate-management.md b/c/docs/eng/certificate-management.md index e22404ece..851eb0a3a 100644 --- a/c/docs/eng/certificate-management.md +++ b/c/docs/eng/certificate-management.md @@ -10,7 +10,7 @@ The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "S ## Status -Implemented. The certificate-provider vtable is versioned to v2 with the +Implemented. The certificate-provider vtable has the role-aware `load()`, CSR hooks (`get_csr`/`release_csr`) and issued-cert storage; the connection client performs DPS CSR enrollment (opt-in via `dps.request_operational_certificate`) and MQTTv3 hub runtime renewal @@ -501,14 +501,13 @@ dying inside the handshake with no useful diagnostic: All nine open questions are resolved below (recommendations accepted 07/03/2026). The platform matrix we must support — **file/pinned · compiled-in image · OS keystore · HSM/TPM/secure-element (PKCS#11) · remote/cloud key** — pushes four items from -"maybe/later" to **v1**: the vtable version field (D1), the key-reference + `sign()` hook +"maybe/later" to **v1**: the full vtable (D1), the key-reference + `sign()` hook (D8), Hub-side renewal (D7), and the layered ownership model (D9). -1. **Vtable ABI — use a `version` field (not append + NULL-check).** A `uint32_t version` - is the first vtable member; the client gates new slots on it. Once HSM *vendors* ship - providers compiled against a different SDK version than the app, reading past a shorter - vtable is UB. One-time cost, every future hook safe. *Supersedes the "append + - NULL-check" note in Change 2.* +1. **Vtable ABI — no `version` field (revised).** First planned as a `uint32_t version` + the client would gate new slots on. Dropped before the first release: every hook ships + in the first vtable, optional hooks are NULL-checked, and a provider is built against + the same headers as the SDK it links with. Any later ABI change is a breaking release. 2. **Opt-in — explicit `bool request_operational_certificate` + capability check.** A provider may support CSR yet a given connection may already hold a valid operational cert. Explicit flag; return `AZ_IOT_ERR_NOT_SUPPORTED` when the provider lacks @@ -576,27 +575,23 @@ typedef enum AZ_IOT_CRED_OPERATIONAL /* DPS/Hub-issued operational cert, once held */ } az_iot_cert_role; -#define AZ_IOT_CERTIFICATE_PROVIDER_VTABLE_VERSION 2u - typedef struct az_iot_certificate_provider_vtable { - uint32_t version; /* = AZ_IOT_CERTIFICATE_PROVIDER_VTABLE_VERSION (D1) */ - - /* v1 core */ + /* core */ az_iot_result (*load)(az_iot_certificate_provider* self, az_iot_cert_role role, /* D3 */ az_iot_certificate_material* out_material); void (*release)(az_iot_certificate_provider* self, az_iot_certificate_material* material); void (*deinit)(az_iot_certificate_provider* self); - /* v2 CSR enrollment (optional; NULL get_csr => not supported) */ + /* CSR enrollment (optional; NULL get_csr => not supported) */ az_iot_result (*get_csr)(az_iot_certificate_provider* self, const char* subject_common_name, az_iot_certificate_signing_request* out_csr); void (*release_csr)(az_iot_certificate_provider* self, az_iot_certificate_signing_request* csr); az_iot_result (*store_issued_certificate)(az_iot_certificate_provider* self, const az_iot_issued_certificate* issued); - /* v2 non-extractable key custody (optional; D8). When present the TLS + /* non-extractable key custody (optional; D8). When present the TLS * adapter calls sign() instead of reading a private key. */ az_iot_result (*sign)(az_iot_certificate_provider* self, const uint8_t* digest, size_t digest_len, diff --git a/c/docs/test-coverage.md b/c/docs/test-coverage.md index ead860464..90a9d2732 100644 --- a/c/docs/test-coverage.md +++ b/c/docs/test-coverage.md @@ -895,7 +895,7 @@ runtime renewal against the connected hub over `$iothub/credentials/...` per | | A provider-made CSR's chain is stored and used on the next connect | `send_csr(NULL)`: CSR from `get_csr()` (subject = device id); stored before the callback; session kept. | unit | Done | [a_provider_csr_chain_is_stored_and_used_on_the_next_connect](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2074) | | | A failed store keeps the session and the previous credential | Reported in `store_status`. | unit | Done | [a_failed_store_keeps_the_session_and_the_previous_credential](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2101) | | | An application CSR's chain is not stored | The application owns the key and the chain. | unit | Done | [an_application_csr_chain_is_not_stored](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2121) | -| | Send CSR without a CSR needs a provider that can renew | No provider, a v1 provider, or no `get_csr`: refused before publishing; slot stays free. | unit | Done | [send_csr_without_a_csr_needs_a_provider_that_can_renew](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2156) | +| | Send CSR without a CSR needs a provider that can renew | No provider, no `get_csr`, or no `store_issued_certificate`: refused before publishing; slot stays free. | unit | Done | [send_csr_without_a_csr_needs_a_provider_that_can_renew](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2156) | | | A failed get_csr fails send CSR | Nothing published. | unit | Done | [a_failed_get_csr_fails_send_csr](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2176) | | | A renewal with no response times out | `CSR_OP_TIMEOUT_MS` elapses. | unit | Pending | *connection_client_test.c* | | | A 202 extends the timeout deadline | A slow-but-alive signer is not abandoned. | unit | Pending | *connection_client_test.c* | @@ -916,8 +916,7 @@ runtime renewal against the connected hub over `$iothub/credentials/...` per | | The forwarded sign hook reaches the provider | Arguments, signature bytes, the provider's own failure, and a NULL context. | unit | Done | [the_forwarded_sign_hook_calls_the_provider](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_key_custody_test.c#L292) | | | The forwarded sign hook rejects an unusable provider | Runs inside an adapter's TLS callback, where the context arrived back through a third-party library: a wrong or stale pointer must yield a failed handshake, not a crash inside one. | unit | Done | [the_forwarded_sign_hook_rejects_an_unusable_provider](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_key_custody_test.c#L371) | | | The forwarded sign hook preserves out_sig_len on failure | `az_iot_mqtt_sign_callback` promises it is untouched on failure; the provider vtable makes no such promise, so the trampoline enforces it. | unit | Done | [the_forwarded_sign_hook_preserves_out_len_on_failure](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_key_custody_test.c#L333) | -| | A v2 provider without sign forwards no hook | — | unit | Done | [a_v2_provider_without_sign_forwards_no_hook](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_key_custody_test.c#L410) | -| | A v1 provider's sign slot is ignored | The slot does not exist below vtable version 2, whatever the memory holds. | unit | Done | [a_v1_provider_sign_slot_is_ignored](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_key_custody_test.c#L427) | +| | A provider without sign forwards no hook | — | unit | Done | [a_provider_without_sign_forwards_no_hook](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_key_custody_test.c#L410) | | | A certificate with no key is refused at open | `AZ_IOT_ERR_CREDENTIAL_INCOMPLETE` before any socket exists. | unit | Done | [a_certificate_without_any_key_is_refused_at_open](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_key_custody_test.c#L446) | | | A key URI with no engine id is refused | Nothing names the provider that owns it, so nothing could resolve it. | unit | Done | [a_key_uri_without_an_engine_id_is_refused](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_key_custody_test.c#L460) | | | A key URI with no engine id is allowed with sign | The adapter can sign without resolving the URI. | unit | Done | [a_key_uri_without_an_engine_id_is_allowed_with_sign](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_key_custody_test.c#L471) | diff --git a/c/inc/azure/iot/az_iot_certificate_provider.h b/c/inc/azure/iot/az_iot_certificate_provider.h index ba66aeedc..8bcac5b91 100644 --- a/c/inc/azure/iot/az_iot_certificate_provider.h +++ b/c/inc/azure/iot/az_iot_certificate_provider.h @@ -88,15 +88,9 @@ extern "C" typedef struct az_iot_certificate_provider az_iot_certificate_provider; -/* Vtable ABI version (D1). The client checks this before calling any hook added - * after v1; a provider MUST set vtable->version to the value it was built with. */ -#define AZ_IOT_CERTIFICATE_PROVIDER_VTABLE_VERSION 2u - typedef struct az_iot_certificate_provider_vtable { - uint32_t version; /* = AZ_IOT_CERTIFICATE_PROVIDER_VTABLE_VERSION (D1) */ - - /* v1 core. load() returns credential material for the requested role. */ + /* Required. load() returns credential material for the requested role. */ az_iot_result (*load)( az_iot_certificate_provider* self, az_iot_cert_role role, @@ -104,7 +98,7 @@ extern "C" void (*release)(az_iot_certificate_provider* self, az_iot_certificate_material* material); void (*deinit)(az_iot_certificate_provider* self); - /* v2 CSR enrollment (optional; NULL get_csr => enrollment not supported). */ + /* CSR enrollment and renewal (optional; NULL get_csr => not supported). */ az_iot_result (*get_csr)( az_iot_certificate_provider* self, const char* subject_common_name, @@ -125,17 +119,16 @@ extern "C" az_iot_certificate_provider* self, const az_iot_issued_certificate* issued); - /* v2 non-extractable key custody (optional; D8). When present the TLS + /* Non-extractable key custody (optional; D8). When present the TLS * adapter calls sign() instead of reading a private key: signs the * caller-provided digest, writing up to out_sig_cap bytes and setting * *out_sig_len. * * The connection client forwards this to the adapter (through - * az_iot_mqtt_tls_options::sign) when vtable->version >= 2 and the slot is - * set. It is the route for stacks with no engine/provider abstraction; - * Paho is not one of them -- it exposes no TLS key callback and refuses a - * sign()-only credential -- so this needs a BYO adapter. Use - * client_key_uri + crypto_engine_id with Paho. */ + * az_iot_mqtt_tls_options::sign) when the slot is set. It is the route for stacks with no + * engine/provider abstraction; Paho is not one of them -- it exposes no TLS key callback and + * refuses a sign()-only credential -- so this needs a BYO adapter. Use client_key_uri + + * crypto_engine_id with Paho. */ az_iot_result (*sign)( az_iot_certificate_provider* self, const uint8_t* digest, diff --git a/c/inc/azure/iot/az_iot_connection_client.h b/c/inc/azure/iot/az_iot_connection_client.h index 65f56037d..a2f168f59 100644 --- a/c/inc/azure/iot/az_iot_connection_client.h +++ b/c/inc/azure/iot/az_iot_connection_client.h @@ -97,11 +97,12 @@ extern "C" bool connection_profile_raw_truncated; } az_iot_hub_profile; -#define AZ_IOT_HUB_PROFILE_INIT \ - { \ - ._internal_size = sizeof(az_iot_hub_profile), \ - .connection_profile = AZ_IOT_CONNECTION_PROFILE_MQTT_V3, .connection_profile_raw = NULL, \ - .connection_profile_raw_truncated = false, \ +#define AZ_IOT_HUB_PROFILE_INIT \ + { \ + ._internal_size = sizeof(az_iot_hub_profile), \ + .connection_profile = AZ_IOT_CONNECTION_PROFILE_MQTT_V3, \ + .connection_profile_raw = NULL, \ + .connection_profile_raw_truncated = false, \ } /* How the client retries a failed connection. Four numbers describe every @@ -337,8 +338,7 @@ extern "C" * from the certificate_provider during DPS * registration and connect to the assigned * hub with the issued operational cert. - * Requires a provider whose vtable - * (version >= 2) has get_csr, + * Requires a provider with get_csr, * release_csr and * store_issued_certificate; open() * returns AZ_IOT_ERR_NOT_SUPPORTED @@ -1480,9 +1480,8 @@ extern "C" * @p csr NULL, a resubmit carries a new CSR from get_csr(). * @param replace NULL, or "*" / a request id to supersede an active hub-side op. * @return AZ_IOT_ERR_BUSY while an operation is in flight; - * AZ_IOT_ERR_NOT_SUPPORTED when @p csr is NULL and the provider's vtable is - * below version 2 or lacks get_csr, release_csr or store_issued_certificate, - * or on an MQTTv5 session. + * AZ_IOT_ERR_NOT_SUPPORTED when @p csr is NULL and the provider lacks get_csr, + * release_csr or store_issued_certificate, or on an MQTTv5 session. */ AZ_NODISCARD az_iot_result az_iot_connection_client_send_csr( az_iot_connection_client* client, diff --git a/c/samples/authentication/custom_provider_template/main.c b/c/samples/authentication/custom_provider_template/main.c index 01beeaf67..560ce1c49 100644 --- a/c/samples/authentication/custom_provider_template/main.c +++ b/c/samples/authentication/custom_provider_template/main.c @@ -171,7 +171,6 @@ static void my_destroy(az_iot_certificate_provider* self) } static const az_iot_certificate_provider_vtable s_my_vtable = { - .version = AZ_IOT_CERTIFICATE_PROVIDER_VTABLE_VERSION, .load = my_load, .release = my_release, .deinit = my_destroy, diff --git a/c/samples/authentication/hsm_pkcs11/main.c b/c/samples/authentication/hsm_pkcs11/main.c index bf1c6a216..ce95347e0 100644 --- a/c/samples/authentication/hsm_pkcs11/main.c +++ b/c/samples/authentication/hsm_pkcs11/main.c @@ -164,7 +164,6 @@ static void hsm_release(az_iot_certificate_provider* self, az_iot_certificate_ma static void hsm_deinit(az_iot_certificate_provider* self) { (void)self; } static const az_iot_certificate_provider_vtable k_hsm_vtable = { - .version = AZ_IOT_CERTIFICATE_PROVIDER_VTABLE_VERSION, .load = hsm_load, .release = hsm_release, .deinit = hsm_deinit, diff --git a/c/samples/authentication/hsm_sign_callback/main.c b/c/samples/authentication/hsm_sign_callback/main.c index ad6f2de98..ef1897e4b 100644 --- a/c/samples/authentication/hsm_sign_callback/main.c +++ b/c/samples/authentication/hsm_sign_callback/main.c @@ -21,7 +21,7 @@ * What the sample shows, and what it does not: * * - It DOES show the whole seam: a provider that implements sign(), the SDK - * gating that hook on vtable version >= 2, and an adapter reading + * forwarding that hook, and an adapter reading * tls.sign / tls.sign_ctx out of az_iot_mqtt_connect_options and invoking * it -- which is the exact line an integrator writes in their own adapter. * - It does NOT speak MQTT. The adapter below is a stand-in that performs the @@ -116,8 +116,6 @@ static az_iot_result sign_sign( } static const az_iot_certificate_provider_vtable k_sign_vtable = { - /* Version 2 or later, or the SDK will not look at .sign at all. */ - .version = AZ_IOT_CERTIFICATE_PROVIDER_VTABLE_VERSION, .load = sign_load, .release = sign_release, .deinit = sign_deinit, diff --git a/c/samples/common/sample_cert_provider.c b/c/samples/common/sample_cert_provider.c index a5a9826f4..a91c6b61a 100644 --- a/c/samples/common/sample_cert_provider.c +++ b/c/samples/common/sample_cert_provider.c @@ -289,7 +289,6 @@ static void provider_deinit_vtable(az_iot_certificate_provider* self) } static const az_iot_certificate_provider_vtable s_vtable = { - .version = AZ_IOT_CERTIFICATE_PROVIDER_VTABLE_VERSION, .load = provider_load, .release = provider_release, .deinit = provider_deinit_vtable, diff --git a/c/samples/software_update/esp32/main/az_iot_cert_embedded.c b/c/samples/software_update/esp32/main/az_iot_cert_embedded.c index 5a2f432a2..993525960 100644 --- a/c/samples/software_update/esp32/main/az_iot_cert_embedded.c +++ b/c/samples/software_update/esp32/main/az_iot_cert_embedded.c @@ -39,7 +39,6 @@ static void embedded_release( static void embedded_destroy(az_iot_certificate_provider* self) { (void)self; } static const az_iot_certificate_provider_vtable k_embedded_vtable = { - .version = AZ_IOT_CERTIFICATE_PROVIDER_VTABLE_VERSION, .load = embedded_load, .release = embedded_release, .deinit = embedded_destroy, diff --git a/c/src/core/certificate_provider_pem.c b/c/src/core/certificate_provider_pem.c index 2f8fb6bd0..385b29452 100644 --- a/c/src/core/certificate_provider_pem.c +++ b/c/src/core/certificate_provider_pem.c @@ -115,7 +115,6 @@ static void pem_deinit_vtable(az_iot_certificate_provider* self) } static const az_iot_certificate_provider_vtable s_pem_vtable = { - .version = AZ_IOT_CERTIFICATE_PROVIDER_VTABLE_VERSION, .load = pem_load, .release = pem_release, .deinit = pem_deinit_vtable, diff --git a/c/src/core/connection_client.c b/c/src/core/connection_client.c index 335d390f1..4d2f949e9 100644 --- a/c/src/core/connection_client.c +++ b/c/src/core/connection_client.c @@ -63,13 +63,6 @@ #define DPS_PHASE_DONE AZ_IOT_DPS_PHASE_DONE #define DPS_PHASE_HOLD AZ_IOT_DPS_PHASE_HOLD -/* The certificate provider vtable ABI version that introduced the v2 hooks - * (sign, get_csr). Every gate on those hooks compares against this, NOT against - * AZ_IOT_CERTIFICATE_PROVIDER_VTABLE_VERSION: that macro tracks the CURRENT - * version, so the moment it becomes 3 it would start rejecting the v2 providers - * these hooks were added for. */ -#define CERT_PROVIDER_VTABLE_V2 2u - /* ------------------------------------------------------------------------- */ /* CSR / issued-certificate wire constants. azure-sdk-for-c does not surface */ /* these DPS/Hub fields, so the JSON field names, the register body and the */ @@ -795,17 +788,16 @@ static az_iot_result provider_sign_adapter( * adapter's TLS callback, where the context came back through a third-party * library: a wrong or stale pointer must produce a failed handshake, not a * crash in the middle of one. */ - if (!p || !p->vtable || p->vtable->version < CERT_PROVIDER_VTABLE_V2 || !p->vtable->sign) + if (!p || !p->vtable || !p->vtable->sign) { /* The only evidence the caller gets is a failed handshake several frames * away inside a third-party TLS stack, so say which link of the chain was * missing while it is still known. */ AZ_IOT_LOG_ERRORF( "connection: TLS asked the certificate provider to sign, but the provider is unusable " - "(provider=%s vtable=%s version=%u sign=%s)", + "(provider=%s vtable=%s sign=%s)", p ? "set" : "NULL", (p && p->vtable) ? "set" : "NULL", - (p && p->vtable) ? (unsigned)p->vtable->version : 0u, (p && p->vtable && p->vtable->sign) ? "set" : "NULL"); return AZ_IOT_ERR_INVALID_ARG; } @@ -823,13 +815,10 @@ static az_iot_result provider_sign_adapter( return r; } -/* True when the provider exposes a usable sign() hook. The v2 slot only exists - * from vtable version 2, so the version is checked before the pointer -- the - * same gating start_csr()/open() apply to get_csr. */ +/* True when the provider exposes a sign() hook. */ static bool provider_has_sign(const az_iot_certificate_provider* p) { - return p != NULL && p->vtable != NULL && p->vtable->version >= CERT_PROVIDER_VTABLE_V2 - && p->vtable->sign != NULL; + return p != NULL && p->vtable != NULL && p->vtable->sign != NULL; } /* Can this credential set possibly complete a TLS handshake? A client @@ -3755,8 +3744,7 @@ az_iot_result az_iot_connection_client_open(az_iot_connection_client* client) if (client->opts.dps.request_operational_certificate) { az_iot_certificate_provider* p = client->opts.certificate_provider; - if (!p || !p->vtable || p->vtable->version < CERT_PROVIDER_VTABLE_V2 - || p->vtable->get_csr == NULL || p->vtable->release_csr == NULL + if (!p || !p->vtable || p->vtable->get_csr == NULL || p->vtable->release_csr == NULL || p->vtable->store_issued_certificate == NULL) { AZ_IOT_LOG_ERROR("connection_client_open: request_operational_certificate requires a " @@ -3813,7 +3801,7 @@ az_iot_result az_iot_connection_client_open(az_iot_connection_client* client) az_iot_certificate_provider* p = client->opts.certificate_provider; if (p->vtable == NULL || p->vtable->load == NULL) { - /* load() is the one hook every vtable version requires. Without it the + /* load() is the one hook every provider must have. Without it the * connect paths have nothing to ask for credentials, so the client would * dereference NULL a few frames later. */ AZ_IOT_LOG_ERROR("connection_client_open: certificate provider vtable has no load()"); @@ -5385,13 +5373,12 @@ static void csr_parse_error(az_span payload, int32_t* out_code, int32_t* out_ret } /** - * @brief True when @p p can both produce a CSR and store its chain (vtable v2). + * @brief True when @p p can both produce a CSR and store its chain. */ static bool provider_owns_renewal(const az_iot_certificate_provider* p) { - return p != NULL && p->vtable != NULL && p->vtable->version >= CERT_PROVIDER_VTABLE_V2 - && p->vtable->get_csr != NULL && p->vtable->release_csr != NULL - && p->vtable->store_issued_certificate != NULL; + return p != NULL && p->vtable != NULL && p->vtable->get_csr != NULL + && p->vtable->release_csr != NULL && p->vtable->store_issued_certificate != NULL; } /** diff --git a/c/tests/e2e/tests/e2e_custody_test.c b/c/tests/e2e/tests/e2e_custody_test.c index 01ac60781..3495e64bf 100644 --- a/c/tests/e2e/tests/e2e_custody_test.c +++ b/c/tests/e2e/tests/e2e_custody_test.c @@ -131,7 +131,6 @@ static void custody_release(az_iot_certificate_provider* self, az_iot_certificat static void custody_deinit(az_iot_certificate_provider* self) { (void)self; } static const az_iot_certificate_provider_vtable k_custody_vtable = { - .version = AZ_IOT_CERTIFICATE_PROVIDER_VTABLE_VERSION, .load = custody_load, .release = custody_release, .deinit = custody_deinit, diff --git a/c/tests/unit/certificate_provider_managed_test.c b/c/tests/unit/certificate_provider_managed_test.c index 4b9b470a2..74444112b 100644 --- a/c/tests/unit/certificate_provider_managed_test.c +++ b/c/tests/unit/certificate_provider_managed_test.c @@ -119,7 +119,6 @@ static void managed_init_generates_key_and_valid_csr(void** state) az_iot_certificate_provider_managed prov; assert_int_equal(AZ_IOT_OK, az_iot_certificate_provider_managed_init(&prov, &opts)); assert_non_null(prov.base.vtable); - assert_int_equal(AZ_IOT_CERTIFICATE_PROVIDER_VTABLE_VERSION, prov.base.vtable->version); /* CSR carries the requested CN and is self-consistent (verifies with its * own public key). */ @@ -814,18 +813,18 @@ static bool has_owner_only_dacl(const char* path) } SECURITY_DESCRIPTOR_CONTROL control = 0; DWORD revision = 0; - ACL_SIZE_INFORMATION info; + ACL_SIZE_INFORMATION info = { 0 }; bool ok = dacl != NULL && GetSecurityDescriptorControl(sd, &control, &revision) && (control & SE_DACL_PROTECTED) != 0 && GetAclInformation(dacl, &info, (DWORD)sizeof(info), AclSizeInformation) && info.AceCount == 2; BYTE owner_rights[SECURITY_MAX_SID_SIZE]; - BYTE system[SECURITY_MAX_SID_SIZE]; + BYTE system_sid[SECURITY_MAX_SID_SIZE]; DWORD n1 = (DWORD)sizeof(owner_rights); - DWORD n2 = (DWORD)sizeof(system); + DWORD n2 = (DWORD)sizeof(system_sid); ok = ok && CreateWellKnownSid(WinCreatorOwnerRightsSid, NULL, owner_rights, &n1) - && CreateWellKnownSid(WinLocalSystemSid, NULL, system, &n2); + && CreateWellKnownSid(WinLocalSystemSid, NULL, system_sid, &n2); bool saw_owner = false; bool saw_system = false; for (DWORD i = 0; ok && i < info.AceCount; ++i) @@ -838,7 +837,7 @@ static bool has_owner_only_dacl(const char* path) { saw_owner = true; } - else if (ok && EqualSid(sid, system)) + else if (ok && EqualSid(sid, system_sid)) { saw_system = true; } diff --git a/c/tests/unit/connection_client_test.c b/c/tests/unit/connection_client_test.c index ccb93be44..685b5969a 100644 --- a/c/tests/unit/connection_client_test.c +++ b/c/tests/unit/connection_client_test.c @@ -1313,7 +1313,7 @@ static void hub_mqtt_v5_birth_ack_truncated_payload_is_safe(void** state) } /* D2: request_operational_certificate requires a certificate_provider whose - * vtable exposes get_csr (ABI version >= 2). open() must reject otherwise. */ + * vtable exposes get_csr. open() must reject otherwise. */ static void open_rejects_operational_cert_without_csr_provider(void** state) { (void)state; @@ -1330,11 +1330,9 @@ static void open_rejects_operational_cert_without_csr_provider(void** state) assert_int_equal(az_iot_connection_client_open(&c1), AZ_IOT_ERR_NOT_SUPPORTED); az_iot_connection_client_deinit(&c1); - /* Case 2: a v2 provider that does not implement get_csr (all hooks NULL; + /* Case 2: a provider that does not implement get_csr (all hooks NULL; * open() rejects before any hook is invoked). */ - static const az_iot_certificate_provider_vtable no_csr_vtable = { - .version = AZ_IOT_CERTIFICATE_PROVIDER_VTABLE_VERSION, - }; + static const az_iot_certificate_provider_vtable no_csr_vtable = { 0 }; az_iot_certificate_provider prov = { .vtable = &no_csr_vtable }; opts.certificate_provider = &prov; @@ -1414,7 +1412,6 @@ static az_iot_result fake_store( return f->store_rc; } static const az_iot_certificate_provider_vtable k_fake_csr_vtable = { - .version = AZ_IOT_CERTIFICATE_PROVIDER_VTABLE_VERSION, .load = fake_csr_load, .release = fake_csr_release, .deinit = fake_csr_destroy, @@ -1598,7 +1595,6 @@ static void open_rejects_operational_cert_without_release_or_store(void** state) { (void)state; static const az_iot_certificate_provider_vtable no_store = { - .version = AZ_IOT_CERTIFICATE_PROVIDER_VTABLE_VERSION, .load = fake_csr_load, .release = fake_csr_release, .deinit = fake_csr_destroy, @@ -1606,7 +1602,6 @@ static void open_rejects_operational_cert_without_release_or_store(void** state) .release_csr = fake_release_csr, }; static const az_iot_certificate_provider_vtable no_release_csr = { - .version = AZ_IOT_CERTIFICATE_PROVIDER_VTABLE_VERSION, .load = fake_csr_load, .release = fake_csr_release, .deinit = fake_csr_destroy, @@ -1914,7 +1909,6 @@ static az_iot_result renew_store( } static const az_iot_certificate_provider_vtable k_renew_vtable = { - .version = AZ_IOT_CERTIFICATE_PROVIDER_VTABLE_VERSION, .load = renew_load, .release = fake_csr_release, .deinit = fake_csr_destroy, @@ -1925,23 +1919,19 @@ static const az_iot_certificate_provider_vtable k_renew_vtable = { /* Can store but not produce a CSR. */ static const az_iot_certificate_provider_vtable k_renew_vtable_no_csr = { - .version = AZ_IOT_CERTIFICATE_PROVIDER_VTABLE_VERSION, .load = renew_load, .release = fake_csr_release, .deinit = fake_csr_destroy, .store_issued_certificate = renew_store, }; -/* v1 vtable: the CSR and store slots are beyond what the provider declares, so - * the client must not call them even though they are populated here. */ -static const az_iot_certificate_provider_vtable k_renew_vtable_v1 = { - .version = 1u, +/* Can produce a CSR but not store the chain. */ +static const az_iot_certificate_provider_vtable k_renew_vtable_no_store = { .load = renew_load, .release = fake_csr_release, .deinit = fake_csr_destroy, .get_csr = renew_get_csr, .release_csr = renew_release_csr, - .store_issued_certificate = renew_store, }; typedef struct renew_cb_ctx @@ -2157,10 +2147,10 @@ static void send_csr_without_a_csr_needs_a_provider_that_can_renew(void** state) { (void)state; renew_fixture rf; - renew_provider v1 = { .base.vtable = &k_renew_vtable_v1 }; - renew_connect(&rf, &v1); - assert_send_csr_without_csr_refused(&rf, &v1); - assert_int_equal(v1.get_csr_calls, 0); + renew_provider no_store = { .base.vtable = &k_renew_vtable_no_store }; + renew_connect(&rf, &no_store); + assert_send_csr_without_csr_refused(&rf, &no_store); + assert_int_equal(no_store.get_csr_calls, 0); az_iot_connection_client_deinit(&rf.client); renew_provider no_csr = { .base.vtable = &k_renew_vtable_no_csr }; diff --git a/c/tests/unit/connection_dps_payload_test.c b/c/tests/unit/connection_dps_payload_test.c index 1c9ceed0f..a114fa772 100644 --- a/c/tests/unit/connection_dps_payload_test.c +++ b/c/tests/unit/connection_dps_payload_test.c @@ -107,7 +107,6 @@ static az_iot_result csr_provider_store( } static const az_iot_certificate_provider_vtable k_csr_vtable = { - .version = AZ_IOT_CERTIFICATE_PROVIDER_VTABLE_VERSION, .load = csr_provider_load, .release = csr_provider_release, .deinit = csr_provider_deinit, diff --git a/c/tests/unit/connection_key_custody_test.c b/c/tests/unit/connection_key_custody_test.c index 04bae5359..f8b847a3e 100644 --- a/c/tests/unit/connection_key_custody_test.c +++ b/c/tests/unit/connection_key_custody_test.c @@ -8,8 +8,8 @@ * These cases cover the seam between az_iot_certificate_material and * az_iot_mqtt_tls_options: the key-reference fields and the sign() hook must * reach the adapter on BOTH connect paths (the DPS/bootstrap one and the - * operational/reconnect one), the sign() hook must be gated on the vtable - * version, and a credential that cannot sign at all must be refused before a + * operational/reconnect one), the sign() hook must be forwarded only when set, + * and a credential that cannot sign at all must be refused before a * socket exists rather than inside the TLS handshake. * * Driven through the public API and observed through the in-memory mock @@ -115,31 +115,19 @@ static az_iot_result custody_sign( return AZ_IOT_OK; } -static const az_iot_certificate_provider_vtable k_vtable_v2_with_sign = { - .version = 2u, +static const az_iot_certificate_provider_vtable k_vtable_with_sign = { .load = custody_load, .release = custody_release, .deinit = custody_deinit, .sign = custody_sign, }; -static const az_iot_certificate_provider_vtable k_vtable_v2_without_sign = { - .version = 2u, +static const az_iot_certificate_provider_vtable k_vtable_without_sign = { .load = custody_load, .release = custody_release, .deinit = custody_deinit, }; -/* A v1 provider that nevertheless populates the v2 sign slot. The slot did not - * exist at v1, so the client must not call it however it looks. */ -static const az_iot_certificate_provider_vtable k_vtable_v1_with_sign = { - .version = 1u, - .load = custody_load, - .release = custody_release, - .deinit = custody_deinit, - .sign = custody_sign, -}; - static void custody_provider_init( custody_provider* p, const az_iot_certificate_provider_vtable* vtable) @@ -171,7 +159,7 @@ static int setup(void** state) { fixture* fx = (fixture*)calloc(1, sizeof(*fx)); assert_non_null(fx); - custody_provider_init(&fx->provider, &k_vtable_v2_with_sign); + custody_provider_init(&fx->provider, &k_vtable_with_sign); fx->factory = az_iot_mock_mqtt_factory_create(AZ_IOT_MQTT_VERSION_3_1_1); assert_non_null(fx->factory); *state = fx; @@ -387,14 +375,8 @@ static void the_forwarded_sign_hook_rejects_an_unusable_provider(void** state) c->connect.sign(&no_vtable, digest, sizeof(digest), sig, sizeof(sig), &sig_len), AZ_IOT_ERR_INVALID_ARG); - /* A vtable from before the slot existed. */ - az_iot_certificate_provider v1 = { .vtable = &k_vtable_v1_with_sign }; - assert_int_equal( - c->connect.sign(&v1, digest, sizeof(digest), sig, sizeof(sig), &sig_len), - AZ_IOT_ERR_INVALID_ARG); - - /* v2, but the slot is empty. */ - az_iot_certificate_provider no_sign = { .vtable = &k_vtable_v2_without_sign }; + /* The slot is empty. */ + az_iot_certificate_provider no_sign = { .vtable = &k_vtable_without_sign }; assert_int_equal( c->connect.sign(&no_sign, digest, sizeof(digest), sig, sizeof(sig), &sig_len), AZ_IOT_ERR_INVALID_ARG); @@ -403,14 +385,14 @@ static void the_forwarded_sign_hook_rejects_an_unusable_provider(void** state) } /* ------------------------------------------------------------------------- */ -/* v2 gating */ +/* sign() forwarding */ /* ------------------------------------------------------------------------- */ -/* A v2 provider that leaves .sign NULL gets no hook forwarded. */ -static void a_v2_provider_without_sign_forwards_no_hook(void** state) +/* A provider that leaves .sign NULL gets no hook forwarded. */ +static void a_provider_without_sign_forwards_no_hook(void** state) { fixture* fx = (fixture*)*state; - fx->provider.base.vtable = &k_vtable_v2_without_sign; + fx->provider.base.vtable = &k_vtable_without_sign; init_client(fx, false); assert_int_equal(az_iot_connection_client_open(&fx->client), AZ_IOT_OK); @@ -418,25 +400,10 @@ static void a_v2_provider_without_sign_forwards_no_hook(void** state) assert_non_null(c); assert_false(c->connect.has_sign); assert_null(c->connect.sign_ctx); - /* The key reference itself is a v1 material field and still travels. */ + /* The key reference is a material field and still travels. */ assert_string_equal(c->connect.crypto_engine_id, "pkcs11"); } -/* The sign slot does not exist below vtable version 2. A v1 provider whose - * memory happens to hold something there must never be called through it. */ -static void a_v1_provider_sign_slot_is_ignored(void** state) -{ - fixture* fx = (fixture*)*state; - fx->provider.base.vtable = &k_vtable_v1_with_sign; - init_client(fx, false); - assert_int_equal(az_iot_connection_client_open(&fx->client), AZ_IOT_OK); - - const az_iot_mock_call* c = last_connect(fx); - assert_non_null(c); - assert_false(c->connect.has_sign); - assert_int_equal(fx->provider.sign_calls, 0); -} - /* ------------------------------------------------------------------------- */ /* validation */ /* ------------------------------------------------------------------------- */ @@ -446,7 +413,7 @@ static void a_v1_provider_sign_slot_is_ignored(void** state) static void a_certificate_without_any_key_is_refused_at_open(void** state) { fixture* fx = (fixture*)*state; - fx->provider.base.vtable = &k_vtable_v2_without_sign; + fx->provider.base.vtable = &k_vtable_without_sign; fx->provider.material.client_key_uri = NULL; fx->provider.material.crypto_engine_id = NULL; init_client(fx, false); @@ -460,7 +427,7 @@ static void a_certificate_without_any_key_is_refused_at_open(void** state) static void a_key_uri_without_an_engine_id_is_refused(void** state) { fixture* fx = (fixture*)*state; - fx->provider.base.vtable = &k_vtable_v2_without_sign; + fx->provider.base.vtable = &k_vtable_without_sign; fx->provider.material.crypto_engine_id = NULL; init_client(fx, false); assert_int_equal(az_iot_connection_client_open(&fx->client), AZ_IOT_ERR_CREDENTIAL_INCOMPLETE); @@ -499,7 +466,7 @@ static void a_sign_hook_alone_satisfies_the_key_requirement(void** state) static void material_without_a_client_certificate_is_not_rejected(void** state) { fixture* fx = (fixture*)*state; - fx->provider.base.vtable = &k_vtable_v2_without_sign; + fx->provider.base.vtable = &k_vtable_without_sign; memset(&fx->provider.material, 0, sizeof(fx->provider.material)); fx->provider.material.trusted_ca_path = "/dev/null/ca.pem"; init_client(fx, false); @@ -514,7 +481,7 @@ static void material_without_a_client_certificate_is_not_rejected(void** state) static void a_plain_pem_credential_is_unaffected(void** state) { fixture* fx = (fixture*)*state; - fx->provider.base.vtable = &k_vtable_v2_without_sign; + fx->provider.base.vtable = &k_vtable_without_sign; memset(&fx->provider.material, 0, sizeof(fx->provider.material)); fx->provider.material.client_cert_pem = "cert"; fx->provider.material.client_key_pem = "key"; @@ -536,7 +503,7 @@ static void a_plain_pem_credential_is_unaffected(void** state) static void a_provider_with_no_material_yet_is_not_refused(void** state) { fixture* fx = (fixture*)*state; - fx->provider.base.vtable = &k_vtable_v2_without_sign; + fx->provider.base.vtable = &k_vtable_without_sign; fx->provider.operational_result = AZ_IOT_ERR_NOT_INITIALIZED; /* Both roles fail: make load() refuse the bootstrap role as well by giving * the provider a vtable whose load always fails. */ @@ -544,7 +511,7 @@ static void a_provider_with_no_material_yet_is_not_refused(void** state) assert_int_equal(az_iot_connection_client_open(&fx->client), AZ_IOT_OK); } -/* load() is required at every vtable version. A provider without one is a +/* load() is required. A provider without one is a * configuration error open() names, not a NULL dereference several frames * later inside the connect attempt. */ /* A provider whose vtable pointer is NULL is a caller bug, and the client's job @@ -563,8 +530,8 @@ static void a_provider_with_a_null_vtable_is_rejected(void** state) assert_int_equal(az_iot_connection_client_open(&fx->client), AZ_IOT_ERR_NOT_SUPPORTED); } -/* The same provider on the CSR path, which gates on the v2 ABI before looking - * for get_csr and so reads the vtable earlier still. */ +/* The same provider on the CSR path, which checks for get_csr and so reads the + * vtable earlier still. */ static void a_null_vtable_is_rejected_on_the_csr_path(void** state) { fixture* fx = (fixture*)*state; @@ -587,7 +554,7 @@ static void a_null_vtable_is_rejected_on_the_csr_path(void** state) static void a_provider_without_load_is_rejected(void** state) { fixture* fx = (fixture*)*state; - static const az_iot_certificate_provider_vtable k_no_load = { .version = 2u }; + static const az_iot_certificate_provider_vtable k_no_load = { 0 }; fx->provider.base.vtable = &k_no_load; init_client(fx, false); assert_int_equal(az_iot_connection_client_open(&fx->client), AZ_IOT_ERR_NOT_SUPPORTED); @@ -599,7 +566,7 @@ static void a_provider_without_load_is_rejected(void** state) static void a_credential_that_degrades_after_open_fails_the_connect(void** state) { fixture* fx = (fixture*)*state; - fx->provider.base.vtable = &k_vtable_v2_without_sign; + fx->provider.base.vtable = &k_vtable_without_sign; /* Call 1 is the open() pre-flight and succeeds; call 2 is the connect * attempt's own load(), and it comes back without a key. */ fx->provider.degrade_after_calls = 1; @@ -612,7 +579,7 @@ static void a_credential_that_degrades_after_open_fails_the_connect(void** state static void a_dps_credential_without_a_key_fails_the_connect(void** state) { fixture* fx = (fixture*)*state; - fx->provider.base.vtable = &k_vtable_v2_without_sign; + fx->provider.base.vtable = &k_vtable_without_sign; fx->provider.degrade_after_calls = 1; init_client(fx, true); assert_int_equal(az_iot_connection_client_open(&fx->client), AZ_IOT_ERR_CREDENTIAL_INCOMPLETE); @@ -640,8 +607,7 @@ int main(void) the_forwarded_sign_hook_preserves_out_len_on_failure, setup, teardown), cmocka_unit_test_setup_teardown( the_forwarded_sign_hook_rejects_an_unusable_provider, setup, teardown), - cmocka_unit_test_setup_teardown(a_v2_provider_without_sign_forwards_no_hook, setup, teardown), - cmocka_unit_test_setup_teardown(a_v1_provider_sign_slot_is_ignored, setup, teardown), + cmocka_unit_test_setup_teardown(a_provider_without_sign_forwards_no_hook, setup, teardown), cmocka_unit_test_setup_teardown( a_certificate_without_any_key_is_refused_at_open, setup, teardown), cmocka_unit_test_setup_teardown(a_key_uri_without_an_engine_id_is_refused, setup, teardown), From df83556e676dc68f0de442a93babcffe5c589363 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Tue, 29 Sep 2026 04:36:14 +0000 Subject: [PATCH 13/17] [C] Bound the persisted chain read; sample refuses empty entries - Managed provider: a persisted chain over 64 KiB is refused at init rather than buffered. - Sample provider: an empty issued entry fails the store, like the managed provider, so the previous chain is kept. - Tests for both, and for init keeping an existing key file's mode. - Restore AZ_IOT_HUB_PROFILE_INIT's formatting to main's. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../az_iot_certificate_provider_managed.c | 13 +- c/docs/test-coverage.md | 158 +++++++++--------- c/inc/azure/iot/az_iot_connection_client.h | 11 +- c/samples/common/sample_cert_provider.c | 3 +- .../unit/certificate_provider_managed_test.c | 61 +++++++ 5 files changed, 158 insertions(+), 88 deletions(-) diff --git a/c/adapters/cert_openssl/az_iot_certificate_provider_managed.c b/c/adapters/cert_openssl/az_iot_certificate_provider_managed.c index 49057a649..83279052e 100644 --- a/c/adapters/cert_openssl/az_iot_certificate_provider_managed.c +++ b/c/adapters/cert_openssl/az_iot_certificate_provider_managed.c @@ -68,6 +68,10 @@ /* Encoded length (excluding NUL) of base64 over `binary_len` bytes. */ #define BASE64_ENCODED_LEN(binary_len) ((((binary_len) + 2) / 3) * 4) +/* Largest chain file init() reads back. An issued chain is a few certificates + * of a few KB each; anything larger is refused rather than buffered. */ +#define MANAGED_MAX_CHAIN_BYTES (64u * 1024u) + #ifdef _WIN32 /* Owner-only DACL for written files: full access for the owner and SYSTEM, * protected from inheriting the directory's ACEs. */ @@ -374,8 +378,9 @@ static bool chain_matches_key(const char* data, size_t len, EVP_PKEY* key, size_ * @brief True when @p path holds a certificate chain that passes * chain_matches_key() for @p key. * - * An empty, partial or unparseable file, or one issued for a different key, - * is not a usable identity: TLS would fail with it on every connect. + * An empty, partial or unparseable file, one issued for a different key, or + * one over MANAGED_MAX_CHAIN_BYTES is not a usable identity: TLS would fail + * with it on every connect. */ static bool operational_cert_is_valid(const char* path, EVP_PKEY* key) { @@ -384,9 +389,11 @@ static bool operational_cert_is_valid(const char* path, EVP_PKEY* key) bool ok = mem != NULL; char buf[1024]; int n = 0; + size_t total = 0; while (ok && (n = BIO_read(in, buf, (int)sizeof(buf))) > 0) { - ok = BIO_write(mem, buf, n) == n; + total += (size_t)n; + ok = total <= MANAGED_MAX_CHAIN_BYTES && BIO_write(mem, buf, n) == n; } if (ok) { diff --git a/c/docs/test-coverage.md b/c/docs/test-coverage.md index 90a9d2732..d20726ef4 100644 --- a/c/docs/test-coverage.md +++ b/c/docs/test-coverage.md @@ -192,13 +192,13 @@ Covers `az_iot_connection_client` lifecycle, CONNACK handling, reconnection, the | | MQTTv5 hub suback failure faults | `dev/#` SUBACK error → FAULTED. | unit | Done | [hub_mqtt_v5_suback_failure_faults](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L825) | | | MQTTv5 hub birth ack timeout faults | No birth-ack within the timeout, no reconnect policy → FAULTED/TIMEOUT. | unit | Done | [hub_mqtt_v5_birth_ack_timeout_faults](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1095) | | | MQTTv3 skips birth handshake | v3.1.1 contrast: CONNACK → CONNECTED with no presence traffic. | unit | Done | [mqtt_v3_connect_skips_birth_handshake](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L888) | -| | MQTTv5 hub birth publish failure faults | Adapter rejects the birth PUBLISH. | unit | Done | [hub_mqtt_v5_birth_publish_failure_faults](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2243) | -| | MQTTv5 hub birth timeout retries with new nonce | A stale ack must not satisfy the retry. | unit | Done | [hub_mqtt_v5_birth_timeout_retries_with_a_new_nonce](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2322) | -| | MQTTv5 hub without v5 factory not supported | No silent downgrade to an MQTTv3 session. | unit | Done | [hub_mqtt_v5_without_v5_factory_is_not_supported](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2222) | -| | MQTTv5 hub birth ack before suback ignored | No nonce exists yet to match against. | unit | Done | [hub_mqtt_v5_birth_ack_before_suback_is_ignored](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2268) | -| DPS provisioning | DPS CSR flow stores issued chain | DPS register with CSR → ASSIGNED → operational chain stored. | unit | Done | [dps_csr_flow_sends_csr_and_stores_issued_chain](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1538) | +| | MQTTv5 hub birth publish failure faults | Adapter rejects the birth PUBLISH. | unit | Done | [hub_mqtt_v5_birth_publish_failure_faults](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2233) | +| | MQTTv5 hub birth timeout retries with new nonce | A stale ack must not satisfy the retry. | unit | Done | [hub_mqtt_v5_birth_timeout_retries_with_a_new_nonce](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2312) | +| | MQTTv5 hub without v5 factory not supported | No silent downgrade to an MQTTv3 session. | unit | Done | [hub_mqtt_v5_without_v5_factory_is_not_supported](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2212) | +| | MQTTv5 hub birth ack before suback ignored | No nonce exists yet to match against. | unit | Done | [hub_mqtt_v5_birth_ack_before_suback_is_ignored](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2258) | +| DPS provisioning | DPS CSR flow stores issued chain | DPS register with CSR → ASSIGNED → operational chain stored. | unit | Done | [dps_csr_flow_sends_csr_and_stores_issued_chain](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1535) | | | Open rejects operational cert without CSR provider | `request_operational_certificate` set but provider has no `get_csr`. | unit | Done | [open_rejects_operational_cert_without_csr_provider](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1317) | -| | Open rejects operational cert without payload buffer | `request_operational_certificate` set but `csr_payload_buffer` empty. | unit | Done | [open_rejects_operational_cert_without_payload_buffer](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1726) | +| | Open rejects operational cert without payload buffer | `request_operational_certificate` set but `csr_payload_buffer` empty. | unit | Done | [open_rejects_operational_cert_without_payload_buffer](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1721) | | | DPS connects to the global endpoint | Default `global.azure-devices-provisioning.net`. | unit | Done | [dps_connects_to_the_global_endpoint_by_default](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_dps_test.c#L309) | | | DPS honors a custom global endpoint | `dps.global_endpoint` override. | unit | Done | [dps_honors_a_custom_global_endpoint](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_dps_test.c#L319) | | | DPS uses v3 1 1 even for an MQTTv5 hub | `connection_profile = AZ_IOT_CONNECTION_PROFILE_MQTT_V5`; the v5 factory is left untouched. | unit | Done | [dps_uses_v3_1_1_even_when_the_hub_is_mqtt_v5](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_dps_test.c#L386) | @@ -845,63 +845,65 @@ runtime renewal against the connected hub over `$iothub/credentials/...` per | Managed provider | Init generates a key | — | unit | Done | [managed_init_generates_key_and_valid_csr](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L106) | | | Init emits a parseable CSR | — | unit | Done | [managed_init_generates_key_and_valid_csr](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L106) | | | The CSR subject carries the registration id | — | unit | Done | [managed_init_generates_key_and_valid_csr](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L106) | -| | Init rejects bad args | — | unit | Done | [managed_init_rejects_bad_args](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L222) | -| | Store persists the issued chain | — | unit | Done | [managed_store_persists_and_survives_restart](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L167) | -| | The persisted chain reloads after a restart | — | unit | Done | [managed_store_persists_and_survives_restart](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L167) | +| | Init rejects bad args | — | unit | Done | [managed_init_rejects_bad_args](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L221) | +| | Store persists the issued chain | — | unit | Done | [managed_store_persists_and_survives_restart](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L166) | +| | The persisted chain reloads after a restart | — | unit | Done | [managed_store_persists_and_survives_restart](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L166) | | | A CSR buffer that is too small is reported | — | unit | Pending | *certificate_provider_managed_test.c* | -| | A stored chain that is not a certificate is refused | The store validates the chain before replacing the file; nothing is written and no operational identity is claimed. | unit | Done | [managed_a_stored_chain_that_is_not_a_certificate_is_refused](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L509) | -| | A failed store keeps the previous certificate | All-or-nothing, in-process and after a restart, so a failed renewal cannot break the next connect. | unit | Done | [managed_a_failed_store_keeps_the_previous_certificate](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L539) | -| | A chain for another key is refused | The leaf must certify the operational key; the previous chain is kept. | unit | Done | [managed_store_refuses_a_chain_for_another_key](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L577) | -| | A persisted chain for another key is not used | After the key is replaced, the stored chain no longer certifies it. | unit | Done | [managed_a_persisted_chain_for_another_key_is_not_used](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L774) | -| | A chain with a bad trailing entry is refused | Every entry must parse; the previous chain is kept. | unit | Done | [managed_store_refuses_a_chain_with_a_bad_trailing_entry](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L612) | -| | A chain with an empty entry is refused | — | unit | Done | [managed_store_refuses_a_chain_with_an_empty_entry](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L645) | -| | A persisted chain with a truncated entry is not used | — | unit | Done | [managed_a_persisted_chain_with_a_truncated_entry_is_not_used](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L676) | -| | A chain followed by other text is refused | In an issued entry or appended to the file. | unit | Done | [managed_a_chain_followed_by_other_text_is_refused](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L724) | -| | Written files are owner-only | Key and chain are exactly 0600 whatever the umask, including 0777, and reload. POSIX. | unit | Done | [managed_written_files_are_owner_only](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L893) | -| | Written files have an owner-only DACL | Protected DACL: Owner Rights and SYSTEM only; reloads. Windows only. | unit | Done | [managed_written_files_have_an_owner_only_dacl](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L855) | -| | A link at a destination is replaced, not followed | Key and chain. POSIX only. | unit | Done | [managed_writes_replace_a_link_instead_of_following_it](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L924) | -| | Store overwrites a previously issued chain | Renewal replaces rather than appends. | unit | Done | [managed_store_overwrites_a_previously_issued_chain](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L478) | -| | The sign hook is not offered by either bundled provider | Neither provider sets `sign`, so the connect path must keep checking it for NULL. Pinned so that adding an implementation is a deliberate act. | unit | Done | [the_sign_hook_is_not_offered_by_this_provider](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L966) | -| | An operational load with no stored chain returns not found | The connect path uses this code to decide it must still enroll. Belongs to the managed provider: the PEM loader ignores the role. | unit | Done | [managed_operational_load_without_a_stored_chain_is_not_found](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L277) | -| | Load rejects null arguments | — | unit | Done | [managed_load_rejects_null_arguments](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L256) | -| | Release leaves the material usable | The paths are borrowed from the provider struct, so freeing them here would dangle what the connection is still using. | unit | Done | [managed_release_leaves_the_material_usable](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L297) | -| | Get CSR rejects null arguments | — | unit | Done | [managed_get_csr_rejects_null_arguments](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L319) | -| | Store rejects an issuance with no certificates | Accepting an empty chain would flip the operational flag with nothing on disk. | unit | Done | [managed_store_rejects_null_arguments](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L336) | -| | Deinit tolerates null | — | unit | Done | [managed_deinit_tolerates_null](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L364) | -| | Deinit through the vtable destroys the provider | What a generic owner of an `az_iot_certificate_provider` calls. | unit | Done | [managed_deinit_through_the_vtable_destroys_the_provider](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L372) | -| Issuance via DPS | The CSR rides the DPS registration body | — | unit | Done | [dps_csr_flow_sends_csr_and_stores_issued_chain](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1538) | -| | The issued chain is handed to the provider | — | unit | Done | [dps_csr_flow_sends_csr_and_stores_issued_chain](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1538) | +| | A stored chain that is not a certificate is refused | The store validates the chain before replacing the file; nothing is written and no operational identity is claimed. | unit | Done | [managed_a_stored_chain_that_is_not_a_certificate_is_refused](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L508) | +| | A failed store keeps the previous certificate | All-or-nothing, in-process and after a restart, so a failed renewal cannot break the next connect. | unit | Done | [managed_a_failed_store_keeps_the_previous_certificate](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L538) | +| | A chain for another key is refused | The leaf must certify the operational key; the previous chain is kept. | unit | Done | [managed_store_refuses_a_chain_for_another_key](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L576) | +| | A persisted chain for another key is not used | After the key is replaced, the stored chain no longer certifies it. | unit | Done | [managed_a_persisted_chain_for_another_key_is_not_used](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L814) | +| | A chain with a bad trailing entry is refused | Every entry must parse; the previous chain is kept. | unit | Done | [managed_store_refuses_a_chain_with_a_bad_trailing_entry](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L611) | +| | A chain with an empty entry is refused | — | unit | Done | [managed_store_refuses_a_chain_with_an_empty_entry](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L644) | +| | A persisted chain with a truncated entry is not used | — | unit | Done | [managed_a_persisted_chain_with_a_truncated_entry_is_not_used](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L675) | +| | A chain followed by other text is refused | In an issued entry or appended to the file. | unit | Done | [managed_a_chain_followed_by_other_text_is_refused](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L723) | +| | An oversized persisted chain is not used | Over 64 KiB is refused rather than buffered. | unit | Done | [managed_an_oversized_persisted_chain_is_not_used](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L773) | +| | Written files are owner-only | Key and chain are exactly 0600 whatever the umask, including 0777, and reload. POSIX. | unit | Done | [managed_written_files_are_owner_only](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L933) | +| | Init keeps an existing key file's mode | The key is loaded, not rewritten. POSIX. | unit | Done | [managed_init_keeps_an_existing_key_file_mode](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L964) | +| | Written files have an owner-only DACL | Protected DACL: Owner Rights and SYSTEM only; reloads. Windows only. | unit | Done | [managed_written_files_have_an_owner_only_dacl](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L895) | +| | A link at a destination is replaced, not followed | Key and chain. POSIX only. | unit | Done | [managed_writes_replace_a_link_instead_of_following_it](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L982) | +| | Store overwrites a previously issued chain | Renewal replaces rather than appends. | unit | Done | [managed_store_overwrites_a_previously_issued_chain](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L477) | +| | The sign hook is not offered by either bundled provider | Neither provider sets `sign`, so the connect path must keep checking it for NULL. Pinned so that adding an implementation is a deliberate act. | unit | Done | [the_sign_hook_is_not_offered_by_this_provider](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L1024) | +| | An operational load with no stored chain returns not found | The connect path uses this code to decide it must still enroll. Belongs to the managed provider: the PEM loader ignores the role. | unit | Done | [managed_operational_load_without_a_stored_chain_is_not_found](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L276) | +| | Load rejects null arguments | — | unit | Done | [managed_load_rejects_null_arguments](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L255) | +| | Release leaves the material usable | The paths are borrowed from the provider struct, so freeing them here would dangle what the connection is still using. | unit | Done | [managed_release_leaves_the_material_usable](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L296) | +| | Get CSR rejects null arguments | — | unit | Done | [managed_get_csr_rejects_null_arguments](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L318) | +| | Store rejects an issuance with no certificates | Accepting an empty chain would flip the operational flag with nothing on disk. | unit | Done | [managed_store_rejects_null_arguments](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L335) | +| | Deinit tolerates null | — | unit | Done | [managed_deinit_tolerates_null](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L363) | +| | Deinit through the vtable destroys the provider | What a generic owner of an `az_iot_certificate_provider` calls. | unit | Done | [managed_deinit_through_the_vtable_destroys_the_provider](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L371) | +| Issuance via DPS | The CSR rides the DPS registration body | — | unit | Done | [dps_csr_flow_sends_csr_and_stores_issued_chain](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1535) | +| | The issued chain is handed to the provider | — | unit | Done | [dps_csr_flow_sends_csr_and_stores_issued_chain](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1535) | | | Open is refused without a CSR-capable provider | — | unit | Done | [open_rejects_operational_cert_without_csr_provider](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1317) | -| | Open is refused without a payload buffer | — | unit | Done | [open_rejects_operational_cert_without_payload_buffer](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1726) | +| | Open is refused without a payload buffer | — | unit | Done | [open_rejects_operational_cert_without_payload_buffer](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1721) | | | The DPS username carries the CSR API version | The CSR flow needs a newer api-version than the azure-sdk-for-c default. | unit | Pending | *connection_client_test.c* | -| | The operational cert callback fires with the issued chain and store result | Fires after the store. | unit | Done | [dps_csr_flow_sends_csr_and_stores_issued_chain](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1538) | -| | A failed store still delivers the chain, with the error | The registration then fails. | unit | Done | [dps_store_failure_still_delivers_the_chain_with_the_error](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1571) | -| | A callback that unregisters itself still handles the chain | The chain is stored and the registration proceeds. | unit | Done | [dps_a_callback_that_unregisters_itself_still_handles_the_chain](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1644) | -| | Open is refused without release_csr or store_issued_certificate | DPS enrollment needs the provider to store the chain. | unit | Done | [open_rejects_operational_cert_without_release_or_store](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1597) | -| Hub-side renewal | A renewal publishes the issue-certificate topic | `$iothub/credentials/POST/issueCertificate/?$rid=`. | unit | Done | [send_csr_two_phase_delivers_issued_chain](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1747) | -| | A 202 reports accepted without completing | — | unit | Done | [send_csr_two_phase_delivers_issued_chain](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1747) | -| | A 200 delivers the issued chain | — | unit | Done | [send_csr_two_phase_delivers_issued_chain](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1747) | -| | An error status reports the service code | 409 conflict with an `errorCode` body. | unit | Done | [send_csr_error_reports_service_code](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1805) | -| | Only one renewal is in flight at a time | A second `send_csr()` is refused while one is open. | unit | Done | [send_csr_cancel_frees_slot](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2192) | -| | Cancel frees the slot for a new renewal | — | unit | Done | [send_csr_cancel_frees_slot](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2192) | -| | The renewal subscribes the credentials response filter | `$iothub/credentials/res/#`. | unit | Done | [send_csr_subscribes_the_credentials_response_filter](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2381) | -| | The request body carries the device id and CSR | — | unit | Done | [send_csr_two_phase_delivers_issued_chain](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1747) | -| | The optional replace field is emitted only when supplied | `"replace":"*"`. | unit | Done | [send_csr_emits_the_replace_field_only_when_supplied](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2421) | -| | A response for a different rid is ignored | — | unit | Done | [a_credentials_response_for_a_different_rid_is_ignored](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2452) | -| | A 400 reports the service code | Invalid request payload. | unit | Done | [a_400_reports_the_service_code](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2475) | -| | A 412 reports the service code | No matching request to replace. | unit | Done | [a_412_reports_the_service_code](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2501) | -| | A 429 surfaces the retry-after hint | The response body carries `retryAfter`. | unit | Done | [a_429_surfaces_the_retry_after_hint](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2527) | -| | A malformed issued chain reports a protocol error | 200 whose body has no `certificates` array. | unit | Done | [a_200_without_a_certificates_array_reports_a_failure](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2555) | -| | A provider-made CSR's chain is stored and used on the next connect | `send_csr(NULL)`: CSR from `get_csr()` (subject = device id); stored before the callback; session kept. | unit | Done | [a_provider_csr_chain_is_stored_and_used_on_the_next_connect](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2074) | -| | A failed store keeps the session and the previous credential | Reported in `store_status`. | unit | Done | [a_failed_store_keeps_the_session_and_the_previous_credential](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2101) | -| | An application CSR's chain is not stored | The application owns the key and the chain. | unit | Done | [an_application_csr_chain_is_not_stored](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2121) | -| | Send CSR without a CSR needs a provider that can renew | No provider, no `get_csr`, or no `store_issued_certificate`: refused before publishing; slot stays free. | unit | Done | [send_csr_without_a_csr_needs_a_provider_that_can_renew](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2156) | -| | A failed get_csr fails send CSR | Nothing published. | unit | Done | [a_failed_get_csr_fails_send_csr](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2176) | +| | The operational cert callback fires with the issued chain and store result | Fires after the store. | unit | Done | [dps_csr_flow_sends_csr_and_stores_issued_chain](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1535) | +| | A failed store still delivers the chain, with the error | The registration then fails. | unit | Done | [dps_store_failure_still_delivers_the_chain_with_the_error](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1568) | +| | A callback that unregisters itself still handles the chain | The chain is stored and the registration proceeds. | unit | Done | [dps_a_callback_that_unregisters_itself_still_handles_the_chain](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1639) | +| | Open is refused without release_csr or store_issued_certificate | DPS enrollment needs the provider to store the chain. | unit | Done | [open_rejects_operational_cert_without_release_or_store](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1594) | +| Hub-side renewal | A renewal publishes the issue-certificate topic | `$iothub/credentials/POST/issueCertificate/?$rid=`. | unit | Done | [send_csr_two_phase_delivers_issued_chain](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1742) | +| | A 202 reports accepted without completing | — | unit | Done | [send_csr_two_phase_delivers_issued_chain](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1742) | +| | A 200 delivers the issued chain | — | unit | Done | [send_csr_two_phase_delivers_issued_chain](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1742) | +| | An error status reports the service code | 409 conflict with an `errorCode` body. | unit | Done | [send_csr_error_reports_service_code](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1800) | +| | Only one renewal is in flight at a time | A second `send_csr()` is refused while one is open. | unit | Done | [send_csr_cancel_frees_slot](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2182) | +| | Cancel frees the slot for a new renewal | — | unit | Done | [send_csr_cancel_frees_slot](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2182) | +| | The renewal subscribes the credentials response filter | `$iothub/credentials/res/#`. | unit | Done | [send_csr_subscribes_the_credentials_response_filter](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2371) | +| | The request body carries the device id and CSR | — | unit | Done | [send_csr_two_phase_delivers_issued_chain](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1742) | +| | The optional replace field is emitted only when supplied | `"replace":"*"`. | unit | Done | [send_csr_emits_the_replace_field_only_when_supplied](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2411) | +| | A response for a different rid is ignored | — | unit | Done | [a_credentials_response_for_a_different_rid_is_ignored](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2442) | +| | A 400 reports the service code | Invalid request payload. | unit | Done | [a_400_reports_the_service_code](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2465) | +| | A 412 reports the service code | No matching request to replace. | unit | Done | [a_412_reports_the_service_code](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2491) | +| | A 429 surfaces the retry-after hint | The response body carries `retryAfter`. | unit | Done | [a_429_surfaces_the_retry_after_hint](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2517) | +| | A malformed issued chain reports a protocol error | 200 whose body has no `certificates` array. | unit | Done | [a_200_without_a_certificates_array_reports_a_failure](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2545) | +| | A provider-made CSR's chain is stored and used on the next connect | `send_csr(NULL)`: CSR from `get_csr()` (subject = device id); stored before the callback; session kept. | unit | Done | [a_provider_csr_chain_is_stored_and_used_on_the_next_connect](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2064) | +| | A failed store keeps the session and the previous credential | Reported in `store_status`. | unit | Done | [a_failed_store_keeps_the_session_and_the_previous_credential](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2091) | +| | An application CSR's chain is not stored | The application owns the key and the chain. | unit | Done | [an_application_csr_chain_is_not_stored](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2111) | +| | Send CSR without a CSR needs a provider that can renew | No provider, no `get_csr`, or no `store_issued_certificate`: refused before publishing; slot stays free. | unit | Done | [send_csr_without_a_csr_needs_a_provider_that_can_renew](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2146) | +| | A failed get_csr fails send CSR | Nothing published. | unit | Done | [a_failed_get_csr_fails_send_csr](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2166) | | | A renewal with no response times out | `CSR_OP_TIMEOUT_MS` elapses. | unit | Pending | *connection_client_test.c* | | | A 202 extends the timeout deadline | A slow-but-alive signer is not abandoned. | unit | Pending | *connection_client_test.c* | -| | Send CSR while disconnected is refused | — | unit | Done | [send_csr_while_disconnected_is_refused](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2581) | +| | Send CSR while disconnected is refused | — | unit | Done | [send_csr_while_disconnected_is_refused](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2571) | | | A renewal open across a reconnect is completed or failed | Same family as C-3 / D-3. | unit | Pending | *connection_client_test.c* | -| | A CSR larger than the service cap is refused | `CSR_MAX_BASE64` is 8 KB. | unit | Done | [a_csr_larger_than_the_service_cap_is_refused](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2601) | +| | A CSR larger than the service cap is refused | `CSR_MAX_BASE64` is 8 KB. | unit | Done | [a_csr_larger_than_the_service_cap_is_refused](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2591) | | | Send CSR rejects null arguments | — | unit | Pending | *connection_client_test.c* | | | Send CSR on an MQTTv5 session is not supported | The MQTTv5 renewal topic space is not defined yet, so the call is refused rather than publishing to an MQTTv3 topic from a v5 session. | unit | Pending | *connection_client_test.c* | | | Send CSR without a payload buffer reports not enough space | — | unit | Pending | *connection_client_test.c* | @@ -910,26 +912,26 @@ runtime renewal against the connected hub over `$iothub/credentials/...` per | | DPS CSR enrollment with an RSA key | — | e2e | Done | [test_dps_csr_enrollment_rsa](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/e2e/tests/e2e_csr_test.c#L216) | | | Hub-side renewal against a live hub | `az_iot_connection_client_send_csr()` is unit-tested only. | e2e | Pending | *e2e_csr_test.c* | | | A reconnect on the renewed certificate | The renewed chain is actually usable to authenticate. | e2e | Pending | *e2e_csr_test.c* | -| Non-extractable key custody (D8) | The key reference reaches the adapter on the DPS connect | `client_key_uri`, `crypto_engine_id` and the `sign()` hook, on the bootstrap/DPS path. | unit | Done | [dps_connect_forwards_the_key_reference](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_key_custody_test.c#L240) | -| | The key reference reaches the adapter on the hub connect | The operational/reconnect path is a SECOND copy site; a field that reaches only the first breaks custody after the first rotation. | unit | Done | [hub_connect_forwards_the_key_reference](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_key_custody_test.c#L260) | -| | Bootstrap fallback carries the key reference | Provider holds no operational identity yet. | unit | Done | [hub_connect_falls_back_to_bootstrap_material](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_key_custody_test.c#L277) | -| | The forwarded sign hook reaches the provider | Arguments, signature bytes, the provider's own failure, and a NULL context. | unit | Done | [the_forwarded_sign_hook_calls_the_provider](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_key_custody_test.c#L292) | -| | The forwarded sign hook rejects an unusable provider | Runs inside an adapter's TLS callback, where the context arrived back through a third-party library: a wrong or stale pointer must yield a failed handshake, not a crash inside one. | unit | Done | [the_forwarded_sign_hook_rejects_an_unusable_provider](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_key_custody_test.c#L371) | -| | The forwarded sign hook preserves out_sig_len on failure | `az_iot_mqtt_sign_callback` promises it is untouched on failure; the provider vtable makes no such promise, so the trampoline enforces it. | unit | Done | [the_forwarded_sign_hook_preserves_out_len_on_failure](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_key_custody_test.c#L333) | -| | A provider without sign forwards no hook | — | unit | Done | [a_provider_without_sign_forwards_no_hook](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_key_custody_test.c#L410) | -| | A certificate with no key is refused at open | `AZ_IOT_ERR_CREDENTIAL_INCOMPLETE` before any socket exists. | unit | Done | [a_certificate_without_any_key_is_refused_at_open](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_key_custody_test.c#L446) | -| | A key URI with no engine id is refused | Nothing names the provider that owns it, so nothing could resolve it. | unit | Done | [a_key_uri_without_an_engine_id_is_refused](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_key_custody_test.c#L460) | -| | A key URI with no engine id is allowed with sign | The adapter can sign without resolving the URI. | unit | Done | [a_key_uri_without_an_engine_id_is_allowed_with_sign](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_key_custody_test.c#L471) | -| | A sign hook alone is a complete credential | Signing is all the handshake needs the key for. | unit | Done | [a_sign_hook_alone_satisfies_the_key_requirement](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_key_custody_test.c#L484) | -| | Material with no client certificate is not rejected | Server-authentication-only connections stay legitimate. | unit | Done | [material_without_a_client_certificate_is_not_rejected](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_key_custody_test.c#L499) | -| | A plain PEM credential is unaffected | Regression guard: the custody fields stay empty. | unit | Done | [a_plain_pem_credential_is_unaffected](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_key_custody_test.c#L514) | -| | A provider with no material yet is not refused | It may hold one by the time the connect attempt runs. | unit | Done | [a_provider_with_no_material_yet_is_not_refused](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_key_custody_test.c#L536) | -| | A provider without load is rejected | Named at open instead of dereferenced mid-connect. | unit | Done | [a_provider_without_load_is_rejected](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_key_custody_test.c#L587) | -| | A provider with a NULL vtable is rejected | Named at open rather than dereferenced: the credential pre-flight is the first thing that touches the provider. | unit | Done | [a_provider_with_a_null_vtable_is_rejected](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_key_custody_test.c#L558) | -| | A NULL vtable is rejected on the CSR path | The CSR gate reads the vtable earlier still. | unit | Done | [a_null_vtable_is_rejected_on_the_csr_path](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_key_custody_test.c#L568) | -| | A credential that degrades after open fails the connect | The hub copy site validates too, not only `open()`. | unit | Done | [a_credential_that_degrades_after_open_fails_the_connect](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_key_custody_test.c#L599) | -| | A DPS credential with no key fails the connect | Same, on the other copy site. | unit | Done | [a_dps_credential_without_a_key_fails_the_connect](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_key_custody_test.c#L612) | -| | The new result code has a string | A log line reading `AZ_IOT_ERR_UNKNOWN` is the diagnostic this change exists to prevent. | unit | Done | [the_new_result_code_has_a_string](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_key_custody_test.c#L624) | +| Non-extractable key custody (D8) | The key reference reaches the adapter on the DPS connect | `client_key_uri`, `crypto_engine_id` and the `sign()` hook, on the bootstrap/DPS path. | unit | Done | [dps_connect_forwards_the_key_reference](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_key_custody_test.c#L228) | +| | The key reference reaches the adapter on the hub connect | The operational/reconnect path is a SECOND copy site; a field that reaches only the first breaks custody after the first rotation. | unit | Done | [hub_connect_forwards_the_key_reference](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_key_custody_test.c#L248) | +| | Bootstrap fallback carries the key reference | Provider holds no operational identity yet. | unit | Done | [hub_connect_falls_back_to_bootstrap_material](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_key_custody_test.c#L265) | +| | The forwarded sign hook reaches the provider | Arguments, signature bytes, the provider's own failure, and a NULL context. | unit | Done | [the_forwarded_sign_hook_calls_the_provider](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_key_custody_test.c#L280) | +| | The forwarded sign hook rejects an unusable provider | Runs inside an adapter's TLS callback, where the context arrived back through a third-party library: a wrong or stale pointer must yield a failed handshake, not a crash inside one. | unit | Done | [the_forwarded_sign_hook_rejects_an_unusable_provider](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_key_custody_test.c#L359) | +| | The forwarded sign hook preserves out_sig_len on failure | `az_iot_mqtt_sign_callback` promises it is untouched on failure; the provider vtable makes no such promise, so the trampoline enforces it. | unit | Done | [the_forwarded_sign_hook_preserves_out_len_on_failure](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_key_custody_test.c#L321) | +| | A provider without sign forwards no hook | — | unit | Done | [a_provider_without_sign_forwards_no_hook](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_key_custody_test.c#L392) | +| | A certificate with no key is refused at open | `AZ_IOT_ERR_CREDENTIAL_INCOMPLETE` before any socket exists. | unit | Done | [a_certificate_without_any_key_is_refused_at_open](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_key_custody_test.c#L413) | +| | A key URI with no engine id is refused | Nothing names the provider that owns it, so nothing could resolve it. | unit | Done | [a_key_uri_without_an_engine_id_is_refused](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_key_custody_test.c#L427) | +| | A key URI with no engine id is allowed with sign | The adapter can sign without resolving the URI. | unit | Done | [a_key_uri_without_an_engine_id_is_allowed_with_sign](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_key_custody_test.c#L438) | +| | A sign hook alone is a complete credential | Signing is all the handshake needs the key for. | unit | Done | [a_sign_hook_alone_satisfies_the_key_requirement](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_key_custody_test.c#L451) | +| | Material with no client certificate is not rejected | Server-authentication-only connections stay legitimate. | unit | Done | [material_without_a_client_certificate_is_not_rejected](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_key_custody_test.c#L466) | +| | A plain PEM credential is unaffected | Regression guard: the custody fields stay empty. | unit | Done | [a_plain_pem_credential_is_unaffected](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_key_custody_test.c#L481) | +| | A provider with no material yet is not refused | It may hold one by the time the connect attempt runs. | unit | Done | [a_provider_with_no_material_yet_is_not_refused](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_key_custody_test.c#L503) | +| | A provider without load is rejected | Named at open instead of dereferenced mid-connect. | unit | Done | [a_provider_without_load_is_rejected](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_key_custody_test.c#L554) | +| | A provider with a NULL vtable is rejected | Named at open rather than dereferenced: the credential pre-flight is the first thing that touches the provider. | unit | Done | [a_provider_with_a_null_vtable_is_rejected](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_key_custody_test.c#L525) | +| | A NULL vtable is rejected on the CSR path | The CSR gate reads the vtable earlier still. | unit | Done | [a_null_vtable_is_rejected_on_the_csr_path](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_key_custody_test.c#L535) | +| | A credential that degrades after open fails the connect | The hub copy site validates too, not only `open()`. | unit | Done | [a_credential_that_degrades_after_open_fails_the_connect](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_key_custody_test.c#L566) | +| | A DPS credential with no key fails the connect | Same, on the other copy site. | unit | Done | [a_dps_credential_without_a_key_fails_the_connect](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_key_custody_test.c#L579) | +| | The new result code has a string | A log line reading `AZ_IOT_ERR_UNKNOWN` is the diagnostic this change exists to prevent. | unit | Done | [the_new_result_code_has_a_string](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_key_custody_test.c#L591) | | Paho key custody | A key reference counts as TLS material | Input to the adapter's `use_ssl` decision; otherwise a URI-only credential falls through to a key-less connect. | unit | Done | [a_key_reference_counts_as_tls_material](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/paho_key_custody_test.c#L141) | | | Prepare and release reject null arguments | — | unit | Done | [null_arguments_are_rejected](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/paho_key_custody_test.c#L166) | | | An ordinary key path passes through | Every PEM/file credential is untouched by the custody path. | unit | Done | [an_ordinary_key_path_passes_through](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/paho_key_custody_test.c#L186) | @@ -1030,7 +1032,7 @@ own, so they are unit-tested only. | | Stderr sink honours its minimum level | Below the threshold is dropped before the sink is reached. | unit | Done | [stderr_sink_honours_its_minimum_level](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/diagnostics_test.c#L308) | | | Gen request id fills a bounded string | NUL-terminated inside the buffer, printable throughout. | unit | Done | [gen_request_id_fills_a_bounded_string](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/diagnostics_test.c#L322) | | | Gen request id advances the rng | Two ids from one state differ, or a renewal retry would reuse an id the service already answered. | unit | Done | [gen_request_id_advances_the_rng](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/diagnostics_test.c#L346) | -| Certificate helpers | A generated request id is fresh each time | Repeating one would make the service treat a new renewal as a duplicate. | unit | Done | [cert_util_generates_a_distinct_request_id](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2631) | +| Certificate helpers | A generated request id is fresh each time | Repeating one would make the service treat a new renewal as a duplicate. | unit | Done | [cert_util_generates_a_distinct_request_id](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2621) | ## Test infrastructure diff --git a/c/inc/azure/iot/az_iot_connection_client.h b/c/inc/azure/iot/az_iot_connection_client.h index a2f168f59..4f17f8eac 100644 --- a/c/inc/azure/iot/az_iot_connection_client.h +++ b/c/inc/azure/iot/az_iot_connection_client.h @@ -97,12 +97,11 @@ extern "C" bool connection_profile_raw_truncated; } az_iot_hub_profile; -#define AZ_IOT_HUB_PROFILE_INIT \ - { \ - ._internal_size = sizeof(az_iot_hub_profile), \ - .connection_profile = AZ_IOT_CONNECTION_PROFILE_MQTT_V3, \ - .connection_profile_raw = NULL, \ - .connection_profile_raw_truncated = false, \ +#define AZ_IOT_HUB_PROFILE_INIT \ + { \ + ._internal_size = sizeof(az_iot_hub_profile), \ + .connection_profile = AZ_IOT_CONNECTION_PROFILE_MQTT_V3, .connection_profile_raw = NULL, \ + .connection_profile_raw_truncated = false, \ } /* How the client retries a failed connection. Four numbers describe every diff --git a/c/samples/common/sample_cert_provider.c b/c/samples/common/sample_cert_provider.c index a91c6b61a..c9c0feeda 100644 --- a/c/samples/common/sample_cert_provider.c +++ b/c/samples/common/sample_cert_provider.c @@ -249,7 +249,8 @@ static az_iot_result provider_store( size_t len = (size_t)az_span_size(cert); if (len == 0) { - continue; + rc = AZ_IOT_ERR_INVALID_ARG; /* every issued entry must be a certificate */ + break; } if (fputs(PEM_CERT_BEGIN, f) < 0 || fwrite(az_span_ptr(cert), 1, len, f) != len || fputs(PEM_CERT_END, f) < 0) diff --git a/c/tests/unit/certificate_provider_managed_test.c b/c/tests/unit/certificate_provider_managed_test.c index 74444112b..ba2d54191 100644 --- a/c/tests/unit/certificate_provider_managed_test.c +++ b/c/tests/unit/certificate_provider_managed_test.c @@ -768,6 +768,47 @@ static void managed_a_chain_followed_by_other_text_is_refused(void** state) remove_test_files(); } +/* A persisted chain larger than the provider reads back is not used. A valid + * chain padded with trailing whitespace is otherwise accepted. */ +static void managed_an_oversized_persisted_chain_is_not_used(void** state) +{ + (void)state; + remove_test_files(); + az_iot_certificate_provider_managed_options opts = test_options(); + az_iot_certificate_provider_managed prov; + assert_int_equal(AZ_IOT_OK, az_iot_certificate_provider_managed_init(&prov, &opts)); + char* good = make_cert_base64((EVP_PKEY*)prov.operational_key, 1); + az_span chain[1] = { az_span_create_from_str(good) }; + az_iot_issued_certificate issued = { .certificates = chain, .count = 1 }; + assert_int_equal(AZ_IOT_OK, prov.base.vtable->store_issued_certificate(&prov.base, &issued)); + az_iot_certificate_provider_managed_deinit(&prov); + + /* Control: a little trailing whitespace is fine. */ + FILE* fp = fopen(OP_CERT, "ab"); + assert_non_null(fp); + assert_true(fputs("\n\n", fp) >= 0); + assert_int_equal(0, fclose(fp)); + az_iot_certificate_provider_managed check; + assert_int_equal(AZ_IOT_OK, az_iot_certificate_provider_managed_init(&check, &opts)); + assert_true(check.has_operational); + az_iot_certificate_provider_managed_deinit(&check); + + fp = fopen(OP_CERT, "ab"); + assert_non_null(fp); + for (int i = 0; i < 64 * 1024; ++i) + { + assert_true(fputc('\n', fp) != EOF); + } + assert_int_equal(0, fclose(fp)); + az_iot_certificate_provider_managed prov2; + assert_int_equal(AZ_IOT_OK, az_iot_certificate_provider_managed_init(&prov2, &opts)); + assert_false(prov2.has_operational); + az_iot_certificate_provider_managed_deinit(&prov2); + + free(good); + remove_test_files(); +} + /* A chain on disk that no longer matches the key (the key was replaced) is not * served as the operational identity after a restart. */ static void managed_a_persisted_chain_for_another_key_is_not_used(void** state) @@ -919,6 +960,24 @@ static void managed_written_files_are_owner_only(void** state) remove_test_files(); } +/* init() loads an existing key without rewriting it, so its mode is kept. */ +static void managed_init_keeps_an_existing_key_file_mode(void** state) +{ + (void)state; + remove_test_files(); + az_iot_certificate_provider_managed_options opts = test_options(); + az_iot_certificate_provider_managed prov; + assert_int_equal(AZ_IOT_OK, az_iot_certificate_provider_managed_init(&prov, &opts)); + az_iot_certificate_provider_managed_deinit(&prov); + + assert_int_equal(0, chmod(OP_KEY, 0640)); + az_iot_certificate_provider_managed prov2; + assert_int_equal(AZ_IOT_OK, az_iot_certificate_provider_managed_init(&prov2, &opts)); + assert_int_equal(0640, file_mode(OP_KEY) & 0777); + az_iot_certificate_provider_managed_deinit(&prov2); + remove_test_files(); +} + /* A link at either destination is replaced, never written through. */ static void managed_writes_replace_a_link_instead_of_following_it(void** state) { @@ -998,11 +1057,13 @@ int main(void) cmocka_unit_test(managed_store_refuses_a_chain_with_an_empty_entry), cmocka_unit_test(managed_a_persisted_chain_with_a_truncated_entry_is_not_used), cmocka_unit_test(managed_a_chain_followed_by_other_text_is_refused), + cmocka_unit_test(managed_an_oversized_persisted_chain_is_not_used), #if defined(_WIN32) cmocka_unit_test(managed_written_files_have_an_owner_only_dacl), #endif #if !defined(_WIN32) cmocka_unit_test(managed_written_files_are_owner_only), + cmocka_unit_test(managed_init_keeps_an_existing_key_file_mode), cmocka_unit_test(managed_writes_replace_a_link_instead_of_following_it), #endif cmocka_unit_test(the_sign_hook_is_not_offered_by_this_provider), From 6ab76658cbdbba5274de667f35edc77f3dbc0614 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Tue, 29 Sep 2026 04:53:30 +0000 Subject: [PATCH 14/17] [C] Managed provider: refuse storing a chain over 64 KiB A chain that init() would not read back is refused at store, so a successful store is always usable after a restart. --- .../az_iot_certificate_provider_managed.c | 6 +-- .../az_iot_certificate_provider_managed.h | 5 ++- c/docs/test-coverage.md | 13 ++++--- .../unit/certificate_provider_managed_test.c | 37 +++++++++++++++++++ 4 files changed, 50 insertions(+), 11 deletions(-) diff --git a/c/adapters/cert_openssl/az_iot_certificate_provider_managed.c b/c/adapters/cert_openssl/az_iot_certificate_provider_managed.c index 83279052e..25c9e1076 100644 --- a/c/adapters/cert_openssl/az_iot_certificate_provider_managed.c +++ b/c/adapters/cert_openssl/az_iot_certificate_provider_managed.c @@ -68,8 +68,8 @@ /* Encoded length (excluding NUL) of base64 over `binary_len` bytes. */ #define BASE64_ENCODED_LEN(binary_len) ((((binary_len) + 2) / 3) * 4) -/* Largest chain file init() reads back. An issued chain is a few certificates - * of a few KB each; anything larger is refused rather than buffered. */ +/* Largest chain file stored or read back. An issued chain is a few + * certificates of a few KB each; anything larger is refused. */ #define MANAGED_MAX_CHAIN_BYTES (64u * 1024u) #ifdef _WIN32 @@ -709,7 +709,7 @@ static az_iot_result managed_store( char* data = NULL; long data_len = BIO_get_mem_data(mem, &data); size_t parsed = 0; - if (data_len <= 0 + if (data_len <= 0 || (size_t)data_len > MANAGED_MAX_CHAIN_BYTES || !chain_matches_key(data, (size_t)data_len, (EVP_PKEY*)m->operational_key, &parsed) || parsed != written) { diff --git a/c/adapters/cert_openssl/az_iot_certificate_provider_managed.h b/c/adapters/cert_openssl/az_iot_certificate_provider_managed.h index 84505a72c..b9801f33b 100644 --- a/c/adapters/cert_openssl/az_iot_certificate_provider_managed.h +++ b/c/adapters/cert_openssl/az_iot_certificate_provider_managed.h @@ -13,8 +13,9 @@ * - persists the DPS/Hub-issued operational certificate chain to disk * (store_issued_certificate) and serves it back on subsequent loads and * process restarts. A store is refused unless every certificate in the - * chain parses and the leaf certifies the operational key, and replaces the - * file in one step, so a failed store keeps the previous certificate. + * chain parses, the leaf certifies the operational key and the PEM chain + * is at most 64 KiB; it replaces the file in one step, so a failed store + * keeps the previous certificate. * * Files it writes (key and chain) are created readable only by the current user * (0600 on POSIX, owner-only DACL on Windows), under a unique temporary name diff --git a/c/docs/test-coverage.md b/c/docs/test-coverage.md index d20726ef4..ee212a290 100644 --- a/c/docs/test-coverage.md +++ b/c/docs/test-coverage.md @@ -852,18 +852,19 @@ runtime renewal against the connected hub over `$iothub/credentials/...` per | | A stored chain that is not a certificate is refused | The store validates the chain before replacing the file; nothing is written and no operational identity is claimed. | unit | Done | [managed_a_stored_chain_that_is_not_a_certificate_is_refused](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L508) | | | A failed store keeps the previous certificate | All-or-nothing, in-process and after a restart, so a failed renewal cannot break the next connect. | unit | Done | [managed_a_failed_store_keeps_the_previous_certificate](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L538) | | | A chain for another key is refused | The leaf must certify the operational key; the previous chain is kept. | unit | Done | [managed_store_refuses_a_chain_for_another_key](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L576) | -| | A persisted chain for another key is not used | After the key is replaced, the stored chain no longer certifies it. | unit | Done | [managed_a_persisted_chain_for_another_key_is_not_used](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L814) | +| | A persisted chain for another key is not used | After the key is replaced, the stored chain no longer certifies it. | unit | Done | [managed_a_persisted_chain_for_another_key_is_not_used](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L850) | | | A chain with a bad trailing entry is refused | Every entry must parse; the previous chain is kept. | unit | Done | [managed_store_refuses_a_chain_with_a_bad_trailing_entry](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L611) | | | A chain with an empty entry is refused | — | unit | Done | [managed_store_refuses_a_chain_with_an_empty_entry](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L644) | | | A persisted chain with a truncated entry is not used | — | unit | Done | [managed_a_persisted_chain_with_a_truncated_entry_is_not_used](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L675) | | | A chain followed by other text is refused | In an issued entry or appended to the file. | unit | Done | [managed_a_chain_followed_by_other_text_is_refused](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L723) | | | An oversized persisted chain is not used | Over 64 KiB is refused rather than buffered. | unit | Done | [managed_an_oversized_persisted_chain_is_not_used](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L773) | -| | Written files are owner-only | Key and chain are exactly 0600 whatever the umask, including 0777, and reload. POSIX. | unit | Done | [managed_written_files_are_owner_only](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L933) | -| | Init keeps an existing key file's mode | The key is loaded, not rewritten. POSIX. | unit | Done | [managed_init_keeps_an_existing_key_file_mode](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L964) | -| | Written files have an owner-only DACL | Protected DACL: Owner Rights and SYSTEM only; reloads. Windows only. | unit | Done | [managed_written_files_have_an_owner_only_dacl](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L895) | -| | A link at a destination is replaced, not followed | Key and chain. POSIX only. | unit | Done | [managed_writes_replace_a_link_instead_of_following_it](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L982) | +| | Store refuses an oversized chain | A chain over 64 KiB is refused; the previous one is kept. | unit | Done | [managed_store_refuses_an_oversized_chain](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L814) | +| | Written files are owner-only | Key and chain are exactly 0600 whatever the umask, including 0777, and reload. POSIX. | unit | Done | [managed_written_files_are_owner_only](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L969) | +| | Init keeps an existing key file's mode | The key is loaded, not rewritten. POSIX. | unit | Done | [managed_init_keeps_an_existing_key_file_mode](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L1000) | +| | Written files have an owner-only DACL | Protected DACL: Owner Rights and SYSTEM only; reloads. Windows only. | unit | Done | [managed_written_files_have_an_owner_only_dacl](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L931) | +| | A link at a destination is replaced, not followed | Key and chain. POSIX only. | unit | Done | [managed_writes_replace_a_link_instead_of_following_it](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L1018) | | | Store overwrites a previously issued chain | Renewal replaces rather than appends. | unit | Done | [managed_store_overwrites_a_previously_issued_chain](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L477) | -| | The sign hook is not offered by either bundled provider | Neither provider sets `sign`, so the connect path must keep checking it for NULL. Pinned so that adding an implementation is a deliberate act. | unit | Done | [the_sign_hook_is_not_offered_by_this_provider](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L1024) | +| | The sign hook is not offered by either bundled provider | Neither provider sets `sign`, so the connect path must keep checking it for NULL. Pinned so that adding an implementation is a deliberate act. | unit | Done | [the_sign_hook_is_not_offered_by_this_provider](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L1060) | | | An operational load with no stored chain returns not found | The connect path uses this code to decide it must still enroll. Belongs to the managed provider: the PEM loader ignores the role. | unit | Done | [managed_operational_load_without_a_stored_chain_is_not_found](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L276) | | | Load rejects null arguments | — | unit | Done | [managed_load_rejects_null_arguments](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L255) | | | Release leaves the material usable | The paths are borrowed from the provider struct, so freeing them here would dangle what the connection is still using. | unit | Done | [managed_release_leaves_the_material_usable](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L296) | diff --git a/c/tests/unit/certificate_provider_managed_test.c b/c/tests/unit/certificate_provider_managed_test.c index ba2d54191..55a231b4e 100644 --- a/c/tests/unit/certificate_provider_managed_test.c +++ b/c/tests/unit/certificate_provider_managed_test.c @@ -809,6 +809,42 @@ static void managed_an_oversized_persisted_chain_is_not_used(void** state) remove_test_files(); } +/* A chain larger than init() reads back is refused at store, keeping the + * previous one, so a successful store is always usable after a restart. */ +static void managed_store_refuses_an_oversized_chain(void** state) +{ + (void)state; + remove_test_files(); + az_iot_certificate_provider_managed_options opts = test_options(); + az_iot_certificate_provider_managed prov; + assert_int_equal(AZ_IOT_OK, az_iot_certificate_provider_managed_init(&prov, &opts)); + char* good = make_cert_base64((EVP_PKEY*)prov.operational_key, 1); + az_span one[1] = { az_span_create_from_str(good) }; + az_iot_issued_certificate issued = { .certificates = one, .count = 1 }; + assert_int_equal(AZ_IOT_OK, prov.base.vtable->store_issued_certificate(&prov.base, &issued)); + + /* Every entry is a valid certificate; only the total size is over. */ + size_t count = (64u * 1024u) / strlen(good) + 1u; + az_span* many = malloc(count * sizeof(az_span)); + assert_non_null(many); + for (size_t i = 0; i < count; ++i) + { + many[i] = one[0]; + } + az_iot_issued_certificate big = { .certificates = many, .count = count }; + assert_int_equal( + AZ_IOT_ERR_INVALID_ARG, prov.base.vtable->store_issued_certificate(&prov.base, &big)); + char* on_disk = read_chain_base64(OP_CERT); + assert_string_equal(on_disk, good); + assert_true(prov.has_operational); + az_iot_certificate_provider_managed_deinit(&prov); + + free(on_disk); + free(many); + free(good); + remove_test_files(); +} + /* A chain on disk that no longer matches the key (the key was replaced) is not * served as the operational identity after a restart. */ static void managed_a_persisted_chain_for_another_key_is_not_used(void** state) @@ -1058,6 +1094,7 @@ int main(void) cmocka_unit_test(managed_a_persisted_chain_with_a_truncated_entry_is_not_used), cmocka_unit_test(managed_a_chain_followed_by_other_text_is_refused), cmocka_unit_test(managed_an_oversized_persisted_chain_is_not_used), + cmocka_unit_test(managed_store_refuses_an_oversized_chain), #if defined(_WIN32) cmocka_unit_test(managed_written_files_have_an_owner_only_dacl), #endif From 1109bb6189768390558191f7758f991aaed6933c Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Tue, 29 Sep 2026 05:06:54 +0000 Subject: [PATCH 15/17] [C] Split the persisted-chain size test in two One test checks trailing whitespace is accepted; the other checks a chain over 64 KiB is not used. The combined test did not build with MSVC. --- c/docs/test-coverage.md | 17 +++---- .../unit/certificate_provider_managed_test.c | 45 +++++++++++++------ 2 files changed, 41 insertions(+), 21 deletions(-) diff --git a/c/docs/test-coverage.md b/c/docs/test-coverage.md index ee212a290..ee5400e18 100644 --- a/c/docs/test-coverage.md +++ b/c/docs/test-coverage.md @@ -852,19 +852,20 @@ runtime renewal against the connected hub over `$iothub/credentials/...` per | | A stored chain that is not a certificate is refused | The store validates the chain before replacing the file; nothing is written and no operational identity is claimed. | unit | Done | [managed_a_stored_chain_that_is_not_a_certificate_is_refused](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L508) | | | A failed store keeps the previous certificate | All-or-nothing, in-process and after a restart, so a failed renewal cannot break the next connect. | unit | Done | [managed_a_failed_store_keeps_the_previous_certificate](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L538) | | | A chain for another key is refused | The leaf must certify the operational key; the previous chain is kept. | unit | Done | [managed_store_refuses_a_chain_for_another_key](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L576) | -| | A persisted chain for another key is not used | After the key is replaced, the stored chain no longer certifies it. | unit | Done | [managed_a_persisted_chain_for_another_key_is_not_used](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L850) | +| | A persisted chain for another key is not used | After the key is replaced, the stored chain no longer certifies it. | unit | Done | [managed_a_persisted_chain_for_another_key_is_not_used](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L868) | | | A chain with a bad trailing entry is refused | Every entry must parse; the previous chain is kept. | unit | Done | [managed_store_refuses_a_chain_with_a_bad_trailing_entry](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L611) | | | A chain with an empty entry is refused | — | unit | Done | [managed_store_refuses_a_chain_with_an_empty_entry](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L644) | | | A persisted chain with a truncated entry is not used | — | unit | Done | [managed_a_persisted_chain_with_a_truncated_entry_is_not_used](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L675) | | | A chain followed by other text is refused | In an issued entry or appended to the file. | unit | Done | [managed_a_chain_followed_by_other_text_is_refused](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L723) | -| | An oversized persisted chain is not used | Over 64 KiB is refused rather than buffered. | unit | Done | [managed_an_oversized_persisted_chain_is_not_used](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L773) | -| | Store refuses an oversized chain | A chain over 64 KiB is refused; the previous one is kept. | unit | Done | [managed_store_refuses_an_oversized_chain](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L814) | -| | Written files are owner-only | Key and chain are exactly 0600 whatever the umask, including 0777, and reload. POSIX. | unit | Done | [managed_written_files_are_owner_only](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L969) | -| | Init keeps an existing key file's mode | The key is loaded, not rewritten. POSIX. | unit | Done | [managed_init_keeps_an_existing_key_file_mode](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L1000) | -| | Written files have an owner-only DACL | Protected DACL: Owner Rights and SYSTEM only; reloads. Windows only. | unit | Done | [managed_written_files_have_an_owner_only_dacl](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L931) | -| | A link at a destination is replaced, not followed | Key and chain. POSIX only. | unit | Done | [managed_writes_replace_a_link_instead_of_following_it](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L1018) | +| | A persisted chain with trailing whitespace is used | Control for the size bound. | unit | Done | [managed_a_persisted_chain_with_trailing_whitespace_is_used](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L772) | +| | An oversized persisted chain is not used | Over 64 KiB is refused rather than buffered. | unit | Done | [managed_an_oversized_persisted_chain_is_not_used](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L799) | +| | Store refuses an oversized chain | A chain over 64 KiB is refused; the previous one is kept. | unit | Done | [managed_store_refuses_an_oversized_chain](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L832) | +| | Written files are owner-only | Key and chain are exactly 0600 whatever the umask, including 0777, and reload. POSIX. | unit | Done | [managed_written_files_are_owner_only](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L987) | +| | Init keeps an existing key file's mode | The key is loaded, not rewritten. POSIX. | unit | Done | [managed_init_keeps_an_existing_key_file_mode](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L1018) | +| | Written files have an owner-only DACL | Protected DACL: Owner Rights and SYSTEM only; reloads. Windows only. | unit | Done | [managed_written_files_have_an_owner_only_dacl](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L949) | +| | A link at a destination is replaced, not followed | Key and chain. POSIX only. | unit | Done | [managed_writes_replace_a_link_instead_of_following_it](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L1036) | | | Store overwrites a previously issued chain | Renewal replaces rather than appends. | unit | Done | [managed_store_overwrites_a_previously_issued_chain](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L477) | -| | The sign hook is not offered by either bundled provider | Neither provider sets `sign`, so the connect path must keep checking it for NULL. Pinned so that adding an implementation is a deliberate act. | unit | Done | [the_sign_hook_is_not_offered_by_this_provider](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L1060) | +| | The sign hook is not offered by either bundled provider | Neither provider sets `sign`, so the connect path must keep checking it for NULL. Pinned so that adding an implementation is a deliberate act. | unit | Done | [the_sign_hook_is_not_offered_by_this_provider](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L1078) | | | An operational load with no stored chain returns not found | The connect path uses this code to decide it must still enroll. Belongs to the managed provider: the PEM loader ignores the role. | unit | Done | [managed_operational_load_without_a_stored_chain_is_not_found](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L276) | | | Load rejects null arguments | — | unit | Done | [managed_load_rejects_null_arguments](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L255) | | | Release leaves the material usable | The paths are borrowed from the provider struct, so freeing them here would dangle what the connection is still using. | unit | Done | [managed_release_leaves_the_material_usable](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L296) | diff --git a/c/tests/unit/certificate_provider_managed_test.c b/c/tests/unit/certificate_provider_managed_test.c index 55a231b4e..dceb16f1c 100644 --- a/c/tests/unit/certificate_provider_managed_test.c +++ b/c/tests/unit/certificate_provider_managed_test.c @@ -768,9 +768,8 @@ static void managed_a_chain_followed_by_other_text_is_refused(void** state) remove_test_files(); } -/* A persisted chain larger than the provider reads back is not used. A valid - * chain padded with trailing whitespace is otherwise accepted. */ -static void managed_an_oversized_persisted_chain_is_not_used(void** state) +/* A persisted chain padded with a little trailing whitespace is still used. */ +static void managed_a_persisted_chain_with_trailing_whitespace_is_used(void** state) { (void)state; remove_test_files(); @@ -783,22 +782,41 @@ static void managed_an_oversized_persisted_chain_is_not_used(void** state) assert_int_equal(AZ_IOT_OK, prov.base.vtable->store_issued_certificate(&prov.base, &issued)); az_iot_certificate_provider_managed_deinit(&prov); - /* Control: a little trailing whitespace is fine. */ FILE* fp = fopen(OP_CERT, "ab"); assert_non_null(fp); assert_true(fputs("\n\n", fp) >= 0); assert_int_equal(0, fclose(fp)); - az_iot_certificate_provider_managed check; - assert_int_equal(AZ_IOT_OK, az_iot_certificate_provider_managed_init(&check, &opts)); - assert_true(check.has_operational); - az_iot_certificate_provider_managed_deinit(&check); + az_iot_certificate_provider_managed prov2; + assert_int_equal(AZ_IOT_OK, az_iot_certificate_provider_managed_init(&prov2, &opts)); + assert_true(prov2.has_operational); + az_iot_certificate_provider_managed_deinit(&prov2); - fp = fopen(OP_CERT, "ab"); + free(good); + remove_test_files(); +} + +/* A persisted chain larger than the provider reads back is not used. */ +static void managed_an_oversized_persisted_chain_is_not_used(void** state) +{ + (void)state; + remove_test_files(); + az_iot_certificate_provider_managed_options opts = test_options(); + az_iot_certificate_provider_managed prov; + assert_int_equal(AZ_IOT_OK, az_iot_certificate_provider_managed_init(&prov, &opts)); + char* good = make_cert_base64((EVP_PKEY*)prov.operational_key, 1); + az_span chain[1] = { az_span_create_from_str(good) }; + az_iot_issued_certificate issued = { .certificates = chain, .count = 1 }; + assert_int_equal(AZ_IOT_OK, prov.base.vtable->store_issued_certificate(&prov.base, &issued)); + az_iot_certificate_provider_managed_deinit(&prov); + + FILE* fp = fopen(OP_CERT, "ab"); assert_non_null(fp); - for (int i = 0; i < 64 * 1024; ++i) - { - assert_true(fputc('\n', fp) != EOF); - } + const size_t pad_len = 64u * 1024u; + char* pad = malloc(pad_len); + assert_non_null(pad); + memset(pad, '\n', pad_len); + assert_true(fwrite(pad, 1, pad_len, fp) == pad_len); + free(pad); assert_int_equal(0, fclose(fp)); az_iot_certificate_provider_managed prov2; assert_int_equal(AZ_IOT_OK, az_iot_certificate_provider_managed_init(&prov2, &opts)); @@ -1093,6 +1111,7 @@ int main(void) cmocka_unit_test(managed_store_refuses_a_chain_with_an_empty_entry), cmocka_unit_test(managed_a_persisted_chain_with_a_truncated_entry_is_not_used), cmocka_unit_test(managed_a_chain_followed_by_other_text_is_refused), + cmocka_unit_test(managed_a_persisted_chain_with_trailing_whitespace_is_used), cmocka_unit_test(managed_an_oversized_persisted_chain_is_not_used), cmocka_unit_test(managed_store_refuses_an_oversized_chain), #if defined(_WIN32) From e4b2ba07e093a27f6e7d3df5ae65d335148585e0 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Tue, 29 Sep 2026 05:38:23 +0000 Subject: [PATCH 16/17] [C] Managed provider: size each issued entry before decoding it An entry that cannot fit in what is left of the 64 KiB chain limit is refused before it is decoded or buffered. --- .../az_iot_certificate_provider_managed.c | 10 +++++++ c/docs/test-coverage.md | 13 +++++----- .../unit/certificate_provider_managed_test.c | 26 +++++++++++++++++++ 3 files changed, 43 insertions(+), 6 deletions(-) diff --git a/c/adapters/cert_openssl/az_iot_certificate_provider_managed.c b/c/adapters/cert_openssl/az_iot_certificate_provider_managed.c index 25c9e1076..28f291eeb 100644 --- a/c/adapters/cert_openssl/az_iot_certificate_provider_managed.c +++ b/c/adapters/cert_openssl/az_iot_certificate_provider_managed.c @@ -696,6 +696,16 @@ static az_iot_result managed_store( rc = AZ_IOT_ERR_INVALID_ARG; /* every issued entry must be a certificate */ break; } + /* Refuse before decoding: an entry adds at least its decoded size (3 bytes + * per 4 of base64, less up to 2 of padding), so one that cannot fit in + * what is left of MANAGED_MAX_CHAIN_BYTES is never allocated. */ + size_t used = BIO_ctrl_pending(mem); + if (used > MANAGED_MAX_CHAIN_BYTES + || ((size_t)len / 4u) * 3u > MANAGED_MAX_CHAIN_BYTES - used + 2u) + { + rc = AZ_IOT_ERR_INVALID_ARG; + break; + } if (!write_issued_cert(mem, az_span_ptr(cert), len)) { rc = AZ_IOT_ERR_INTERNAL; diff --git a/c/docs/test-coverage.md b/c/docs/test-coverage.md index ee5400e18..785c11ea3 100644 --- a/c/docs/test-coverage.md +++ b/c/docs/test-coverage.md @@ -852,7 +852,7 @@ runtime renewal against the connected hub over `$iothub/credentials/...` per | | A stored chain that is not a certificate is refused | The store validates the chain before replacing the file; nothing is written and no operational identity is claimed. | unit | Done | [managed_a_stored_chain_that_is_not_a_certificate_is_refused](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L508) | | | A failed store keeps the previous certificate | All-or-nothing, in-process and after a restart, so a failed renewal cannot break the next connect. | unit | Done | [managed_a_failed_store_keeps_the_previous_certificate](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L538) | | | A chain for another key is refused | The leaf must certify the operational key; the previous chain is kept. | unit | Done | [managed_store_refuses_a_chain_for_another_key](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L576) | -| | A persisted chain for another key is not used | After the key is replaced, the stored chain no longer certifies it. | unit | Done | [managed_a_persisted_chain_for_another_key_is_not_used](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L868) | +| | A persisted chain for another key is not used | After the key is replaced, the stored chain no longer certifies it. | unit | Done | [managed_a_persisted_chain_for_another_key_is_not_used](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L893) | | | A chain with a bad trailing entry is refused | Every entry must parse; the previous chain is kept. | unit | Done | [managed_store_refuses_a_chain_with_a_bad_trailing_entry](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L611) | | | A chain with an empty entry is refused | — | unit | Done | [managed_store_refuses_a_chain_with_an_empty_entry](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L644) | | | A persisted chain with a truncated entry is not used | — | unit | Done | [managed_a_persisted_chain_with_a_truncated_entry_is_not_used](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L675) | @@ -860,12 +860,13 @@ runtime renewal against the connected hub over `$iothub/credentials/...` per | | A persisted chain with trailing whitespace is used | Control for the size bound. | unit | Done | [managed_a_persisted_chain_with_trailing_whitespace_is_used](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L772) | | | An oversized persisted chain is not used | Over 64 KiB is refused rather than buffered. | unit | Done | [managed_an_oversized_persisted_chain_is_not_used](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L799) | | | Store refuses an oversized chain | A chain over 64 KiB is refused; the previous one is kept. | unit | Done | [managed_store_refuses_an_oversized_chain](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L832) | -| | Written files are owner-only | Key and chain are exactly 0600 whatever the umask, including 0777, and reload. POSIX. | unit | Done | [managed_written_files_are_owner_only](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L987) | -| | Init keeps an existing key file's mode | The key is loaded, not rewritten. POSIX. | unit | Done | [managed_init_keeps_an_existing_key_file_mode](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L1018) | -| | Written files have an owner-only DACL | Protected DACL: Owner Rights and SYSTEM only; reloads. Windows only. | unit | Done | [managed_written_files_have_an_owner_only_dacl](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L949) | -| | A link at a destination is replaced, not followed | Key and chain. POSIX only. | unit | Done | [managed_writes_replace_a_link_instead_of_following_it](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L1036) | +| | Store refuses an oversized entry before decoding | Sized by encoded length against what is left of 64 KiB. | unit | Done | [managed_store_refuses_an_oversized_entry_before_decoding](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L867) | +| | Written files are owner-only | Key and chain are exactly 0600 whatever the umask, including 0777, and reload. POSIX. | unit | Done | [managed_written_files_are_owner_only](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L1012) | +| | Init keeps an existing key file's mode | The key is loaded, not rewritten. POSIX. | unit | Done | [managed_init_keeps_an_existing_key_file_mode](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L1043) | +| | Written files have an owner-only DACL | Protected DACL: Owner Rights and SYSTEM only; reloads. Windows only. | unit | Done | [managed_written_files_have_an_owner_only_dacl](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L974) | +| | A link at a destination is replaced, not followed | Key and chain. POSIX only. | unit | Done | [managed_writes_replace_a_link_instead_of_following_it](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L1061) | | | Store overwrites a previously issued chain | Renewal replaces rather than appends. | unit | Done | [managed_store_overwrites_a_previously_issued_chain](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L477) | -| | The sign hook is not offered by either bundled provider | Neither provider sets `sign`, so the connect path must keep checking it for NULL. Pinned so that adding an implementation is a deliberate act. | unit | Done | [the_sign_hook_is_not_offered_by_this_provider](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L1078) | +| | The sign hook is not offered by either bundled provider | Neither provider sets `sign`, so the connect path must keep checking it for NULL. Pinned so that adding an implementation is a deliberate act. | unit | Done | [the_sign_hook_is_not_offered_by_this_provider](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L1103) | | | An operational load with no stored chain returns not found | The connect path uses this code to decide it must still enroll. Belongs to the managed provider: the PEM loader ignores the role. | unit | Done | [managed_operational_load_without_a_stored_chain_is_not_found](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L276) | | | Load rejects null arguments | — | unit | Done | [managed_load_rejects_null_arguments](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L255) | | | Release leaves the material usable | The paths are borrowed from the provider struct, so freeing them here would dangle what the connection is still using. | unit | Done | [managed_release_leaves_the_material_usable](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L296) | diff --git a/c/tests/unit/certificate_provider_managed_test.c b/c/tests/unit/certificate_provider_managed_test.c index dceb16f1c..cfc7cd5de 100644 --- a/c/tests/unit/certificate_provider_managed_test.c +++ b/c/tests/unit/certificate_provider_managed_test.c @@ -863,6 +863,31 @@ static void managed_store_refuses_an_oversized_chain(void** state) remove_test_files(); } +/* An entry too large for the chain limit is refused before it is decoded. */ +static void managed_store_refuses_an_oversized_entry_before_decoding(void** state) +{ + (void)state; + remove_test_files(); + az_iot_certificate_provider_managed_options opts = test_options(); + az_iot_certificate_provider_managed prov; + assert_int_equal(AZ_IOT_OK, az_iot_certificate_provider_managed_init(&prov, &opts)); + + /* Not base64: if decoded, the store fails with AZ_IOT_ERR_INTERNAL instead. */ + const size_t big_len = 128u * 1024u; + uint8_t* big = malloc(big_len); + assert_non_null(big); + memset(big, '!', big_len); + az_span entry[1] = { az_span_create(big, (int32_t)big_len) }; + az_iot_issued_certificate issued = { .certificates = entry, .count = 1 }; + assert_int_equal( + AZ_IOT_ERR_INVALID_ARG, prov.base.vtable->store_issued_certificate(&prov.base, &issued)); + assert_false(prov.has_operational); + + az_iot_certificate_provider_managed_deinit(&prov); + free(big); + remove_test_files(); +} + /* A chain on disk that no longer matches the key (the key was replaced) is not * served as the operational identity after a restart. */ static void managed_a_persisted_chain_for_another_key_is_not_used(void** state) @@ -1114,6 +1139,7 @@ int main(void) cmocka_unit_test(managed_a_persisted_chain_with_trailing_whitespace_is_used), cmocka_unit_test(managed_an_oversized_persisted_chain_is_not_used), cmocka_unit_test(managed_store_refuses_an_oversized_chain), + cmocka_unit_test(managed_store_refuses_an_oversized_entry_before_decoding), #if defined(_WIN32) cmocka_unit_test(managed_written_files_have_an_owner_only_dacl), #endif From 9b208e2d72535018ce7cb0411fafcf547a951956 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Tue, 29 Sep 2026 05:58:39 +0000 Subject: [PATCH 17/17] [C] Store an issued chain only from a complete JSON response A truncated, unbalanced or trailing-text credentials response (hub) or ASSIGNED payload (DPS) no longer reaches the provider: the hub renewal fails with AZ_IOT_ERR_PROTOCOL and the DPS registration fails. The chain array must also close. --- c/docs/test-coverage.md | 72 +++++++++++++------------ c/src/core/cert_util.c | 26 ++++++++- c/src/core/connection_client.c | 9 +++- c/src/core/internal/cert_util.h | 8 ++- c/tests/unit/connection_client_test.c | 78 +++++++++++++++++++++++++++ 5 files changed, 154 insertions(+), 39 deletions(-) diff --git a/c/docs/test-coverage.md b/c/docs/test-coverage.md index 785c11ea3..097d3ab95 100644 --- a/c/docs/test-coverage.md +++ b/c/docs/test-coverage.md @@ -192,13 +192,13 @@ Covers `az_iot_connection_client` lifecycle, CONNACK handling, reconnection, the | | MQTTv5 hub suback failure faults | `dev/#` SUBACK error → FAULTED. | unit | Done | [hub_mqtt_v5_suback_failure_faults](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L825) | | | MQTTv5 hub birth ack timeout faults | No birth-ack within the timeout, no reconnect policy → FAULTED/TIMEOUT. | unit | Done | [hub_mqtt_v5_birth_ack_timeout_faults](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1095) | | | MQTTv3 skips birth handshake | v3.1.1 contrast: CONNACK → CONNECTED with no presence traffic. | unit | Done | [mqtt_v3_connect_skips_birth_handshake](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L888) | -| | MQTTv5 hub birth publish failure faults | Adapter rejects the birth PUBLISH. | unit | Done | [hub_mqtt_v5_birth_publish_failure_faults](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2233) | -| | MQTTv5 hub birth timeout retries with new nonce | A stale ack must not satisfy the retry. | unit | Done | [hub_mqtt_v5_birth_timeout_retries_with_a_new_nonce](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2312) | -| | MQTTv5 hub without v5 factory not supported | No silent downgrade to an MQTTv3 session. | unit | Done | [hub_mqtt_v5_without_v5_factory_is_not_supported](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2212) | -| | MQTTv5 hub birth ack before suback ignored | No nonce exists yet to match against. | unit | Done | [hub_mqtt_v5_birth_ack_before_suback_is_ignored](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2258) | -| DPS provisioning | DPS CSR flow stores issued chain | DPS register with CSR → ASSIGNED → operational chain stored. | unit | Done | [dps_csr_flow_sends_csr_and_stores_issued_chain](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1535) | +| | MQTTv5 hub birth publish failure faults | Adapter rejects the birth PUBLISH. | unit | Done | [hub_mqtt_v5_birth_publish_failure_faults](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2309) | +| | MQTTv5 hub birth timeout retries with new nonce | A stale ack must not satisfy the retry. | unit | Done | [hub_mqtt_v5_birth_timeout_retries_with_a_new_nonce](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2388) | +| | MQTTv5 hub without v5 factory not supported | No silent downgrade to an MQTTv3 session. | unit | Done | [hub_mqtt_v5_without_v5_factory_is_not_supported](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2288) | +| | MQTTv5 hub birth ack before suback ignored | No nonce exists yet to match against. | unit | Done | [hub_mqtt_v5_birth_ack_before_suback_is_ignored](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2334) | +| DPS provisioning | DPS CSR flow stores issued chain | DPS register with CSR → ASSIGNED → operational chain stored. | unit | Done | [dps_csr_flow_sends_csr_and_stores_issued_chain](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1542) | | | Open rejects operational cert without CSR provider | `request_operational_certificate` set but provider has no `get_csr`. | unit | Done | [open_rejects_operational_cert_without_csr_provider](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1317) | -| | Open rejects operational cert without payload buffer | `request_operational_certificate` set but `csr_payload_buffer` empty. | unit | Done | [open_rejects_operational_cert_without_payload_buffer](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1721) | +| | Open rejects operational cert without payload buffer | `request_operational_certificate` set but `csr_payload_buffer` empty. | unit | Done | [open_rejects_operational_cert_without_payload_buffer](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1754) | | | DPS connects to the global endpoint | Default `global.azure-devices-provisioning.net`. | unit | Done | [dps_connects_to_the_global_endpoint_by_default](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_dps_test.c#L309) | | | DPS honors a custom global endpoint | `dps.global_endpoint` override. | unit | Done | [dps_honors_a_custom_global_endpoint](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_dps_test.c#L319) | | | DPS uses v3 1 1 even for an MQTTv5 hub | `connection_profile = AZ_IOT_CONNECTION_PROFILE_MQTT_V5`; the v5 factory is left untouched. | unit | Done | [dps_uses_v3_1_1_even_when_the_hub_is_mqtt_v5](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_dps_test.c#L386) | @@ -874,39 +874,41 @@ runtime renewal against the connected hub over `$iothub/credentials/...` per | | Store rejects an issuance with no certificates | Accepting an empty chain would flip the operational flag with nothing on disk. | unit | Done | [managed_store_rejects_null_arguments](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L335) | | | Deinit tolerates null | — | unit | Done | [managed_deinit_tolerates_null](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L363) | | | Deinit through the vtable destroys the provider | What a generic owner of an `az_iot_certificate_provider` calls. | unit | Done | [managed_deinit_through_the_vtable_destroys_the_provider](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/certificate_provider_managed_test.c#L371) | -| Issuance via DPS | The CSR rides the DPS registration body | — | unit | Done | [dps_csr_flow_sends_csr_and_stores_issued_chain](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1535) | -| | The issued chain is handed to the provider | — | unit | Done | [dps_csr_flow_sends_csr_and_stores_issued_chain](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1535) | +| Issuance via DPS | The CSR rides the DPS registration body | — | unit | Done | [dps_csr_flow_sends_csr_and_stores_issued_chain](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1542) | +| | The issued chain is handed to the provider | — | unit | Done | [dps_csr_flow_sends_csr_and_stores_issued_chain](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1542) | | | Open is refused without a CSR-capable provider | — | unit | Done | [open_rejects_operational_cert_without_csr_provider](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1317) | -| | Open is refused without a payload buffer | — | unit | Done | [open_rejects_operational_cert_without_payload_buffer](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1721) | +| | Open is refused without a payload buffer | — | unit | Done | [open_rejects_operational_cert_without_payload_buffer](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1754) | | | The DPS username carries the CSR API version | The CSR flow needs a newer api-version than the azure-sdk-for-c default. | unit | Pending | *connection_client_test.c* | -| | The operational cert callback fires with the issued chain and store result | Fires after the store. | unit | Done | [dps_csr_flow_sends_csr_and_stores_issued_chain](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1535) | -| | A failed store still delivers the chain, with the error | The registration then fails. | unit | Done | [dps_store_failure_still_delivers_the_chain_with_the_error](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1568) | -| | A callback that unregisters itself still handles the chain | The chain is stored and the registration proceeds. | unit | Done | [dps_a_callback_that_unregisters_itself_still_handles_the_chain](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1639) | -| | Open is refused without release_csr or store_issued_certificate | DPS enrollment needs the provider to store the chain. | unit | Done | [open_rejects_operational_cert_without_release_or_store](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1594) | -| Hub-side renewal | A renewal publishes the issue-certificate topic | `$iothub/credentials/POST/issueCertificate/?$rid=`. | unit | Done | [send_csr_two_phase_delivers_issued_chain](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1742) | -| | A 202 reports accepted without completing | — | unit | Done | [send_csr_two_phase_delivers_issued_chain](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1742) | -| | A 200 delivers the issued chain | — | unit | Done | [send_csr_two_phase_delivers_issued_chain](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1742) | -| | An error status reports the service code | 409 conflict with an `errorCode` body. | unit | Done | [send_csr_error_reports_service_code](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1800) | -| | Only one renewal is in flight at a time | A second `send_csr()` is refused while one is open. | unit | Done | [send_csr_cancel_frees_slot](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2182) | -| | Cancel frees the slot for a new renewal | — | unit | Done | [send_csr_cancel_frees_slot](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2182) | -| | The renewal subscribes the credentials response filter | `$iothub/credentials/res/#`. | unit | Done | [send_csr_subscribes_the_credentials_response_filter](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2371) | -| | The request body carries the device id and CSR | — | unit | Done | [send_csr_two_phase_delivers_issued_chain](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1742) | -| | The optional replace field is emitted only when supplied | `"replace":"*"`. | unit | Done | [send_csr_emits_the_replace_field_only_when_supplied](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2411) | -| | A response for a different rid is ignored | — | unit | Done | [a_credentials_response_for_a_different_rid_is_ignored](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2442) | -| | A 400 reports the service code | Invalid request payload. | unit | Done | [a_400_reports_the_service_code](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2465) | -| | A 412 reports the service code | No matching request to replace. | unit | Done | [a_412_reports_the_service_code](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2491) | -| | A 429 surfaces the retry-after hint | The response body carries `retryAfter`. | unit | Done | [a_429_surfaces_the_retry_after_hint](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2517) | -| | A malformed issued chain reports a protocol error | 200 whose body has no `certificates` array. | unit | Done | [a_200_without_a_certificates_array_reports_a_failure](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2545) | -| | A provider-made CSR's chain is stored and used on the next connect | `send_csr(NULL)`: CSR from `get_csr()` (subject = device id); stored before the callback; session kept. | unit | Done | [a_provider_csr_chain_is_stored_and_used_on_the_next_connect](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2064) | -| | A failed store keeps the session and the previous credential | Reported in `store_status`. | unit | Done | [a_failed_store_keeps_the_session_and_the_previous_credential](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2091) | -| | An application CSR's chain is not stored | The application owns the key and the chain. | unit | Done | [an_application_csr_chain_is_not_stored](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2111) | -| | Send CSR without a CSR needs a provider that can renew | No provider, no `get_csr`, or no `store_issued_certificate`: refused before publishing; slot stays free. | unit | Done | [send_csr_without_a_csr_needs_a_provider_that_can_renew](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2146) | -| | A failed get_csr fails send CSR | Nothing published. | unit | Done | [a_failed_get_csr_fails_send_csr](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2166) | +| | The operational cert callback fires with the issued chain and store result | Fires after the store. | unit | Done | [dps_csr_flow_sends_csr_and_stores_issued_chain](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1542) | +| | A truncated ASSIGNED payload is not stored | The registration fails. | unit | Done | [dps_a_truncated_assignment_is_not_stored](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1575) | +| | A failed store still delivers the chain, with the error | The registration then fails. | unit | Done | [dps_store_failure_still_delivers_the_chain_with_the_error](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1601) | +| | A callback that unregisters itself still handles the chain | The chain is stored and the registration proceeds. | unit | Done | [dps_a_callback_that_unregisters_itself_still_handles_the_chain](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1672) | +| | Open is refused without release_csr or store_issued_certificate | DPS enrollment needs the provider to store the chain. | unit | Done | [open_rejects_operational_cert_without_release_or_store](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1627) | +| Hub-side renewal | A renewal publishes the issue-certificate topic | `$iothub/credentials/POST/issueCertificate/?$rid=`. | unit | Done | [send_csr_two_phase_delivers_issued_chain](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1775) | +| | A 202 reports accepted without completing | — | unit | Done | [send_csr_two_phase_delivers_issued_chain](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1775) | +| | A 200 delivers the issued chain | — | unit | Done | [send_csr_two_phase_delivers_issued_chain](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1775) | +| | An error status reports the service code | 409 conflict with an `errorCode` body. | unit | Done | [send_csr_error_reports_service_code](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1833) | +| | Only one renewal is in flight at a time | A second `send_csr()` is refused while one is open. | unit | Done | [send_csr_cancel_frees_slot](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2258) | +| | Cancel frees the slot for a new renewal | — | unit | Done | [send_csr_cancel_frees_slot](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2258) | +| | The renewal subscribes the credentials response filter | `$iothub/credentials/res/#`. | unit | Done | [send_csr_subscribes_the_credentials_response_filter](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2447) | +| | The request body carries the device id and CSR | — | unit | Done | [send_csr_two_phase_delivers_issued_chain](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L1775) | +| | The optional replace field is emitted only when supplied | `"replace":"*"`. | unit | Done | [send_csr_emits_the_replace_field_only_when_supplied](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2487) | +| | A response for a different rid is ignored | — | unit | Done | [a_credentials_response_for_a_different_rid_is_ignored](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2518) | +| | A 400 reports the service code | Invalid request payload. | unit | Done | [a_400_reports_the_service_code](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2541) | +| | A 412 reports the service code | No matching request to replace. | unit | Done | [a_412_reports_the_service_code](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2567) | +| | A 429 surfaces the retry-after hint | The response body carries `retryAfter`. | unit | Done | [a_429_surfaces_the_retry_after_hint](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2593) | +| | A malformed issued chain reports a protocol error | 200 whose body has no `certificates` array. | unit | Done | [a_200_without_a_certificates_array_reports_a_failure](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2621) | +| | A provider-made CSR's chain is stored and used on the next connect | `send_csr(NULL)`: CSR from `get_csr()` (subject = device id); stored before the callback; session kept. | unit | Done | [a_provider_csr_chain_is_stored_and_used_on_the_next_connect](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2104) | +| | A malformed credentials response is not stored | Truncated or trailing JSON is `AZ_IOT_CSR_FAILED` (`AZ_IOT_ERR_PROTOCOL`); the session is kept. | unit | Done | [a_malformed_credentials_response_is_not_stored](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2131) | +| | A failed store keeps the session and the previous credential | Reported in `store_status`. | unit | Done | [a_failed_store_keeps_the_session_and_the_previous_credential](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2167) | +| | An application CSR's chain is not stored | The application owns the key and the chain. | unit | Done | [an_application_csr_chain_is_not_stored](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2187) | +| | Send CSR without a CSR needs a provider that can renew | No provider, no `get_csr`, or no `store_issued_certificate`: refused before publishing; slot stays free. | unit | Done | [send_csr_without_a_csr_needs_a_provider_that_can_renew](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2222) | +| | A failed get_csr fails send CSR | Nothing published. | unit | Done | [a_failed_get_csr_fails_send_csr](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2242) | | | A renewal with no response times out | `CSR_OP_TIMEOUT_MS` elapses. | unit | Pending | *connection_client_test.c* | | | A 202 extends the timeout deadline | A slow-but-alive signer is not abandoned. | unit | Pending | *connection_client_test.c* | -| | Send CSR while disconnected is refused | — | unit | Done | [send_csr_while_disconnected_is_refused](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2571) | +| | Send CSR while disconnected is refused | — | unit | Done | [send_csr_while_disconnected_is_refused](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2647) | | | A renewal open across a reconnect is completed or failed | Same family as C-3 / D-3. | unit | Pending | *connection_client_test.c* | -| | A CSR larger than the service cap is refused | `CSR_MAX_BASE64` is 8 KB. | unit | Done | [a_csr_larger_than_the_service_cap_is_refused](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2591) | +| | A CSR larger than the service cap is refused | `CSR_MAX_BASE64` is 8 KB. | unit | Done | [a_csr_larger_than_the_service_cap_is_refused](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2667) | | | Send CSR rejects null arguments | — | unit | Pending | *connection_client_test.c* | | | Send CSR on an MQTTv5 session is not supported | The MQTTv5 renewal topic space is not defined yet, so the call is refused rather than publishing to an MQTTv3 topic from a v5 session. | unit | Pending | *connection_client_test.c* | | | Send CSR without a payload buffer reports not enough space | — | unit | Pending | *connection_client_test.c* | @@ -1035,7 +1037,7 @@ own, so they are unit-tested only. | | Stderr sink honours its minimum level | Below the threshold is dropped before the sink is reached. | unit | Done | [stderr_sink_honours_its_minimum_level](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/diagnostics_test.c#L308) | | | Gen request id fills a bounded string | NUL-terminated inside the buffer, printable throughout. | unit | Done | [gen_request_id_fills_a_bounded_string](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/diagnostics_test.c#L322) | | | Gen request id advances the rng | Two ids from one state differ, or a renewal retry would reuse an id the service already answered. | unit | Done | [gen_request_id_advances_the_rng](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/diagnostics_test.c#L346) | -| Certificate helpers | A generated request id is fresh each time | Repeating one would make the service treat a new renewal as a duplicate. | unit | Done | [cert_util_generates_a_distinct_request_id](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2621) | +| Certificate helpers | A generated request id is fresh each time | Repeating one would make the service treat a new renewal as a duplicate. | unit | Done | [cert_util_generates_a_distinct_request_id](https://github.com/Azure/azure-iot-sdk/blob/main/c/tests/unit/connection_client_test.c#L2697) | ## Test infrastructure diff --git a/c/src/core/cert_util.c b/c/src/core/cert_util.c index 6974686b7..8ac1546b9 100644 --- a/c/src/core/cert_util.c +++ b/c/src/core/cert_util.c @@ -48,6 +48,21 @@ void az_iot_cert_util_gen_request_id(uint64_t* rng_state, char* buf, size_t cap) (void)result; } +bool az_iot_cert_util_json_is_complete(az_span payload) +{ + az_json_reader jr; + if (az_result_failed(az_json_reader_init(&jr, payload, NULL))) + { + return false; + } + az_result r; + do + { + r = az_json_reader_next_token(&jr); + } while (az_result_succeeded(r)); + return r == AZ_ERROR_JSON_READER_DONE; +} + az_iot_result az_iot_cert_util_collect_chain_spans( az_json_reader* jr, az_span* certs, @@ -55,9 +70,16 @@ az_iot_result az_iot_cert_util_collect_chain_spans( size_t* out_count) { size_t count = 0; - while (az_result_succeeded(az_json_reader_next_token(jr)) - && jr->token.kind != AZ_JSON_TOKEN_END_ARRAY) + for (;;) { + if (az_result_failed(az_json_reader_next_token(jr))) + { + return AZ_IOT_ERR_PROTOCOL; /* the array never closed */ + } + if (jr->token.kind == AZ_JSON_TOKEN_END_ARRAY) + { + break; + } if (jr->token.kind != AZ_JSON_TOKEN_STRING) { return AZ_IOT_ERR_PROTOCOL; diff --git a/c/src/core/connection_client.c b/c/src/core/connection_client.c index 4d2f949e9..8276c30ad 100644 --- a/c/src/core/connection_client.c +++ b/c/src/core/connection_client.c @@ -1536,6 +1536,11 @@ static az_iot_result dps_read_connection_profile(az_iot_connection_client* c, az * does not surface this field, so we walk the raw payload with az_json. */ static az_iot_result dps_store_issued_cert(az_iot_connection_client* c, az_span payload) { + /* A truncated or malformed payload never reaches the provider. */ + if (!az_iot_cert_util_json_is_complete(payload)) + { + return AZ_IOT_ERR_PROTOCOL; + } az_json_reader jr; az_iot_result entered = dps_enter_registration_state(&jr, payload); if (entered != AZ_IOT_OK) @@ -5466,7 +5471,9 @@ static void on_csr_response(void* user_ctx, const az_iot_mqtt_message* msg) size_t count = 0; az_iot_result rc = AZ_IOT_ERR_PROTOCOL; az_json_reader jr; - if (az_result_succeeded(az_json_reader_init(&jr, payload, NULL)) + /* A truncated or malformed response is never treated as ISSUED. */ + if (az_iot_cert_util_json_is_complete(payload) + && az_result_succeeded(az_json_reader_init(&jr, payload, NULL)) && az_result_succeeded(az_json_reader_next_token(&jr)) && jr.token.kind == AZ_JSON_TOKEN_BEGIN_OBJECT) { diff --git a/c/src/core/internal/cert_util.h b/c/src/core/internal/cert_util.h index addf781f2..9d03584c8 100644 --- a/c/src/core/internal/cert_util.h +++ b/c/src/core/internal/cert_util.h @@ -23,10 +23,16 @@ bool az_iot_cert_util_is_base64(const char* s, size_t* out_len); * seeded by the monotonic clock and advanced through *rng_state. */ void az_iot_cert_util_gen_request_id(uint64_t* rng_state, char* buf, size_t cap); +/** + * @brief True when @p payload is one complete JSON value: nothing truncated, + * unbalanced or trailing. + */ +bool az_iot_cert_util_json_is_complete(az_span payload); + /* Collect the base64 DER cert strings of a JSON array (the reader positioned so * the next tokens are the array's string elements) as ZERO-COPY spans into the * payload. Fills certs[0..*out_count) and returns AZ_IOT_OK, or a *_PROTOCOL / - * *_NOT_ENOUGH_SPACE error. */ + * *_NOT_ENOUGH_SPACE error. An array that does not close is *_PROTOCOL. */ az_iot_result az_iot_cert_util_collect_chain_spans( az_json_reader* jr, az_span* certs, diff --git a/c/tests/unit/connection_client_test.c b/c/tests/unit/connection_client_test.c index 685b5969a..07fdf5659 100644 --- a/c/tests/unit/connection_client_test.c +++ b/c/tests/unit/connection_client_test.c @@ -1446,6 +1446,9 @@ static void on_dps_op_cert( /* Init a DPS client with request_operational_certificate over @p prov, drive it * through registration and deliver an ASSIGNED response carrying a two-cert * chain. The client is left initialized; the caller deinits it. */ +/* ASSIGNED response drive_dps_csr_assignment() injects; NULL = a valid one. */ +static const char* g_dps_resp = NULL; + static void drive_dps_csr_assignment( az_iot_connection_client* client, uint8_t* csr_buf, @@ -1516,6 +1519,10 @@ static void drive_dps_csr_assignment( "\"registrationState\":{\"registrationId\":\"ut-device\"," "\"assignedHub\":\"myhub.azure-devices.net\",\"deviceId\":\"ut-device\"," "\"issuedCertificateChain\":[\"TEEF\",\"SU5U\"]}}"; + if (g_dps_resp) + { + resp = g_dps_resp; + } assert_true(az_iot_mock_mqtt_client_inject_message( dps, "$dps/registrations/res/200/?$rid=1", @@ -1563,6 +1570,32 @@ static void dps_csr_flow_sends_csr_and_stores_issued_chain(void** state) az_iot_connection_client_deinit(&client); } +/* A truncated ASSIGNED payload never reaches the provider; the registration + * fails. */ +static void dps_a_truncated_assignment_is_not_stored(void** state) +{ + (void)state; + + fake_csr_provider prov = { 0 }; + prov.base.vtable = &k_fake_csr_vtable; + az_iot_connection_client client; + uint8_t csr_buf[AZ_IOT_CSR_PAYLOAD_BUFFER_MIN]; + g_dps_resp = "{\"operationId\":\"op1\",\"status\":\"assigned\"," + "\"registrationState\":{\"registrationId\":\"ut-device\"," + "\"assignedHub\":\"myhub.azure-devices.net\",\"deviceId\":\"ut-device\"," + "\"issuedCertificateChain\":[\"TEEF\",\"SU5U\"]"; + drive_dps_csr_assignment(&client, csr_buf, sizeof(csr_buf), &prov, true); + g_dps_resp = NULL; + + assert_int_equal(prov.store_calls, 0); + assert_int_equal(g_dps_op_cert_count, 0); + assert_int_equal( + az_iot_connection_client_get_state(&client, AZ_IOT_CONN_SCOPE_HUB), AZ_IOT_CONN_STATE_IDLE); + assert_null(az_iot_connection_client_get_iothub_address(&client)); + + az_iot_connection_client_deinit(&client); +} + /* A failed store must not hide the chain from the app: the callback still fires, * carrying the provider's error, and the registration fails as before. */ static void dps_store_failure_still_delivers_the_chain_with_the_error(void** state) @@ -1940,6 +1973,8 @@ typedef struct renew_cb_ctx size_t issued_count; az_iot_result store_status; int store_calls_at_cb; + int failed; + az_iot_result failed_status; renew_provider* prov; } renew_cb_ctx; @@ -1954,6 +1989,11 @@ static void on_renew_evt(const az_iot_csr_event* evt, void* uc) t->store_status = evt->store_status; t->store_calls_at_cb = t->prov ? t->prov->store_calls : -1; } + else if (evt->kind == AZ_IOT_CSR_FAILED) + { + t->failed++; + t->failed_status = evt->status; + } } typedef struct renew_fixture @@ -2086,6 +2126,42 @@ static void a_provider_csr_chain_is_stored_and_used_on_the_next_connect(void** s az_iot_connection_client_deinit(&rf.client); } +/* A truncated or malformed 200 is a failed renewal: nothing reaches the + * provider and the session is kept. */ +static void a_malformed_credentials_response_is_not_stored(void** state) +{ + (void)state; + static const char* const k_bad[] = { + "{\"certificates\":[\"TEEF\"", + "{\"certificates\":[\"TEEF\"]", + "{\"certificates\":[\"TEEF\"]}x", + }; + renew_fixture rf; + renew_provider prov = { .base.vtable = &k_renew_vtable }; + renew_cb_ctx tc = { .prov = &prov }; + renew_connect(&rf, &prov); + + for (size_t i = 0; i < sizeof(k_bad) / sizeof(k_bad[0]); ++i) + { + assert_int_equal( + az_iot_connection_client_send_csr(&rf.client, NULL, "req-bad", NULL, on_renew_evt, &tc), + AZ_IOT_OK); + assert_true(az_iot_mock_mqtt_client_inject_message( + rf.hub, + "$iothub/credentials/res/200/?$rid=req-bad", + (const uint8_t*)k_bad[i], + strlen(k_bad[i]), + AZ_IOT_MQTT_QOS_1)); + (void)az_iot_connection_client_do_work(&rf.client, 0); + assert_int_equal(tc.failed, (int)i + 1); + assert_int_equal(tc.failed_status, AZ_IOT_ERR_PROTOCOL); + } + assert_int_equal(tc.issued, 0); + assert_int_equal(prov.store_calls, 0); + assert_session_untouched(&rf); + az_iot_connection_client_deinit(&rf.client); +} + /* Staying connected comes first: a failed store is reported, the session is * kept, and the next connect still uses the previous credential. */ static void a_failed_store_keeps_the_session_and_the_previous_credential(void** state) @@ -2729,6 +2805,7 @@ int main(void) hub_mqtt_v5_birth_timeout_retries_with_a_new_nonce, setup_mqtt_v5_with_reconnect, teardown), cmocka_unit_test(open_rejects_operational_cert_without_csr_provider), cmocka_unit_test(dps_csr_flow_sends_csr_and_stores_issued_chain), + cmocka_unit_test(dps_a_truncated_assignment_is_not_stored), cmocka_unit_test(dps_store_failure_still_delivers_the_chain_with_the_error), cmocka_unit_test(dps_a_callback_that_unregisters_itself_still_handles_the_chain), cmocka_unit_test(open_rejects_operational_cert_without_release_or_store), @@ -2736,6 +2813,7 @@ int main(void) cmocka_unit_test_setup_teardown(send_csr_two_phase_delivers_issued_chain, setup, teardown), cmocka_unit_test_setup_teardown(send_csr_error_reports_service_code, setup, teardown), cmocka_unit_test(a_provider_csr_chain_is_stored_and_used_on_the_next_connect), + cmocka_unit_test(a_malformed_credentials_response_is_not_stored), cmocka_unit_test(a_failed_store_keeps_the_session_and_the_previous_credential), cmocka_unit_test(an_application_csr_chain_is_not_stored), cmocka_unit_test(send_csr_without_a_csr_needs_a_provider_that_can_renew),