SDK for TESAIoT Dev Kit
API reference & tutorials (ModusToolbox)
Loading...
Searching...
No Matches
D2 — Enrolment and Protected Update end to end

Learning goal

Button → IPC → worker → chip → broker → ingest → verdict, with the exact point at which the secure element — not the host — checks the platform's signature. Along the way, three facts the SDK's own headers can mislead you about:

The real firmware sequence

1. The button

/* ...context: button event callbacks - GFX task context ... */
static void enrol_btn_clicked_cb(lv_event_t *e) { (void)e; hsm_enrol_open(); }
static void protect_btn_clicked_cb(lv_event_t *e) { (void)e; hsm_protect_open(); }

hsm_enrol_open() / hsm_protect_open() are exported cm55_core functions; the overlay they open (hsm_provision_ui.c) is not shipped as source — it is in libbento_cm55.a. The header tells you the contract: IPC_CMD_HSM_PROVISION returns immediately and an lv_timer polls. The owning page must pair the open with a teardown in its destroy callback:

void page_hsm_destroy(void)
{
memset(&s_ctx, 0, sizeof(s_ctx));
s_cert_overlay = NULL;
s_bench_overlay = NULL;
/* The screen (and every PIN widget under it) is being deleted by the
* page manager — cancel the pending one-shot timer and drop all widget
* pointers so a late callback cannot touch freed LVGL objects. */
if (s_pin.timer) {
lv_timer_delete(s_pin.timer);
s_pin.timer = NULL;
}
s_pin.overlay = NULL;
s_pin.icon_lbl = NULL;
s_pin.title_lbl = NULL;
s_pin.msg_lbl = NULL;
for (int i = 0; i < PIN_LENGTH; i++) s_pin.dots[i] = NULL;
/* Any provisioning overlay and its poll timer go with the page. A timer
* that fires after these widgets are freed dereferences them. */

Last statement of destroy_cb, after the page's own timers are deleted — otherwise a provisioning poll timer fires after the widgets are freed.

2. The wire format

IPC_CMD_HSM_PROVISION is 0xBE (ipc_communication.h:184). Ops: HSM_PROV_OP_POLL 0, HSM_PROV_OP_CSR 1 (Enrol), HSM_PROV_OP_PU 2 (Protect), HSM_PROV_OP_FETCH_CSR 3, HSM_PROV_OP_UNLOCK 4. States IDLE/BUSY/DONE/FAILED; steps KEYGEN 1, CSR 2, PUBLISH 3, WAIT 4, INSTALL 5, VERIFY 6 (:253-271). And one event id:

TESAIOT_PU_CHIP_VERIFIED_MANIFEST is the moment the secure element itself checked the platform's signature against its trust anchor — the one step in the flow that a compromised host cannot fake, and the reason any of this is worth more than a plain write. (ipc_communication.h:273-277)

3. CM33_NS: latch OIDs, hand to a worker

handle_hsm_provision() rejects with HSM_PROV_REJECTED_BUSY if a run is in progress, defaults target_oid to 0xE0E1 and anchor_oid to 0xE0E8, and sets pending_op. prov_task polls it every 50 ms and calls prov_run(op).

4. The open/close envelope and the disarm

/* ...context: tail of prov_run(), after prov_run_locked() returns ... */
/* Disarm before letting go. Nothing is outstanding once this returns.
*
* trustm_reset_state() clears the correlation id, and the ingest treats a
* NULL id as "nobody asked for this" - which is the only thing standing
* between the board and a replay. The platform retains the last Protected
* Update bundle, so one is delivered on every single connect; while the id
* stayed armed for the whole boot, that retained bundle matched, and the
* board silently re-ran a Protected Update nobody had asked for. Measured
* on 2026-08-08: an Enrol immediately after a successful Protect connected,
* received the retained bundle, re-locked 0xE0E1 on top of the unlock the
* operator had just performed, and exhausted the C heap far enough that the
* publisher task could not be created - so the enrolment then failed with
* "could not publish", three layers away from the cause.
*
* The function existed and did exactly this. It had no caller anywhere in
* the tree: the reference project drives it from its own menu loop, and
* that call was not carried across when these operations became screens.
*
* It goes here, around every exit of prov_run_locked() including the early
* failures, because a run that timed out leaves an id armed just as surely
* as one that succeeded. */
if (trustm_reset_state != NULL) {
}
prov_close_held();

prov_open_held() — optiga_manager_init() then optiga_trust_open_application() under a touch hold (chapter D1) — then prov_run_locked(op), then trustm_reset_state() on every exit, then prov_close_held(). The reset is belt-and-braces: the ingest disarms itself (step 10), but a run that timed out leaves an id armed just as surely as one that succeeded, and this is the one point every run passes through.

5. Reach the platform before touching the chip

prov_run_locked disconnects and restarts MQTT, waits on mqtt_is_started() (not mqtt_is_connected()), pushes "Connecting to the platform" to the screen, tries two connects of 15 s each, then waits for data_received_event_group to exist — the subscriber task creates it, and its existence is the only proof available here that something is listening for the reply (ipc_hsm_handler.c:2023-2114). Failure text: "No answer from the platform after 30 seconds.".

6–8. Refuse a locked slot, make the key and CSR, publish

/* ...context: inside prov_run_locked() ... */
if (op != HSM_PROV_OP_PU) {
if (prov_manifest_anchor_held(s_prov.target_oid) != 0U) {
/* Say what is true. The manifest requirement is a metadata field,
* not a fuse: writing D0 back to E1 FC 07 clears it, which this
* firmware already does to key slots on every key generation, and
* which was measured on this board on 2026-08-08: D0 on 0xE0E1
* read 21 e0 e8 before the write and e1 fc 07 after. Calling it
* permanent would teach the operator something false about their
* own hardware. */
prov_say(HSM_PROV_STATE_FAILED, HSM_PROV_STEP_NONE,
"This slot takes signed manifests only. Use Protect, or "
"clear the requirement first. Nothing was changed.");
return;
}
if (publish_csr == NULL) {
prov_say(HSM_PROV_STATE_FAILED, HSM_PROV_STEP_NONE,
"CSR enrolment is not built into this firmware");
return;
}
if (!prov_make_csr_held(key_oid)) return;
}
char t[8], a[8];
(void)snprintf(t, sizeof(t), "%04X", s_prov.target_oid);
(void)snprintf(a, sizeof(a), "%04X", s_prov.anchor_oid);
/* Read the completion counter before publishing. Anything that finishes
* after this point is an answer to this request; anything that finished
* before it is not, no matter what a flag says. */
uint32_t events_before = g_optiga_ingest_events;
if (op == HSM_PROV_OP_PU) {
prov_say(HSM_PROV_STATE_FAILED, HSM_PROV_STEP_PUBLISH,
"Protected Update is not built into this firmware");
return;
}
prov_say(HSM_PROV_STATE_BUSY, HSM_PROV_STEP_PUBLISH,
"Asking the platform for a signed manifest");
events_before = g_optiga_ingest_events;
if (prov_publish_pu_held(t, a) != 0) {
prov_say(HSM_PROV_STATE_FAILED, HSM_PROV_STEP_PUBLISH,
"Could not publish the request - is the broker connected?");
return;
}
} else {
/* Plain enrolment publishes the CSR and the platform answers on
* commands/certificate, which the subscriber installs with an ordinary
* write. Once Protected Update has run against this object the chip
* refuses ordinary writes for good, so that path is simply gone - and
* saying so is more use than letting it fail inside the vendor library
* with a bare status code. */
prov_say(HSM_PROV_STATE_BUSY, HSM_PROV_STEP_PUBLISH,
"Sending the request to the platform");
events_before = g_optiga_ingest_events;
if (publish_csr((uint8_t *)s_prov.csr, strlen(s_prov.csr),
s_prov.target_oid, s_prov.anchor_oid, 1U) != 0) {
prov_say(HSM_PROV_STATE_FAILED, HSM_PROV_STEP_PUBLISH,
"Could not publish - is the broker connected?");
return;
}
}

Three things in that block:

  • Locked-slot refusal (Enrol only). If the target slot's metadata already demands a signed manifest, Enrol stops before generating anything: "This slot takes signed manifests only. Use Protect, or clear the requirement first. Nothing was changed." The comment is careful: the requirement is a metadata field, not a fuse — while the chip's life-cycle state permits it, Protect or Unlock can clear it.
  • Weak-symbol guards. if (publish_csr == NULL) and if (tesaiot_publish_protected_update == NULL) — chapter D3.
  • The completion counter snapshot events_before = g_optiga_ingest_events taken before publishing. Anything that finishes after this point is an answer to this request; anything before it is not, "no matter what a flag says".

Key generation and CSR run inside prov_make_csr_held() under the reason "Generating a key and signing the request"; the screen gets "Generating a key pair inside the secure element" then "Signing the request with the key that never leaves the chip" (ipc_hsm_handler.c:1807-1840). Subject is CN=<mqtt username>,O=TESAIoT.

Two publish branches:

  • Enrol → publish_csr(csr, len, target, anchor, 1). Inside the archive (tesaiot_optiga_trust_m.c:895-1058): clears g_protected_update_just_completed, generates a fresh correlation id from the TRNG, sets trustm_state = TRUSTM_STATE_PUBLISHING_CSR by direct assignment, wraps the CSR in {"device_id":…,"csr":…,"correlation_id":…}, and publishes to device/<id>/commands/csr directly, bypassing the publisher queue, at QoS 0. Then trustm_update_state(WAITING_FOR_MANIFEST, "csr_sent", …).
  • Protect → prov_publish_pu_held():
static int prov_publish_pu_held(const char *target, const char *anchor)
{
int rc;
optiga_manager_touch_hold_reason("Generating a key and signing the request");
rc = tesaiot_publish_protected_update(target, anchor, 1U, true);
return rc;
}

tesaiot_publish_protected_update(target, anchor, version, with_csr) — four arguments, OIDs as hex strings ("E0E1", "E0E8" via snprintf "%04X"). with_csr = true generates a key pair on-chip, which is why the wrapper holds touch. The archive records the requested OIDs (readable later through trustm_requested_target_oid() / trustm_requested_anchor_oid()), refuses if the chip already binds the target to a different anchor, reads the anti-rollback counter for current_version, and publishes to device/<id>/commands/request. The MicroPython binding is the second compiled caller — tesaiot_protected_update_py() at modtesaiot.c:730-768, marker [hsm_publish_pu_mpy_binding] (mtb-mpy zip only — this file is not in the mtb-only package). It defaults target/anchor to "E0E1"/"E0E8" and makes the same four-argument call:

int rc = tesaiot_publish_protected_update(target, anchor,
(uint32_t)vals[ARG_version].u_int,
vals[ARG_csr].u_bool);
return mp_obj_new_bool(rc == 0);
int tesaiot_publish_protected_update(const char *target_oid, const char *trust_anchor_oid, uint32_t payload_version, bool with_csr)
Ask the platform for a Protected Update (OIDs as hex strings); weak — NULL-check first.

After either publish: the CSR buffer is freed (1,600 bytes the next step needs), "Waiting for the platform" goes to the screen, and the worker polls g_optiga_ingest_events != events_before for 60 s — no touch hold across that wait.

9a. Return path A — certificate on commands/certificate

When the platform signs a CSR it publishes the certificate straight to device/<id>/commands/certificate. The suffix router (chapter C3) dispatches to the weak tesaiot_pu_ingest_certificate:

void tesaiot_pu_ingest_certificate(char *cert_payload, uint16_t cert_size)
{
bool installed = false;
optiga_manager_touch_hold_reason("Installing the device certificate");
bool answered = pu_ingest_certificate_held(cert_payload, cert_size, &installed);
/* Announce completion here, once, for every path on which the platform
* actually answered — including the ones that failed to install.
*
* Setting it only on the success path left a waiter to burn its full 60
* seconds and then report "the platform did not deliver a bundle", which
* blames the wrong subsystem for a write the chip refused. Setting it
* inside, before the pair check, put two tasks into that check at once.
* After the hold is released and after every chip transaction this function
* makes is the only place that is both complete and safe. */
if (installed) {
g_protected_update_just_completed = true;
}
if (answered) {
/* Same disarm as the bundle path, for the same reason: the platform
* retains this topic too, and an armed id turns every later connect
* into a certificate install nobody asked for. */
g_optiga_ingest_events++; /* stop the waiter either way */
}
}

Install under a touch hold; if installed, set g_protected_update_just_completed; if answered, trustm_reset_state() and bump g_optiga_ingest_events. That bump is what wakes the worker.

9b. Return path B — Protected Update bundle ingest

Router → tesaiot_pu_ingest_bundle() (tesaiot_pu_ingest.c, ~90 KB of shipped source, the linear goto pu_done ladder). The touch hold is taken at the top for the entire ingest (chapter D1), then:

/* ...context: inside the Protected Update bundle ingest ... */
// State update: Processing JSON bundle
#if 0 /* TRACE messages disabled */
printf("%s [TRACE-2] State updated\n", LABEL_SUBSCRIBER);
fflush(stdout);
#endif
if (!subscriber_q_data.data) {
#if TESAIOT_DEBUG_VERBOSE_ENABLED
printf("%s ERROR: NULL bundle data\n", LABEL_SUBSCRIBER);
#endif /* TESAIOT_DEBUG_VERBOSE_ENABLED */
g_protected_update_active = false; // Reset flag on early exit
goto pu_done;
}

trustm_update_state(PROCESSING_JSON_BUNDLE) — a setter. The compiled progression through this file is PROCESSING_JSON_BUNDLE → WRITING_TRUST_ANCHOR → VERIFYING_MANIFEST → PROTECTED_UPDATE_SUCCESS, with a *_FAILED transition and a detail string on every early exit. Nothing reads the variable to decide what to do next; the goto ladder does.

Then the replay defence:

/* ...context: inside the Protected Update bundle ingest, after JSON parse ... */
const char *expected_corr_id = trustm_current_correlation_id();
/* No outstanding request means nothing on this device asked for this. The
* platform retains the last bundle, so one is delivered on every connect —
* and until now every connect applied it. That is a replay: it rewrites the
* target, re-locks its access condition, and burns a step of the chip's
* anti-rollback counter, all without anyone asking. Observed three times on
* 2026-08-07, each one silently redoing the previous run. */
if (NULL == expected_corr_id) {
printf("%s Ignoring a Protected Update bundle nobody asked for. Retained "
"bundles arrive on every connect; call tesaiot.protected_update() "
"to arm one.\n", LABEL_SUBSCRIBER);
fflush(stdout);
g_protected_update_active = false;
vPortFree(json_copy);
goto pu_done;
}
if (expected_corr_id && parse_ctx.correlation_id) {
// Compare correlation_id (case-sensitive, exact match required)
bool match = (parse_ctx.correlation_id_len == strlen(expected_corr_id)) &&
(strncmp(parse_ctx.correlation_id, expected_corr_id, parse_ctx.correlation_id_len) == 0);

trustm_current_correlation_id() returning NULL means nothing on this device asked for this — discard the bundle. Not optional: the platform retains the last bundle and redelivers it on every connect; before this check every connect applied it — "a replay: it rewrites the target, re-locks its access condition, and burns a step of the chip's anti-rollback counter", observed three times on 2026-08-07. A non-NULL id is compared exactly, case-sensitively, with the bundle's correlation_id.

The anchor and target the bundle is for come from the request, through weak accessors with the documented defaults:

/* Which object this bundle is for — see the note on the definition. Weak so a
* build without the MQTT request path still links. */
extern uint16_t trustm_requested_target_oid(void) __attribute__((weak));
extern uint16_t trustm_requested_anchor_oid(void) __attribute__((weak));
/* The anchor the last request named. Everything below used to bake 0xE0E8 in,
* including the target's Change access condition — so asking for a different
* anchor was accepted, stored, and then quietly ignored, and the chip would
* verify the manifest against an object the platform had not signed for. That
* is 0x800F with no diagnostic, the same failure the target OID caused before
* it was made to follow the request. */
static uint16_t pu_anchor_oid(void)
{
: 0xE0E8U;
}
static uint16_t pu_target_oid(void)
{
return (trustm_requested_target_oid != NULL)
: 0xE0E1U; /* certificate slot — the platform's default target */
}

(The cautionary tale is in the comment: the anchor used to be baked in as 0xE0E8, so a request naming a different anchor was accepted and silently ignored, and the chip verified the manifest against an object the platform had not signed for — 0x800F with no diagnostic.)

Then the chip work: optiga_manager_acquire(); write the trust anchor's metadata (0xE8 0x01 0x11, Data Object Type = Trust Anchor); STEP 3 — base64-decode fragment_0..2 and concatenate; MUD metadata on the target; STEP 4 — optiga_util_protected_update_start(me, 1, manifest, len), which is where the chip verifies the manifest's signature against the trust anchor; on success:

if (NULL != tesaiot_pu_progress) {
tesaiot_pu_progress(TESAIOT_PU_CHIP_VERIFIED_MANIFEST);
}

(tesaiot_pu_ingest.c, immediately after the [4.1] Manifest verification OK line; consumed by ipc_hsm_handler.c:2258-2260 to drive the screen's INSTALL step.) Then fragments are applied with protected_update_final, the success flag is set, the event group bit is set, the ACK is published to device/<id>/telemetry/system, and finally:

/* ...context: the single pu_done exit of the bundle ingest ... */
pu_done:
/* Announce completion here, not at the end of the write.
*
* The increment used to sit beside the success flag, roughly ten seconds
* before this function releases the chip: the acknowledgement publish and the
* 0xE0E1 read-back are both still ahead of it. The waiting provisioning task
* woke on that increment and went straight into optiga_verify_cert_key_pair(),
* which then queued behind this task's own chip gate - and if that wait
* exceeded its ten second ceiling the pair check ran anyway, alongside this
* task, which is the 0x0102 collision the gate exists to prevent.
*
* After the release, and after the touch hold, is the only point at which this
* task is genuinely finished with the chip. It matches what the certificate
* path already does. */
if (pu_handled_for_us)
{
/* Disarm before announcing. The request has been answered, so nothing is
* outstanding, and trustm_current_correlation_id() must stop matching.
*
* The platform RETAINS the last bundle, so one is redelivered on every
* subsequent connect. While the id stayed armed for the rest of the boot
* each of those redeliveries matched and was executed: a Protected Update
* nobody asked for, rewriting the target, re-locking its access condition
* on top of an unlock the operator had just performed, and consuming
* enough of the C heap that the MQTT publisher task could not be created -
* which then surfaced as an unrelated "could not publish" three layers
* away. Measured 2026-08-08.
*
* Disarming here rather than in each caller is deliberate. The callers -
* the HSM Security screens, tesaiot.protected_update() from MicroPython,
* and the reference menu loop - each have to remember otherwise, and two
* of the three did not. This is the one point every one of them passes
* through, and it is the point at which the statement "a request is
* outstanding" stops being true. */
g_optiga_ingest_events++;
}

Release the chip and the touch hold first; then, only if this bundle was for us, trustm_reset_state() and bump the counter. The order matters: the increment used to sit ten seconds earlier, the waiting worker woke into optiga_verify_cert_key_pair() and queued behind this task's own gate — the 0x0102 collision the gate exists to prevent.

10. The verdict

ipc_hsm_handler.c:2234-2262: "Checking the certificate against the key in the chip" → optiga_verify_cert_key_pair(target, key) → one of "The device can prove it holds the key this certificate names" (DONE), "Installed, but the certificate does not belong to this chip's key" (FAILED), "Installed; the pair check could not run".

Step-by-step

Prerequisites: WiFi (C1/C2), a working server-TLS or mTLS connection (C3/C4), the board's device_id registered on the platform, and a build with ENABLE_OPTIGA_CLM=1 (the default — chapter D3).

Step 1 — Snapshot the chip before you start

On mtb-mpy: import optiga; optiga.read_metadata(0xE0E1). Note tag C0 (life-cycle state) and tag D0 (change access condition). Every board on the bench should read C0 = 01 (Creation). Never write tag C0.

What you should observe. The metadata TLV on the REPL. Chapter D1's discipline runs underneath the call.

Step 2 — Enrol

Home → HSM Security → Enrol Certificate.

What you should observe. Screen: Connecting to the platform → Generating a key pair inside the secure element → Signing the request with the key that never leaves the chip → Sending the request to the platform → Waiting for the platform → Checking the certificate against the key in the chip → The device can prove it holds the key this certificate names. Those seven sentences are pushed by CM33_NS (prov_say), and they are verified source strings. UART, live: the [MQTT] reconnect sequence from C3, then [CSR] Using DIRECT PUBLISH (bypassing publisher_task queue) and [DirectPub] Publishing u bytes to 's' (archived tesaiot_optiga_trust_m.c, no mute), then on the reply [Subscriber] Certificate from platform (d bytes) (subscriber_task.c:217, (printf) form — live).

Step 3 — Protect

Home → HSM Security → Protected Update.

What you should observe. Screen: Connecting to the platform → Asking the platform for a signed manifest → Waiting for the platform → the INSTALL step → the VERIFY step and its verdict. UART, live, in order:

[Subscriber] Protected Update bundle (%d bytes)
[PU-Ingest] Fragment count: %u
[PU-Ingest] OPTIGA acquired: OK
[PU-Ingest] STEP 3: Processing fragments...
[PU-Ingest] STEP 4: Executing OPTIGA Trust M Protected Update
[PU-Ingest] [4.1] Manifest verification OK (Trust Anchor signature valid)
[PU-Ingest] PROTECTED UPDATE COMPLETED SUCCESSFULLY!
[PU-Ingest] [ACK] Certificate ACK published successfully

(subscriber_task.c:205 via (printf); tesaiot_pu_ingest.c has no mute — LABEL_SUBSCRIBER is "[PU-Ingest]".) The [4.1] Manifest verification OK line is followed in the code by tesaiot_pu_progress(TESAIOT_PU_CHIP_VERIFIED_MANIFEST) — this is the one unfakeable event: the signature check happened inside the Trust M against its own trust anchor. Everything before it a compromised host could print.

Step 4 — Read the chip again

optiga.read_metadata(0xE0E1).

What you should observe. Tag D0 now demands a signed manifest (21 e0 e8-style value naming the anchor) where it was e1 fc 07 before; tag C0 unchanged at 01. If C0 moved, stop and report — nothing in this flow writes it, and optiga.write_metadata() refuses TLVs containing it.

Step 5 — Reconnect and watch the replay defence

Power-cycle, connect WiFi, tesaiot.connect() (or the page). The broker redelivers the retained bundle.

What you should observe. [Subscriber] Protected Update bundle (d bytes) then [PU-Ingest] Ignoring a Protected Update bundle nobody asked for. … — and no OPTIGA acquired, no STEP lines. Nothing was written. If instead you see the full STEP 3/STEP 4 sequence with no button pressed, the correlation id was armed when it should not have been.

Step 6 — Enrol into a locked slot

Enrol again, now that 0xE0E1 demands a manifest.

What you should observe. Screen: This slot takes signed manifests only. Use Protect, or clear the requirement first. Nothing was changed. — before any key generation. On a Creation-state board, HSM Security → Unlock (HSM_PROV_OP_UNLOCK) clears the requirement and Enrol works again.

Traps

Trap 1 — Believing the enum drives a switch.
trustm_update_state() assigns trustm_state and stamps a tick (tesaiot_optiga_trust_m.c:377-388). The sequencing is the goto pu_done ladder in tesaiot_pu_ingest.c and the linear prov_run_locked(). If you write switch (trustm_state) in your own code you are inventing a machine the firmware does not have.
Trap 2 — The four dead enumerators.
APPLYING_UPDATE, WAITING_FOR_CERTIFICATE, COMPLETE, APPLYING_FRAGMENTS are never written. A dashboard waiting for COMPLETE waits forever; the success state is PROTECTED_UPDATE_SUCCESS.
Trap 3 — Skipping the correlation-id check in a custom ingest.
Retained bundles arrive on every connect. NULL id → discard. Appendix X, item 12.
Trap 4 — Snapshotting g_optiga_ingest_events after publishing.
publish_csr and tesaiot_publish_protected_update arm the ingest — take the snapshot first, or a completion that raced the publish counts as your answer.
Trap 5 — Ending a run with trustm_update_state(IDLE, …).
Only trustm_reset_state() clears the correlation id. A run that ends without it leaves the board armed for the next retained bundle.
Trap 6 — Calling the publishers without the NULL check.
Both are weak. Chapter D3.
Trap 7 — Citing tesaiot_protected_update_workflow.c or tesaiot_csr_workflow.c.
Both ship as source; neither is compiled by proj_cm33_ns. The first calls tesaiot_publish_protected_update() with three arguments against a four-argument prototype and would not compile; the second has TODO stubs for certificate validation and ends in an intentional infinite wait for a hardware reset. prov_run_locked() is the shipping path.
Trap 8 — The screen's step labels.
strings(1) on libbento_cm55.a shows Key pair made in the chip, Request signed by that key, Sent to the platform, Waiting for the platform, The chip verified that signature, Checked against the key, Certificate written, Key possession proved. These are strings(1) evidence: they exist in the archive, but the mapping from each label to a specific HSM_PROV_STEP_* value is not proven from source (hsm_provision_ui.c does not ship). Treat the seven CM33 prov_say sentences as the verified observables; treat the label chain as unconfirmed until the source is available.
Trap 9 — Writing tag C0.
The life-cycle state moves one way. Every flow here leaves it alone.

Variant

Variant
mtb-mpy and mtb-only

libbento_hsm.a links on both variants; the ingest, helpers and handler compile on both. mtb-mpy adds tesaiot.protected_update() and the optiga module for Steps 1 and 4; on mtb-only, read the metadata through optiga_util_read_metadata() from your own task, under chapter D1's discipline. The Enrol/Protect buttons and the CM55 overlay are identical.