# 2026-07-13 -- D-113(a2): API-key bootstrap, with no GUI click and no re-implemented crypto

The last unknown in D-113(a2) edge provisioning. The API path was proven (read AND write), but
every new edge still needed a **manual GUI click** to mint its API key -- which would have put a
human in the middle of building DC1's and DC2's edges in Stage 3. Closed.

## What shipped

- `scripts/opnsense-mint-apikey.php` -- runs ON the edge; calls **OPNsense's own model**
  (`Auth\User` -> `apikeys->add()`), the exact code path the GUI's ticket button invokes.
- `scripts/opnsense-bootstrap-apikey.sh` -- ships the minter over SSH, runs it, retrieves the
  key, wipes the remote copies.
- `tests/opnsense-bootstrap-apikey/run-tests.sh` -- offline harness, **8 PASS** (ssh/scp stubbed;
  no edge contacted, no real key minted).

## The design decision, and why the alternative was rejected

OPNsense stores API secrets as `crypt(secret, '$6$')` -- SHA-512 with an EMPTY salt (verified on
the live 26.1 box: the stored hash is `$6$$` + 86 chars). So we *could* mint keys offline and
seed them into a bootstrap `config.xml`.

**We deliberately do not.** That would couple our provisioning to a vendor hash format we would
have to keep byte-compatible forever -- and a mismatch **fails silently**: keys that simply never
authenticate, with no error at generation time. Calling the vendor's own generator has no format
to keep in sync.

This is the same principle as D-113(a2) itself: **call the interface, do not re-implement the
internals.** Hand-reimplementing OPNsense's internals is exactly what cost us DOCFIX-191/192/193.

(An offline-hash attempt was actually blocked by the permission classifier mid-session. The block
was correct, and it pushed us to the better design.)

## VERIFIED END TO END on the live edge (2026-07-12/13)

Minted a SECOND key on Office1 via the vendor model, proved it works, then deleted it:

    mint via OPNsense's own model   -> key=80 chars, secret=80 chars (base64 of 60 random bytes)
    GET core/firmware/status        -> HTTP 200  [the vendor-minted key AUTHENTICATES]
    POST auth/user/del_api_key/<id> -> {"result":"deleted"}
    search_api_key                  -> 1 row remaining (the operator's original key)
    retired key re-tested           -> REJECTED  [negative test: deletion really took]

The operator's existing key was untouched throughout, and the temporary creds file was shredded
from the jumphost. The edge is back to its prior state.

## Guard clause worth keeping (harness T5)

The script **refuses to overwrite an existing creds file.** An existing file means a LIVE key on
the edge; overwriting the local copy would **strand it there permanently**, because the secret is
hashed on save and can never be read back. That is unrecoverable credential loss, and a guard
clause is the only thing standing in front of it. T5 asserts both the refusal AND that the
existing file is left byte-intact.

The secret is never printed: creation is the only moment it exists in cleartext. It goes straight
to a 0600 file in the `key=`/`secret=` form `scripts/opnsense-api.sh` parses.

## What this unblocks

Edge provisioning is now automatable end to end, with no human in the loop:

    boot factory nano
      -> D-112(c) console bootstrap (enable SSH + install key)
      -> scripts/opnsense-bootstrap-apikey.sh        <-- THIS (no GUI click)
      -> scripts/opnsense-api.sh                     <-- everything else: DHCP, firewall, ifaces

No `config.xml` anywhere in that chain.

## Still OPEN

- **The template reduction itself is NOT done.** `opentofu/templates/opnsense-config.xml.tmpl`
  still renders a FULL config. Under D-113(a2) it should shrink to a minimal bootstrap or vanish
  entirely -- the chain above suggests it may not be needed at all, since the console bootstrap
  plus this script covers first contact. That is the next call.
- The `opnsense-edge` module still wires `config_seed` + a cdrom disk for the INERT config-ISO
  path (D-112). Removing them touches an INSTANTIATED resource (`libvirt_volume.config_seed` is
  in state), so it is a live change, not a docs change -- deliberately not done here.

## Revert

    git rm scripts/opnsense-bootstrap-apikey.sh scripts/opnsense-mint-apikey.php
    git rm -r tests/opnsense-bootstrap-apikey/

Nothing live depends on these: the live edge's key was minted via the GUI and is unaffected.
