Factory Flashing (SEC.3)

SkillProductivity

Use when working on the SEC.3 factory-flashing pipeline, provisioning per-device keys into an STM32 at manufacture (app/services/factory_flashing/, lib/tasks/factory.rake): the one-pass silicon UID→DID derivation [FW.54], the wrong-board guard, UID collision → quarantine, the six Flash key blocks and their magics, word→big-endian byte order [FW.30], the chain-hashed audit trail, and the 2-Person supervisor rule. ⚠️ Гілка B = Гілка A + SE identity: its runtime keys go to Protected Flash through the same SWD writes (since 2026-09-27, before that a Гілка-B unit got no Flash KEYL and bricked), but the SE step itself is EMIT-ONLY, textual legacy ATECC calls, no real transport, so treat any SE05x request as blocked, not supported. Routes to 03_06 §5 + 03_01 §7, does not restate. Examples: \"why did factory:flash raise CollisionError\", \"add a key slot\", \"the backend DID does not match the silicon\", \"provision a new board\", \"what does the flashing session do, in order\", \"why is Гілка B not usable\".

Use Factory Flashing (SEC.3) in Claude, ChatGPT or Ahel Desktop

Free. Sign in, add Factory Flashing (SEC.3) and connect your AI. About a minute.

Also: Claude Code · Cursor · Codex

Then ask your AI: use the Factory Flashing (SEC.3) skill

Details

Instructions available. Your AI can read the instructions. Execution depends on the setup they require.

Add Ahel to your AI once: Claude, ChatGPT, Cursor, Claude Code or Codex. Then ask it to use this.

Factory Flashing (SEC.3)Start free

What this skill tells your AI

The instructions your AI receives, as published by alexey-lukin/silken_net in .claude/skills/factory-flashing/SKILL.md and read by Ahel’s review.

Burns per-device keys into a Soldier/Queen at manufacture, freezes the IWDG and sets RDP — L0 or L1 only; L2 is burned outside the pipeline (step 6). Navigation aid — the SSOT is the code + docs below; this skill points, it does not restate.

SSOT Documents — Read These First

DocumentWhat it covers
docs/03_06_Factory_Flashing_and_Key_Provisioning.mdTHE factory home (split from 03_05 §3.4): pipeline Гілки A/B, HKDF per-device derivation, K_ota, §5 ops-security (2-Person Rule, master-key delivery variants, SEC.3 status)
docs/03_05_Hardware_Symmetric_Crypto_and_Security.mdCrypto modes, SE050 (SEC.6), key rotation, RDP (SEC.2)
docs/00_07_Action_Plan_Tracker.mdSEC.3 (bench SWD + Bitwarden live — residuals), SEC.2 (RDP Level 2)

Pipeline (entry → exit)

Entry: lib/tasks/factory.rake → [FW.54] one-pass UID→DID at factory:flash: for a Tree the device_uid arg = 24-hex silicon UID (NOT a DID) → SilkenNet::DidDerivation.wire_did_from_uid_hex → TreeResolver.resolve! (create with CLUSTER_ID+TREE_FAMILY_ID env / re-flash / bind legacy / DID-collision → CollisionError = quarantine, 03_01 §7); the session's device_uid = derived wire-DID. Bare SNET- DID accepted only for a Tree that already has trees.silicon_uid_hex; Gateway path unchanged.

Then FactoryFlashing::Session.run (after supervisor-approved). One ActiveRecord::Base.transaction:

  1. Preflight — session may_start? + device exists + master key fetched into @master_key (fail fast before the tx; the result is NOT discarded — SEC.3 DI).
  2. Wrong-board guard [FW.54] — CommandBuilder.preflight_commands (one invocation: -c + -r32 0x1FFF7590 12 + -r32 0x0803E000 4, the first key-page word) runs FIRST; a passport-less board (a Queen) that already holds keys is erased only with REFLASH_ACK=<device_uid> (guard_unverified_reflash! — declared intent, like the bench script's typed «RDP2»); live mode parses stdout via UidReadout and compares the board's UID to trees.silicon_uid_hex before any derivation or -w32 — mismatch/unparseable → WrongBoardError (not even a HardwareKey row materializes). dry-run or passport-less device → skip. ⚠️ The guard checks ONE line, and every line reconnects: on a station with several ST-LINK probes each line takes probe index 0 afresh, so pass STLINK_SN=<probe serial> — CommandBuilder.connect puts sn= into every -c, the passport read included (alphanumeric only: the line reaches a shell through Open3.capture3). Nothing detects a multi-probe station without it — the --list output was never seen on silicon.
  3. Master key — MasterKeySource (Env or Bitwarden adapter); WeakKeyDetector refuses a weak key. The fetched key threads as master_key: param into every derivation below (runtime callers of the same services use the ENV fallback instead).
  4. HardwareKey — HardwareKeyService.provision(device, master_key:) (the SINGLE HKDF source — same derivation the firmware runs; never derive keys elsewhere).
  5. ATECC (Гілка B + Tree only) — SecureElementProvisioner emits the I²C ATCA write-zone transcript.
  6. Commands — CommandBuilder#flash_commands (key-page erase + key writes as whole doublewords + IWDG freeze [SEC.15] + RDP; the UID-read already ran as preflight — gotcha #11). ⚖️ The pipeline never burns L2 (SEC.2, founder 2026-09-28): one run = keys → IWDG → RDP, which leaves no room for the self-test and WRP the ratified burn order puts before L2, so ProvisioningSession::RDP_LEVELS = {0, 1} and CommandBuilder::RDP_OPTION_BYTE has no L2 byte (raw bytes, not level numbers — 03_05 §3.6). Production runs the pipeline at RDP_LEVEL=0, then the 03_05 §3.6 steps 3–7 (NOT at L1: on L1 a failed self-test board cannot be reflashed without an L0 regression, i.e. a mass erase). WRP is per chip — the Queen's page 125 is her runtime OTA-SHA mirror, so only page 124 there.
  7. Execute — Executor (dry-run prints; --execute spawns subprocesses).
  8. Audit — AuditTrail.record! → chain-hashed AuditLog (metadata incl. silicon_uid_hex) + MaintenanceRecord; complete! (or fail_with! + rollback).

Key Components

ComponentRole
app/services/factory_flashing/session.rbOrchestrator (run, preflight!, verify_silicon_uid! wrong-board guard, AASM start!/complete!/fail_with!)
app/services/factory_flashing/tree_resolver.rb[FW.54] UID→DID→Tree: create / re-flash / bind / collision→quarantine; deliberately does NOT enqueue peaq (offline factory)
app/services/factory_flashing/uid_readout.rb[FW.54] tolerant -r32 stdout parser (keyed on 1FFF7590); live format = bench-confirm (RUNBOOK 1.3)
app/services/factory_flashing/command_builder.rbSTM32_Programmer_CLI emission (preflight_commands class-method: -c+UID-read in one invocation; flash_commands per гілка, block_words → flash_write_commands (page erase + whole doublewords), rdp_command)
app/services/factory_flashing/secure_element_provisioner.rbATECC608B data-zone provisioning (Гілка B)
app/services/factory_flashing/executor.rbdry-run vs live subprocess (programmer_available?)
app/services/factory_flashing/master_key_source.rbBase / EnvAdapter / BitwardenAdapter master-key fetch — the fetched key feeds HKDF via Session (SEC.3 DI), not just the preflight gate
app/services/factory_flashing/audit_trail.rbchain-hashed audit log record
ota_hmac_key_service.rbper-cluster OTA HMAC key fetch_for(cluster_id, master_key: nil) (KOTA block in BOTH branches + the Гілка-B SE Slot-3 copy)

Line numbers drift every commit — grep/read Session for live locations rather than a hardcoded table ([[feedback_no_volatile_counts]]).

Gotchas Not Obvious From Code

  1. Гілка A vs Гілка B (ARCH.42 variants, post-SEC.14 2026-07-03) — A = keys written into Protected Flash via SWD (-w32). B = Гілка A + SE05x identity-chip: CommandBuilder#flash_commands emits the SAME protected_flash_commands for both branches (KEYL · LSED · KOTA · KEYB for a Tree; KEYL=KEYB-value · KEYC · EDSK for a Queen), because every runtime key has an MCU consumer (CRYP · Lorenz-VM · OTA-HMAC); the SE adds only the Ed25519 voice / cert / anti-clone serial (+ a Slot-3 K_ota copy for the open post-TRL-7 migration), NOT the LoRa key — Slot 0 is reserved and never written (⚖️ delegated 2026-09-27; verdict, price and weakest link — 03_06 §1, callout «Набір ключів Гілки B»). 🔴 Until 2026-09-27 gilka_b_commands skipped every key write (legacy ATECC model): a Гілка-B Soldier had no Flash KEYL → brick, and a Гілка-B Queen had neither KEYC nor EDSK. ⛔ Do not read «Гілка B» as «keys live in the SE»: its runtime keys are protected by RDP exactly like Гілка A's (SEC.2/SEC.24) — the SE's non-extractability covers identity only.

  2. Flash layout MUST match firmware — CommandBuilder addresses/magics mirror firmware/soldier/main.c FLASH_KEY_ADDR (post-ARCH.42): KEYL(0x4B45594C)+aes@0x0803E000, LSED(0x4C534544)+k_seed@0x0803E014 (Tree), KEYC(0x4B455943)+coap@0x0803E040, EDSK(0x4544534B)+ed25519_seed@0x0803E064 (Gateway, L1 QATT), KOTA(0x4B4F5441)+k_ota@0x0803E800 (Tree, FW.23), KEYB(0x4B455942)+bcast@0x0803E828 (Tree, FW.2 (в) cluster control-plane). Drift here ⇒ device can't read its own key. Change one side → change both + host tests. Word→BE-bytes convention (FW.30): firmware unpacks each -w32 word MSB-first — naive memcpy on LE Cortex-M4 reverses every word.

  3. Dry-run is the default; live is the SEC.3 residual — real STM32_Programmer_CLI execution is bench-gated, not yet run on hardware. RDP Level 2 (SEC.2, irreversible chip lock) is not a bench-gated pipeline step but no pipeline step at all: the session refuses level 2 (step 6), and production L2 is the 03_05 §3.6 procedure, steps 5–7 — whose script does not exist yet (00_07 SEC.2).

  4. Key shapes — Tree: 32-hex AES-128 LoRa key + 64-hex Lorenz K_seed; Gateway: 64-hex AES-256 CoAP key + 32-hex broadcast key written to the LoRa KEYL slot (FW.2 (в), post-2026-07-03 — Queen's single control-plane key = the KEYB broadcast value; the pre-fix «LoRa slot unused» bricked her at boot) + optional 64-hex Ed25519 seed (L1 QATT «голос Королеви» — generated by Session on the factory host, NOT HKDF; only the pubkey persists in HardwareKey). CommandBuilder#validate! enforces this.

  5. Transaction + chain-hash integrity — the whole run is one AR transaction; a downstream raise rolls back HardwareKey + audit rows together, so rolled-back rows never enter the chain-hashed AuditLog (the chain stays intact).

  6. Supervisor gate — Session refuses to run unless the ProvisioningSession is supervisor_approved (preflight may_start?).

  7. Never add WeakKeyDetector to the ENV-fallback branch of the derivation services — the rails_helper test pin (silken-net-test-master-key-32b!!) is itself a placeholder needle in the detector, so validating inside hkdf_derive/fetch_for/derive_seed fails the whole suite. Coverage is already two-layer by design: EnvAdapter guards the factory path, the boot initializer (master_key_strength_check.rb) guards runtime.

  8. [FW.54] UID wire-form is frozen — 24 hex = three %08X words in register order (0x1FFF7590 first), exactly how firmware did_derive.h reads them; golden pair 0039002F3138511538323634 → SNET-80B12004 frozen in did_derivation_spec ↔ firmware/test/test_soldier_logic.c. Reordering/re-endianing the parse = a different DID on backend vs silicon (keys diverge silently).

  9. DID is derived EVERYWHERE by one function — wire_did_from_uid_hex feeds both the factory (TreeResolver) and the field ProvisioningController#register (the old last(8)-suffix DID + the dead tree double-init guard were a real prod bug, fixed 2026-07-03). Never invent a third derivation path.

  10. Collision ≠ re-flash — same derived DID + different silicon_uid_hex = birthday collision or wrong chip → CollisionError, unit goes to quarantine (03_01 §7); same UID = legit re-flash (idempotent no-op). ⚠️ The no-op is the RESOLVER's only. For a TREE the session is not: since 2026-09-28 every re-flash of a tree whose HardwareKey row exists is a RE-PROVISION into a new key EPOCH (Session#reprovision_tree_key!, ⚖️ founder 2026-09-28, 03_05 §3.8) — KEYL = the epoch root K0_e from the CURRENT master, a fresh Flash-KV journal (FlashKvImage: only 0x15, pages 122 AND 123 erased — a surviving sibling with a higher seq would win the mount and resurrect the ratchet version; since 2026-09-29 on the FIRST provision of a tree too — a bench-used board carries a foreign journal), K_seed re-derived under the CURRENT master and written with the row (⚖️ founder 2026-09-29, 03_06 §5 — same master, same seed), and in the DB epoch + 1 · key_version 0 · downlink_frame_counter 0 (the fresh journal carries no DLFC record 0x12, 03_05 §2.5) · the old key in grace until the first MIC under the new one. Dry-run only PLANS it: an epoch bumped without a flashed chip deafens the node. ⛔ Don't reset key_version or epoch by hand to «fix» a tree — the re-provision is the fix. A Queen's re-flash still writes her row's key as is (it IS the delivery of a rotated KEYC).

  11. Every transcript line is its own CLI process, and WL Flash takes only whole erased doublewords [SEC.3, 2026-09-28] — STM32_Programmer_CLI holds SWD only within one invocation, so CommandBuilder::CONNECT (-c port=SWD mode=UR, the vendor WL script's form; UR = hardware reset, so NRST must be wired) prefixes EVERY line — a «disconnect» line does not exist, and UR is needed because after -e a keyless firmware resets every ~100 ms. -w32 does not erase (STM32CubeProgrammer manual) and WL programs a whole doubleword + ECC (HAL), so flash_write_commands erases the touched pages first and writes each touched doubleword exactly once, padding with 0xFFFFFFFF; the erase is irreversible, hence the passport-less guard (step 2). 🔴 Before 2026-09-28 only the KOTA|KEYB boundary was aligned (+40) — magic + key word and the KEYL|LSED boundary still shared doublewords across commands, and the execute-path shim stayed green because it accepts any args: a shim proves orchestration, not CLI semantics. Adding a key block → the pins «форма запису Flash WL» in command_builder_spec judge it (incl. «redact hides every key word»); a runtime-written page (Soldier 122–123/126, Queen 122–123/125) must never enter the erase set, and the erase also wipes the Soldier's role word on page 124. Executor.redact keeps -w32 data out of the dry-run print AND the persisted error_message; dry-run never persists the Queen's voice pubkey. ⚠️ Keys still sit in the CLI's argv — a factory-host requirement (03_06 §5).

  12. Dry-run is a PLAN, not a flash — and it is the default of factory:execute [SEC.3, 2026-09-28] — it runs the real derivation from the real master key, so nothing it produces may be persisted as if it had been flashed, and nothing it prints may carry key data. Today's carriers: confirm_gateway_key_delivery! (grace) and gateway_voice_seed (the Queen's voice pubkey) both skip on dry_run?; Executor prints redact(command). 🔴 Until 2026-09-28 a Queen dry-run replaced her pubkey with one of a seed that lived only in the terminal output — the real Queen's L1 signatures stopped verifying — and two specs pinned exactly that, because they ran dry-run and asserted the pubkey was persisted. Adding a side effect to Session? Ask «does it describe the chip?» — if yes, gate it on a live run and pin the dry-run negative.

How to Explore

  1. grep/read Session — orchestrator callers/callees
  2. grep factory flashing across app/services/ — related flows
  3. Read command_builder.rb for the exact CLI sequence; 03_06 §5 for the threat model

Signals

GitHub stars
23
Forks
1
Last commit
Oct 2026
Advanced
Item type
skill
Key
factory-flashing
Source
github.com/alexey-lukin/silken_net