Key management & security#
The security-critical playbook: how per-tag keys are derived, how the System Master Key is custodied, how rotation works, and what may leave the system. The one rule that ties it all together: key bytes never appear in any export.
Warning
Every hex value on this page is a labeled test vector, never a real key. Never paste a production master key into docs, tickets, chats, or URLs.
The key hierarchy#
There are two layers:
- System Master Key — one per system (16 bytes, AES-128), generated with a CSPRNG. This is the crown jewel: whoever holds it can forge any tag in that system.
- Per-tag diversified keys — each tag carries its own
SDMFileReadKey, derived from the master key and the tag's UID. They are never stored; the verifier re-derives them on demand.
Because each tag holds only its own diversified key, a tag teardown or side-channel leaks one tag's key, not the master. Verification re-derives the per-tag key from master + UID every time.
AN10922 diversification#
The per-tag key is derived with NXP AN10922 AES-128 key diversification — a fixed two-block construction over the UID, not generic AES-CMAC.
The shape of it, without the arithmetic:
- Build the diversification input
Mfrom a fixed one-byte role label (file-read vs meta-read) plus the tag's UID, and prefix a constant key-type byte. - Pad that to exactly two AES blocks — always two, never one.
- Run the two-block chain under the master key. The output is the tag's key.
Why "two blocks, always" matters#
This is the single easiest thing to get wrong in the whole system. Generic AES-CMAC uses one block when the input fits in one; AN10922 forces two. EdgeNFC's diversification input is short enough that CMAC would take the one-block path — so an implementation that reaches for a stock CMAC function produces a key that is wrong, but wrong plausibly: it is the right length, it looks random, and nothing errors. You only find out when every tag you provisioned fails to verify.
The construction is pinned against the published NXP AN10922 vector and lives in the core's test suite, and the same diversify_key implementation is used by the provisioning app and every verifier — app, hosted gateway, and DIY edge alike. Do not re-implement it. If you are self-hosting, call the core; if you must port it, make the published vector pass first.
Test master key (published NXP vector, safe to show):
K = 00112233445566778899aabbccddeeffThe diversification input is built from the tag's UID; the core computes the per-tag key from (K, UID). Only the master key ever needs to exist at rest — no per-tag key storage is required.
Master-key custody#
Custody depends on your tier. In every tier the key bytes are handled as secret material and never logged.
| Tier | Where the master key lives | Returned by the API? |
|---|---|---|
| Hosted (SaaS) | envelope-encrypted under a KMS/HSM wrapping key; plaintext exists only transiently in memory at verify time, then zeroized | Never after creation |
| DIY (Enterprise Edge) | shown/downloaded exactly once at creation; never persisted server-side; you self-host it | Once, at creation only |
| Provisioning app | Android Keystore-wrapped storage; used in-process to derive per-tag keys, then zeroized | n/a (device-local) |
Hosted custody + the reveal-once rule#
Hosted keeps the master key server-side (envelope-encrypted) so it can verify taps. But the provisioning app needs the key on the device to write tags. The reconciliation is a one-time reveal: POST /api/keys with { "tier": "hosted", "reveal": true } returns the generated key once (with a warning) while the server retains its envelope-encrypted copy for the verify path. Without reveal, hosted key creation returns the key_id only and never the key bytes.
DIY custody is the mirror image: the key is yours. POST /api/keys with { "tier": "diy" } returns the key exactly once and stores no copy:
{ "key_id": "sys_01H…", "tier": "diy", "key": "00112233445566778899aabbccddeeff",
"key_version": 1, "warning": "shown once; not stored server-side" }DIY custody requires a perpetual Enterprise Edge license (402 license_required otherwise).
Rotation#
Rotate with POST /api/keys/{id}/rotate. Rotation bumps key_version, issues a new master key, and — on Hosted — opens a 90-day migration window during which the verify path accepts both the new version and the one it replaced.
Rotation is a confirmed action. The call refuses without an explicit {"confirm": true} in the body, and the refusal reports how many tags the rotation is about to affect, so the preview is a real number rather than a placeholder:
# Step 1 — call it without confirming. The 400 refusal is your preview.
curl -X POST https://edgenfc.com/api/keys/sys_01H…/rotate \
-H "authorization: Bearer $TOKEN" -H "content-type: application/json" -d '{}'
# Step 2 — rotate for real.
curl -X POST https://edgenfc.com/api/keys/sys_01H…/rotate \
-H "authorization: Bearer $TOKEN" -H "content-type: application/json" \
-d '{"confirm": true}'What the migration window is, and what it is not#
Per-tag keys are derived from the master key and never stored anywhere. That is what makes retiring a master key decisive: retire it, and every tag derived from it is retired with it — in one operation, across every verifier, with no per-tag list to chase and nothing left quietly trusting the old material. Rotation is a real answer to a compromised key, and this is the property that makes it one.
The migration window is how you spend that decisiveness on purpose when the rotation is planned rather than an emergency. On a Hosted rotation the outgoing master key is retained — envelope-encrypted under exactly the same KMS-held wrapping key as the current key — alongside the version number those bytes belong to and a hard expiry timestamp 90 days out. Within that window:
- The current key is tried first. A tap is verified against the current master key; only if that fails does the verifier retry once against the retained previous key. A tag already on the new version costs exactly what it cost before rotation, and neither path adds a database read to the tap.
- The registry decides, not the ciphertext. A previous-key acceptance is only honoured if the tag's own recorded
key_versionequals the retained version. The recorded version is the authority on which key a tag may be accepted under — it is not merely a hint about which key to try first.
That second rule is the one with day-to-day consequences: re-provisioning a tag closes its N-1 door immediately. A tag you re-write today moves to the new version in the registry at that moment, and the old key stops being accepted for it — you do not wait out the remaining window, and a tag whose hardware and registry disagree is refused with a nameable reason instead of silently accepted.
Only one previous version is retained. Rotating twice inside a window replaces the retained material with the key you just retired; tags two versions back are not covered by anything.
The window is the deadline — re-provisioning is the job#
Ninety days is one operational quarter: long enough to schedule a field-service pass, a recall, or a next-shipment re-write, and deliberately no longer, because an accepted N-1 key is by definition key material you have already decided to stop using. The window buys you the scheduling cycle. It does not do the work. The work is physically re-provisioning every tag onto the new version, and the portal tells you exactly what is outstanding:
- Dashboard — a system past
v1shows a Stale tags button, and while a window is open the key version is annotatedv<N-1> still accepted until <date>. - API —
GET /api/keys/{id}/stale-tagslists the active tags still on an older version, marks which arestill_verifying(carried by the open window) versus already dark, and reportswindow_days_remaining. See the API reference.
When the window expires#
At expiry the retained key material is ignored, not deleted. The fallback is gated on the clock, not on presence: the moment the expiry timestamp passes, the previous key is inert and the tap path is byte-for-byte what it was before the rotation. The bytes stay envelope-encrypted at rest until the next rotation overwrites the slot.
That is deliberate. Clearing the slot on expiry would put a database write on the anonymous, unauthenticated tap path, on the single row every tap in the system reads. Because safety comes from the timestamp rather than the deletion, the tap path never has to write at all — which is also why a tap can never be made to modify your system's key state.
Any tag still on the old version when the window closes stops verifying. Only re-writing the hardware brings it back.
Rotating a compromised key — take no window#
If you are rotating because the old key is burned, a grace period is the wrong answer: it is 90 more days of accepting material you have decided not to trust. Opt out in the same call:
{ "confirm": true, "retain_previous": false }The outgoing key is cleared instead of retained, and every tag on it stops verifying immediately — the decisive behaviour described above, reached on purpose rather than by default. This is also how you close a window you have already opened: rotate again with retain_previous: false, and the retained slot is overwritten with nothing.
DIY systems never open a window. No key bytes exist server-side, so there is nothing to retain and nothing to fall back to: a DIY rotation stops every tag already in the field from verifying at the bump, and the window fields come back null. If a DIY key is ever lost, rotation is the recovery path — there is no server copy to recover from.
Handling the key in an edge environment (DIY)#
If you self-host the verification core — your own servers, your own Workers — you hold the master key, and these are the rules that keep holding it safe. They are the same rules the hosted gateway follows internally.
- Load the key from a secret store, at runtime. A platform secret binding, a KMS/vault fetch, an injected environment secret. Never a config file in the repo.
- The key is never in the bundle. The DIY package ships the core plus public config and asserts
contains_master_key:false. If your build artifact contains key bytes, your build is the vulnerability — a Wasm bundle shipped to a browser or an edge CDN is readable by anyone who wants it. - Derive, use, zeroize. Per-tag keys are derived on demand and never stored. Zeroize derived and session keys after each verify; keep master-key plaintext in memory only for the moment it is needed. The provisioning app does the same via Android Keystore-wrapped storage.
- Compare in constant time. MAC comparison must be constant-time with no early exit — a byte-by-byte
==on a MAC leaks the correct value one byte at a time under timing analysis. The core uses a constant-time compare; if you wrap it, don't undo that. - Never log it, never let it into an error. No key bytes in logs, traces, exception messages, crash dumps, or query strings. The core returns non-secret error values by design.
- One key per system. Don't share a master key across unrelated systems or between staging and production — the blast radius of a leak is every tag derived from it.
Warning
Whoever holds the System Master Key can forge any tag in that system. Treat a suspected exposure as a rotation event, not a monitoring event — and rotate it with
retain_previous: false, so the exposed key is not accepted for another 90 days.
Export options — key bytes never leave#
Three things can leave the system, and none of them ever contains key bytes:
- DIY package (
GET /api/download/diy) — core WASM + verify template + public config. No master key. The bundle assertscontains_master_key:false. - Registry export (from the provisioning app /
GET /api/tags) — UIDs, key version, SDM config. No key bytes. - DSAR export (
POST /api/dsar{ "type": "export" }) — the account bundle, which assertscontains_key_material:false.
The only time key bytes are ever emitted is the reveal-once moment at creation (DIY always, Hosted only with reveal:true). After that, no API, export, or listing returns them.
Threat model recap#
| Threat | Mitigation |
|---|---|
| Cloned / forged tag | AES-CMAC over the SDM data with a per-tag diversified key — a forgery needs that key |
| Replay of a captured URL | Monotonic SDMReadCtr; the verifier rejects ctr <= last_seen (non_monotonic) |
| UID harvesting / tracking | Encrypted-PICC mode hides the UID inside the URL |
| Master-key exfiltration | Envelope encryption + KMS (hosted); Android Keystore (app); never in logs or errors |
| Timing side-channel | Constant-time MAC comparison, no early exit |
Verify it worked#
- Create a DIY system and confirm the key is returned once; list keys (
GET /api/keys) and confirm the bytes are not in the listing. - Download the DIY package and confirm
contains_master_key:false. - Run a DSAR export and confirm
contains_key_material:false. - Rotate a Hosted system with
{"confirm": true}and confirmkey_versionincrements, the response carriesprev_key_versionplus aprev_key_expires_at~90 days out, and tags on the previous version still verify. - Call
GET /api/keys/{id}/stale-tagsand confirm those tags are listed withstill_verifying: true— then re-provision one and confirm it drops off the list. - Rotate with
{"confirm": true, "retain_previous": false}and confirmprev_key_expires_atcomes backnulland previous-version tags stop verifying at once.
Next steps#
- REST API reference — the
/api/keyslifecycle and export endpoints. - DIY Edge Core — self-host verification with your own key.
- Android provisioning — write tags with the master key.
EdgeNFC