core: Add micro-motion liveness check (universal)
The first real liveness signal: scores involuntary frame-to-frame change
across the capture burst. A live subject always moves a little over the
~100-200ms a burst spans (head sway, micro-expression, blink); a printed
photo or a screen held up is rigid, differing only by sensor noise. So
mean per-pixel change is a model-free liveness signal — and it works on
*any* IR sensor, which is why it's the first check.
# Same-illumination comparison
The one subtlety is strobed sensors: their burst alternates lit/dark
frames, so diffing consecutive raw frames would read the strobe as huge
bogus "motion". The check therefore compares only same-illumination-phase
frames — those within `phase_band` of the lit frame's brightness. On a
constant sensor every frame qualifies (the grouping collapses to "use
all"), which is what makes the check universal. A test pins that the
strobe's dark frame is excluded.
# Applicability
Needs >= 2 same-phase frames; with fewer it returns `NotApplicable`, not
a bogus score. A minimal period-2 matching burst (one lit frame) is
therefore NotApplicable — liveness-aware burst sizing (next commit) is
what gives it enough same-phase frames. The additive model means this
just contributes nothing until then, never breaks anything.
# Scoring
Normalised mean-absolute per-pixel difference between consecutive
same-phase frames, saturating to 1.0 at `MicroMotionConfig::saturation`.
The constants are first-cut estimates to be tuned from real
live-vs-spoof captures (guided calibration), which is why liveness stays
observe-only (threshold 0.0) until then.
Proptested: total + bounded over arbitrary frames, and monotonic in
motion (more per-pixel change never lowers the score). Also records the
planned complementary checks (embedding-agreement, texture, differencing)
in the module docs.
core: Add liveness profile store (atomic CBOR)
`LivenessProfileStore`: a single-file CBOR store for the accumulating
guided-calibration profile. Like the camera calibration store and unlike
the enrollment store, no HMAC — it holds tuning data, not secrets, and
liveness degrades gracefully on an absent/unreadable profile. Atomic
writes (temp + fsync + rename) so a crash never leaves a torn file;
versioned (refuses unknown future versions). `load` returns None when
absent (uncalibrated, normal), `load_or_default` for read-modify-write
verbs, plus `save`/`remove`.
Tested: missing-is-None, save/load round-trip, idempotent remove, no
temp-file leakage, an arbitrary-profile round-trip proptest, and a
16-thread concurrent-writer stress test asserting the file is never torn
(every post-race load decodes; last-writer-wins on content).
core: Add HMAC-protected on-disk enrollment store
Adds the persistence layer for enrollments. Each enrolled user gets one
CBOR-encoded file under a configured root directory, wrapped in a fixed-
position header and HMAC-SHA256 so any out-of-band mutation surfaces as
IntegrityFailure on the next load.
# File format
offset size contents
0 4 magic "PARE" (ASCII, fixed)
4 1 version currently 1
5 3 reserved zero-padded
8 32 HMAC-SHA256 HMAC of bytes [40..] under the key
40 … payload CBOR-encoded `Enrollment`
The HMAC sits at a fixed offset *before* the payload deliberately:
verification is one constant-time slice compare with zero
deserialisation. Parsing untrusted CBOR ahead of integrity verification
would expose the daemon to whatever bugs a malicious file could
trigger in the deserialiser.
`verify_slice` is constant-time, which matters — a timing leak there
would let an attacker forge an HMAC byte by byte.
# Atomicity
`save` currently writes the target file directly; a crash mid-write
can leave a torn file. The atomic write-temp-then-rename path lands in
the next sub-milestone (M4.3) and won't change the format or HMAC
scheme.
# Username sanitisation
Usernames double as filenames, so they're validated against an
allowlist: ASCII alphanumerics plus `_`, `-`, `.`, no leading dot,
no `..`, max 64 bytes. Path traversal attempts surface as
InvalidUsername before the filesystem sees them.
# Tests
11 unit tests covering encode/decode round-trip, header validation
(magic, version, truncation), HMAC integrity (wrong key, payload
tampering, HMAC tampering), username validation (positive and negative
cases), and the EnrollmentStore filesystem operations (save+load,
NotEnrolled, list_users sorting, remove idempotency, on-disk tamper
detection).
3 property tests pin the headline guarantees:
- `decode(encode(e, k), k) == e` for arbitrary enrollments — the
format loses no information.
- Flipping any post-header byte triggers IntegrityFailure — what turns
'we have an HMAC' into 'we have integrity'.
- Cross-key decode always fails — keys are not interchangeable.
# Deps
serde + ciborium + hmac + sha2 join the workspace deps. All four are
pure Rust with no system requirements, so they don't need feature
gating; CI's --no-default-features build picks them up automatically.