at main
4 folders
4 files
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 property tests for SCRFD's pure stages
The SCRFD module's pure stages (NMS, letterbox preprocess, bilinear
resize, anchor decode) had hand-picked goldens but no property coverage.
Those are exactly the code paths where structural bugs silently degrade
detection quality without crashing — the kind property tests catch best.
# Coverage added
NMS (the largest gap):
- Output count is never greater than input count.
- Every pair of surviving detections has IoU ≤ threshold — the
postcondition NMS exists to enforce.
- The globally-best detection (highest confidence) always survives.
- Idempotency: running NMS on its own output produces the same set.
- Input-order independence: reversing the input order produces the same
surviving set.
letterbox_preprocess:
- Output tensor is always [1, 3, target, target] regardless of source
dimensions.
- det_scale is finite, positive, and produces source dimensions that
fit inside the target (within sub-pixel rounding).
bilinear_resize_rgb8:
- Output buffer length is always exactly dst_w · dst_h · 3.
- Solid colour preservation across arbitrary resize ratios (stronger
than the unit test which fixed one ratio).
decode_stride:
- Never panics on arbitrary tensor lengths or grid sizes. A malformed
model output should produce zero detections, never an index-out-of-
bounds panic that takes down the daemon.
# Performance
Letterbox proptests are bounded to 400×400 source dimensions because
each iteration runs a full bilinear resize. The 'output shape' and
'det_scale fit' properties are structural and don't gain coverage from
larger inputs; capping the bound keeps the test in single-digit seconds.
# Cost to CI
Zero: the proptests live inside the inference-feature-gated scrfd
module, so CI's --no-default-features build doesn't compile or run them.
The full-feature local suite picks them up via `just test-features`.
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,cli: Extract CLI decision logic into typed outcomes
We're committing to a CLI design where the project stays ready for
additional UI layers (GUI, TUI, RPC client) without requiring the CLI
to be untangled. The rule that falls out, recorded in CONTRIBUTING.md
§ UI architecture: decisions live in `pareidolia-core` as pure
functions returning structured outcomes; rendering lives in
`pareidolia-cli`. Each match arm in a subcommand should be either
'translate this variant to its display string' (rendering) or 'call
this core function and dispatch on its outcome' (still rendering at
the CLI layer, with the actual decision made one frame down). It
should not be 'compute the next state inline'.
This commit extracts four meaningful instances of inline decision
logic from CLI commands into typed outcomes in core, and documents
the pattern so future contributors don't have to re-derive it.
# core: `camera::select::IrCameraResolution` + `resolve_ir_camera`
The IR-camera resolution that lived inline in `pareidolia enroll`
(explicit flag wins, then opt-out, then auto-detect with three
sub-branches) becomes a five-variant enum returned by a pure
function over `&[CameraInfo]`.
Unit-testable without V4L2: ten tests cover each branch and the
precedence ordering (explicit > opt-out, opt-out > auto-detect,
multi-IR collapses to Ambiguous, defensive handling of a path-less
`CameraInfo`). A future GUI would call `resolve_ir_camera` with a
list it owns and render the same five variants as widgets. The
daemon will use the same function with a hardware snapshot pulled
per auth request.
`enroll.rs` now contains only a `match` over the five variants
that picks the appropriate stderr notice and extracts the resolved
path.
# core: `enrollment::AuthMode`
Two-variant enum (`Single { rgb_camera }` / `Dual { rgb_camera,
ir_camera }`) for the configured authentication flow. CLI builds
one via `AuthMode::from_optional_ir(rgb, ir_opt)`; the daemon will
build its own from config + per-user persisted paths in M5. Both
will then call `authenticate` or `authenticate_dual` based on the
variant.
Replaces the inline `match &args.ir_camera` dispatch in
`pareidolia test`. The decision and the camera-path data now live
in one place instead of being threaded through args throughout the
flow.
# core: `enrollment::EnrollmentReadiness` + `from_load_result`
Three-variant classification of a `store.load(user)` result:
`NotEnrolled` (no enrollment file), `Empty` (file exists, zero
samples — distinct because the user *was* enrolled at some point),
`Ready(Enrollment)`.
`from_load_result` folds the `StoreError::NotEnrolled` variant
into a non-error `NotEnrolled` readiness state since 'no enrollment
yet' is normal flow control, not a failure. Genuine I/O / decode
errors stay as `Err`.
`pareidolia test` now calls this exactly once at the top of the
flow; the daemon (M5) will call it on every PAM auth request as
the canonical 'should we open the camera or short-circuit?' check.
Four unit tests cover the three readiness states plus error
propagation.
# core: `enrollment::EnrollmentSessionSummary`
Per-session counts (`new_rgb`, `new_ir`, `total_rgb`, `total_ir`,
`user`) returned by the enroll loop for rendering. CLI picks
between two text shapes based on `summary.had_ir()`; a GUI will
populate widgets from the same struct.
A small extraction, included alongside the larger three for two
reasons: it demonstrates the value-struct flavour of the pattern
(not every extraction needs to be an enum), and it cleans up the
last loose `if new_ir > 0` decision-vs-render mix in the enroll
flow.
# Documentation
CONTRIBUTING.md gains a `## UI architecture` section between
'Testing philosophy' and 'Security' that captures the convention,
points at `IrCameraResolution` as the canonical worked example,
lists what already follows the pattern and what doesn't yet, and
records the constraints we will *not* break (no interactive
prompts, no spinners/colour/TUI, no `init` wizards) — those would
make the CLI hostile to scripting and impossible for a non-terminal
UI to reuse anyway.
core: Add pluggable liveness framework (noisy-OR, additive)
Introduces liveness / presentation-attack detection as a confidence axis
orthogonal to identity matching, structured so it works on any IR camera
and treats a strobed illuminator as an additive bonus rather than a
prerequisite.
# Shape
- `LivenessCheck` trait: each check inspects a `LivenessInput` (the full
captured burst, the chosen lit frame's index, and an optional dark
frame index for strobed sensors) and returns `CheckOutcome` — either
an `Applicable(score)` in 0..=1 or `NotApplicable`. A check that needs
hardware it didn't get (e.g. differencing on a constant sensor)
returns `NotApplicable`, never a low score, so adding it can't break
unsupported hardware.
- `LivenessPolicy` runs all checks and combines applicable scores via
noisy-OR (`1 - ∏(1 - sᵢ)`), producing a `LivenessAssessment` with the
combined score, the applicable count, and a per-check breakdown for
logging.
# Why noisy-OR, not a weighted average
Two properties the security model needs, both proptested:
1. No inflation: one applicable check of score `s` combines to exactly
`s`. A weighted average over *applicable* checks would renormalize a
lone weak signal up to full-range confidence, erasing the difference
between a constant sensor (few checks) and a strobed one (more). With
noisy-OR a `NotApplicable` check simply isn't in the product.
2. Monotonic: more applicable evidence never lowers the score, so a
strobed sensor's extra check can only *raise* its ceiling. Strobing
being more secure is thus structural, not a tuning artifact.
# No-op by default
An empty policy assesses `combined = 0.0`; `passes(threshold)` with the
default `threshold = 0.0` passes everything — exactly today's behavior,
liveness effectively off until checks are registered and a threshold is
chosen. A positive threshold with zero applicable checks fails closed.
Pure and total (clamps out-of-range / non-finite scores); no hardware,
no models. Real checks and the auth-path wiring land in follow-up
commits.
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.