A tamper-evident, on-device log of security events: boots, FIDO registrations and logins, factory resets, PIN set/change/lockouts, policy changes, seed backup and soft-lock activity.
rsk audit log # export + print (add --pin if a PIN is set)
rsk audit verify # log + DEVK-signed checkpoint (touch)
rsk audit verify --expect-key <hex> # also pin the enrolled attestation keylog is a plain read. It pretty-prints the live window and the recomputed
chain head, no signature. It takes --pin on a device with a PIN, and a touch
on one with none (the read is never fully open). verify does the same read
and asks the device to sign the head. It is the command that actually proves
the log is real. Use log for a quick glance, verify when the answer matters.
| event | detail |
|---|---|
BOOT |
first journal touch of each power cycle |
MAKE_CREDENTIAL / GET_ASSERTION / U2F_REGISTER / U2F_AUTH |
first 8 bytes of the rpIdHash (only weakly pseudonymous, see Gating) |
RESET |
factory reset (survives it — see below) |
PIN_SET / PIN_CHANGE / PIN_LOCKOUT |
lockout aux: 0 = retries exhausted, 1 = per-boot block |
CFG_MIN_PIN |
aux = new minimum; detail[0] = forceChangePin |
CFG_ENTERPRISE_ATT |
no aux/detail (flag-only) |
LOCK_ENGAGE / LOCK_RELEASE |
soft-lock engage/release |
BACKUP_EXPORT / BACKUP_LOAD / BACKUP_FINALIZE |
seed-backup lifecycle |
ATT_IMPORT / ATT_CLEAR |
org attestation provisioning |
CFG_ALWAYS_UV |
alwaysUv toggled; no aux/detail (flag-only) |
CONFIG_WRITE |
a device-config write over the FIDO vendor channel. aux = the target that opened the entry (0 dev-conf, 1 phy, 2 led); detail = repeats(2 LE) ‖ targets(1) (see below) |
AUDIT_CFG |
journalling itself: aux 1 = turned on, 0 = turned off (that entry is the last one written, so the trail shows when it stopped) |
CHECKPOINT |
every signed checkpoint is itself logged |
Each entry is a fixed 20 bytes:
seq(4) ‖ uptime_ms(4) ‖ event(1) ‖ aux(1) ‖ detail(8) ‖ rsvd(2). There is
no wall clock on the device. uptime_ms counts from the moment the key
attached to USB, not from power-on — boot spends seconds before that on TRNG
seeding and flash migrations, and none of it is time a host could have used.
Every power cycle opens with a BOOT entry, and the sequence number gives total
order. Wall-clock attribution is the host's job (e.g. record when you ran
rsk audit verify).
For the FIDO operations — MAKE_CREDENTIAL, GET_ASSERTION, U2F_REGISTER,
U2F_AUTH — the detail field carries the first 8 bytes of the rpIdHash and
nothing else: no RP names, user handles, or credential IDs. That is deliberate:
the log answers "how was this key used and how often" without revealing which
sites. See gating below. Other events use detail for their own small
payload (CFG_MIN_PIN's forceChangePin flag, CONFIG_WRITE's run counters); none
of them records a site.
The journal is append-only with one bounded exception. CONFIG_WRITE is
ungated on the default build and is the only event a silent
host can drive on demand, so 128 of them would otherwise evict every other entry
from the 128-slot window. Instead a run of config writes folds into a single
entry: it keeps the seq and the timestamp of the first write of the run,
and its detail counts the rest — repeats(2 LE) ‖ targets(1), where targets is
a 1 << target mask of every record the run touched. rsk audit log renders it:
seq uptime event aux detail
201 8.2s CONFIG_WRITE 1 300× write (phy+led)
Two consequences worth knowing:
- A run never folds across a power cycle, so the
BOOTentry between two runs is never swallowed. - A fold moves the chain head without advancing
seq_next. Seeing the sameseq_nextwith a different head is legitimate, not a tamper signal;verifyre-folds the exported window and still matches.
This bounds the flood, it does not remove it: a phy write latches a reboot, so a
host willing to make the key re-enumerate can still spend two slots per cycle
(a fresh BOOT plus a fresh run). Each cycle is a visible re-enumeration, and
building --features strict-config — which puts the write behind a touch and a
PIN token — is the complete answer.
A log run prints a header, the chain state, then the window:
window [72, 200) — 128 entries, 72 folded into the epoch
epoch : 4f1c… (the accumulator for evicted history)
head : a93b… (chain over the window — OK)
seq uptime event aux detail
72 3.4s GET_ASSERTION 0 1a2b3c4d5e6f7081
73 120.9s MAKE_CREDENTIAL 0 9f8e7d6c5b4a3928
…
The journal is a 128-entry flash ring. Each entry extends a SHA-256 hash
chain; when the ring is full, the oldest entry is folded into an epoch
accumulator (epoch' = SHA-256(epoch ‖ entry)) before its slot is reused, so
evicted history stays attested in aggregate even though its per-event details
are gone. The chain head is fold(epoch, window): the epoch run forward
through every entry still in the ring.
The chain is anchored, on an empty journal, at
SHA-256("RSK-AUDIT-GENESIS-v1" ‖ serial_hash), bound to the device so two
boards' empty journals never share a head.
flowchart TD
e["new event"] --> chain["SHA-256 hash chain (window)"]
chain -->|ring full| fold["fold oldest into epoch accumulator"]
fold --> reuse["slot reused"]
chain --> head["chain head = fold(epoch, window)"]
rsk audit verify sends a fresh 16-byte random challenge. The device signs
"RSK-AUDIT-CKPT-v1" ‖ head ‖ seq_next ‖ challenge with an ECDSA P-256 key
derived (HKDF) from the OTP DEVK (production.md stage 1)
and returns the signature plus its 65-byte SEC1 public key. The host refolds
the exported window, verifies the signature over the message it reconstructs,
and checks that the signed head matches the refold. The challenge is what makes
the verdict fresh. A replayed old checkpoint signs a stale challenge and fails.
A successful verify prints the window, the head, and the attestation key with
a short fingerprint. The verdict depends on whether you pinned the key:
chain : OK — head a93b…
sig : OK — checkpoint over seq_next=201, fresh challenge
att key : 04a1b2… (65-byte SEC1)
fingerprint 9c4e7f12ab… — record this; pin later runs with --expect-key
verdict : chain + signature OK — the key is NOT pinned, so this does not prove
which device signed it
With --expect-key the last line becomes
journal authentic ✓ (signed by the pinned key).
Meta updates are ordered so that a power cut at any point loses at most the newest event and never produces a false tamper verdict: when the ring is full the fold-and-advance meta is committed before the slot is reused.
The checkpoint key is deterministic and reset-stable (HKDF of the DEVK), so a given device always signs with the same public key. Record it once at provisioning, then pass it back on every later run:
rsk audit verify # first run: copy the printed "att key" hex
rsk audit verify --expect-key 04a1b2… # afterwards: fail loudly on any mismatch
rsk audit verify --expect-key 9c4e7f12ab… # the short fingerprint works tooA mismatch means the public key changed, which can only happen if the DEVK
changed. That means you are talking to a different device, or a clone that
was flashed without burning the same OTP. --expect-key takes either the full
65-byte SEC1 point (04 ‖ x ‖ y) or the 16-hex fingerprint, lower-case, and the
comparison is exact. The full key is the stronger pin. Stash it in your
provisioning record alongside the device serial.
Without a pin, verify proves nothing about identity. The public key it
checks against arrives in the same response it is checking, so an unpinned run
establishes only that the journal is internally consistent and self-signed — a
counterfeit signing with a key of its own passes it. The device refuses to sign
without an OTP DEVK, but a host cannot tell a DEVK-derived key from any other
P-256 point. Pinning is what turns "some key" into "your device".
authenticatorReset does not erase the journal. It folds the whole
window into the epoch and deletes the per-event details, then logs the
RESET. A handed-over device therefore proves "N events happened, then a
reset" without revealing where it had been used. The chain (and the
checkpoint key, which is DEVK-derived) continue uninterrupted across resets, so
verify keeps working and the head still validates against the same
--expect-key.
| command | open device | PIN set |
|---|---|---|
audit log / AUDIT_READ |
touch | pinUvAuthToken with the acfg permission |
audit verify / AUDIT_CHECKPOINT |
touch | touch + acfg pinUvAuthToken |
AUDIT_READ(export). With a PIN set it needs a pinUvAuthToken carrying theacfg(authenticator-config) permission. With no PIN it needs a physical touch. The entries are only weakly pseudonymous. Adetailis a 64-bit rpIdHash prefix, never an RP name or user handle, but short enough to be dictionary-matched back to a domain. So the touch is what stops a silent host from harvesting a no-PIN device's RP-usage history.AUDIT_CHECKPOINT. The same PIN gate plus a physical touch, and it refuses entirely without a provisioned OTP DEVK. An attestation that anyone could re-derive would be theatre. The signing step is what the touch protects. The read that precedes it isAUDIT_READ-gated as above.
If a PIN is set, both subcommands take --pin. The PIN is exchanged over the
standard CTAP pinUvAuth protocol (it is not sent in clear), and a wrong PIN
counts against the FIDO retry counter. Do not guess.
| symptom | meaning / fix |
|---|---|
device requires a PIN — pass --pin (status 0x36) |
a FIDO PIN is set; add --pin <pin> |
checkpoint refused — no OTP DEVK provisioned (status 0x30) |
dev board with no DEVK burned; verify cannot sign. log still works. See production.md |
denied — no touch within 30 s (status 0x27) |
press the button when the LED blinks; rerun |
attestation key MISMATCH — this is not the enrolled device |
--expect-key did not match: wrong device, or a clone flashed without your OTP |
signed head differs from the exported window |
the journal changed between the read and the checkpoint. Rerun; if it persists, treat it as TAMPER |
checkpoint SIGNATURE INVALID — do not trust this journal |
the signature did not verify under the returned key. Do not trust the log |
export length does not match the window |
the exported entry bytes don't match seq_next − start; a corrupt or truncated read |
The two "rerun first" verdicts are different. A head mismatch can happen benignly if an event landed between the read and the checkpoint (a login on another host, say). One rerun usually clears it. A signature failure or a key mismatch never has a benign cause. Do not retry your way past those.
The log is written by the firmware, so its honesty is rooted in the boot
chain: with secure boot + the OTP master key (production.md,
otp-fuses.md) only your signed firmware can append to the
journal or wield the checkpoint key, and a flash dump cannot forge it. On an
unprovisioned dev board the journal still works as a debugging aid, but verify
is refused. There is no device-bound key to sign with, and a checkpoint without
one would prove nothing.
Two honest limits worth stating:
- The window is 128 entries. Older events are folded into the epoch and
their details are gone. You can prove they happened (the head still
covers them) but not read them back.
verifyregularly if you want a per-event record. The host transcript is your archive, the device is not. - There is no wall clock. The device cannot tell you when in calendar
time something happened, only the order and the milliseconds since that power
cycle's USB attach. Pair the
seqandBOOTmarkers with your own host-side timestamps.