wifi: bringup unit now carries the union ordering (slpi/adsp for TZ MSA, rmtfs+tqftpserv for firmware serving — the 2026-07-20 boot proved the old load unit's ordering incomplete) and supersedes blueline-wifi-load; msa-release adopted into the repo (shutdown unload that keeps warm reboots clean). cellular: adopt blueline-clat service+script. clat script now pins a host route to the Fido MMS proxy via the CLAT — with wifi up the wlan default beat the clat default and MMS died off-carrier. mmsd config contract documented (MMS_APN must match the bearer APN ltemobile.apn; shipped as netsvcs which matches nothing). tasks: retire 06 qtpim (no longer the contacts path), archive 10 (done), index catches up on 11 (archived); 01 re-pointed at a contacts-store decision.
202 lines
10 KiB
Markdown
202 lines
10 KiB
Markdown
# TASK 12 — Face auth as a capability factor (Gaze is the reference, not the dep)
|
|
|
|
**Status:** open. Reference cloned to `~/Projects/gaze` (GunduLabs/gaze @
|
|
`c9c8ffb`, MIT, v0.2.6, 2026-07-18). This doc scopes the SouveraineOS-owned
|
|
face-auth factor modeled on it. We own the source — anything touching the
|
|
permission/capability gating is in-tree, not vendored.
|
|
|
|
## Goal
|
|
|
|
A first-class **face-auth factor** that plugs into the existing capability
|
|
tier model as a step-up / unlock credential, symmetric with fingerprint and
|
|
PIN. Gaze proves the pipeline is real and fully on-device; we build the
|
|
SouveraineOS-native equivalent against the primitives we already have
|
|
(`PamContext`, `StepUpAuth`, `LockContext`, `LockContentPolicy`), not a
|
|
wrapper around `gazed`.
|
|
|
|
Two placements, one mechanism:
|
|
|
|
1. **Unlock.** `session.unlock()` is already gated on `LockContext PIN/PAM`
|
|
(TRUST-BOUNDARY-MATRIX row). Face becomes a PAM factor behind that same
|
|
gate — a face mint unlocks, exactly as a fingerprint mint would.
|
|
2. **Step-up / in-place reveal.** The doctrine's open slot: `PamContext`
|
|
against `souveraine-stepup` "accepting fingerprint mints a short-lived
|
|
grant," and `LockContentPolicy.allowsOnLock()` gains its third condition
|
|
(`|| hasFreshGrant('read')`). Face is that same mint for a second factor
|
|
type — reveal message/notification bodies on the lock surface without
|
|
unlocking the session. (SESSION-AUTHORITY-DOCTRINE §2, currently "gated
|
|
on the fingerprint sensor coming online (not yet true on blueline)." Face
|
|
is unblocked by any working front camera; it does not wait on the FP
|
|
driver.)
|
|
|
|
Face auth is **not** a new trust mechanism. It is one more factor behind the
|
|
two existing gates. The doctrinal guarantees are unchanged: step-up never
|
|
unlocks the session, never accepts an agent-supplied boolean, the grant is
|
|
short-lived / in-memory / action-family-bound.
|
|
|
|
## Context — what Gaze does (the reference)
|
|
|
|
Cloned at `~/Projects/gaze`. Architecture worth copying, decisions worth
|
|
re-making:
|
|
|
|
- **Pipeline** (all on-device, no network): `Camera → SCRFD detect → align
|
|
→ ArcFace embed → match → MiniFASNet-V2 liveness`. Optional IR camera for
|
|
high-security spoof resistance.
|
|
- **Process split (the part to copy structurally):**
|
|
- `gazed` — system daemon, owns the camera and embeddings, exposes
|
|
`com.gundulabs.Gaze` on D-Bus. Stateful: `claim/release`,
|
|
`verify_start/stop`, `enroll_start/stop`, signals `face_status` /
|
|
`verify_status` carrying `CaptureStatus` (TooDark / etc.) and
|
|
`VerifyResult` (Match / NoMatch).
|
|
- `pam-gaze` — thin C-ABI PAM shim (`pam_sm_authenticate`).
|
|
- `pam-gaze-core` — talks to the daemon over zbus, returns
|
|
`Match / NoMatch / Unavailable`. The shim is dumb on purpose.
|
|
- **Tiered strictness** — 5 levels (`low/medium/high/maximum/custom`) swap
|
|
detector models (`det_500m.onnx` vs `det_10g.onnx`) and recognizer
|
|
(`w600k_mbf` vs `w600k_r50`) + liveness threshold. The model-swap-by-tier
|
|
idea maps cleanly onto our `ambient/personal/stepUp` tiers in reverse:
|
|
*higher trust decision → stricter pipeline*, not a single global knob.
|
|
- **Presence gates** — `abort_if_ssh` and `abort_if_lid_closed` refuse to
|
|
fire when no user is physically present. This is the right instinct and
|
|
aligns with our fail-closed lock policy: no camera / too dark / SSH
|
|
session → `Unavailable` → don't unlock, stay secure.
|
|
- **Storage (reference, not to copy verbatim):** `/var/lib/gaze/users`,
|
|
config `/etc/gaze/config.toml`. We will not inherit this layout — see
|
|
"What to build."
|
|
|
|
Files of record in the reference:
|
|
`gaze-core/src/{dbus.rs,config.rs,face.rs,detect.rs,camera.rs}`,
|
|
`pam-gaze/src/lib.rs:113` (`pam_sm_authenticate`),
|
|
`pam-gaze-core/src/lib.rs` (D-Bus auth flow, `AuthOutcome`).
|
|
|
|
## Context — what SouveraineOS already has
|
|
|
|
- `SESSION-AUTHORITY-DOCTRINE.md` §2-3 — `PamContext` is a standalone
|
|
connection type (not baked into `WlSessionLock`); step-up points a second
|
|
PAM context at `/etc/pam.d/souveraine-stepup`, accepts password or
|
|
fingerprint in any order, mints a short-lived grant bound to an action
|
|
family. Face slots in as a third accepted factor on the same context.
|
|
- `SESSION-TRUST-ARCHITECTURE.md` — capability tiers `ambient / personal /
|
|
stepUp`. `personal` gate is `!screenLocked && !screenLockSecure`. `stepUp`
|
|
requires a recent grant.
|
|
- `TRUST-BOUNDARY-MATRIX.md` — `session.unlock()` → stepUp, `LockContext
|
|
PIN/PAM`; `StepUpAuth.requestAuth()` → PAM conversation. Both are the
|
|
insertion points for a face factor.
|
|
- `02-lockscreen-rust-system.md` — the glance/lock surface where an
|
|
in-place reveal (face mint → `hasFreshGrant('read')`) would show
|
|
notification/message bodies without unlocking.
|
|
- `11-souveraine-secrets.md` — secrets store; face embeddings are a
|
|
biometric secret and belong in the same trust/at-rest discipline, not a
|
|
parallel `/var/lib` tree.
|
|
|
|
## What to build (full plan, not V1/V2)
|
|
|
|
One owned factor, end to end. No phased feature-stripping — design the
|
|
final shape, then implement.
|
|
|
|
1. **In-tree face daemon (the `gazed` equivalent), SouveraineOS-native.**
|
|
- Owns the camera, runs SCRFD/ArcFace/MiniFASNet, stores embeddings.
|
|
- NOT a D-Bus `com.gundulabs.Gaze` clone. Exposed over the existing
|
|
Souveraine IPC surface (same bus agents and sessiond use), with a
|
|
`verify_*` / `enroll_*` / status-signal shape derived from Gaze's
|
|
`CaptureStatus` + `VerifyResult` — because that enum pair is
|
|
well-thought-out and the `Unavailable` vs `NoMatch` distinction is
|
|
load-bearing for fail-closed behavior.
|
|
- Camera/tiling/liveness run as their own service the PAM path can
|
|
reach; keep the PAM-facing shim dumb (Gaze's split is right).
|
|
|
|
2. **PAM factor, not a PAM silo.** A face mint flows through the *existing*
|
|
`PamContext` → `souveraine-stepup` and the lock's `LockContext`, not a
|
|
new `pam-gaze` service with its own policy. Add face to the factor list
|
|
`/etc/pam.d/souveraine-stepup` already accepts. The unlock path
|
|
(`session.unlock()`) and the reveal path (`StepUpAuth` →
|
|
`LockContentPolicy.allowsOnLock()` third condition) both consume it
|
|
identically — one factor, two effects, by virtue of which context it
|
|
mints into. This is the doctrine's design; we're populating it, not
|
|
extending it.
|
|
|
|
3. **Tiered pipeline strictness.** Reuse Gaze's model-swap pattern but map
|
|
it onto our capability tiers at the *decision* side: a grant that will
|
|
unlock the session or satisfy `stepUp` runs the strict pipeline
|
|
(`det_10g` + IR-if-available + tight liveness); an ambient read-mint can
|
|
run the fast pipeline. The mapping is policy; the mechanism is two model
|
|
sets behind the same daemon.
|
|
|
|
4. **Presence / fail-closed gates (copy Gaze's instinct, wire to our
|
|
state).** Refuse to capture when:
|
|
- session is SSH/remote (our session authority already knows this),
|
|
- lid closed / no front camera available (device-state manager,
|
|
TASK-08),
|
|
- liveness fails or capture is `TooDark`.
|
|
Every one of these yields `Unavailable`, never `Match`. No unlock, no
|
|
reveal. This matches the suspend-before-lock fail-closed invariant.
|
|
|
|
5. **Enrollment + storage.** Enrollment UI in the shell settings app
|
|
(SETTINGS-APP-PLAN). Embeddings stored under the souveraine-secrets
|
|
discipline (TASK-11), at-rest protected, never logged, never transmitted.
|
|
No `/var/lib/gaze` parallel tree — biometric data lives where secrets
|
|
live.
|
|
|
|
6. **Audit.** Face success/failure/unavailable events hit the existing
|
|
`SessionAudit` append-only JSONL with hash chain (TRUST-BOUNDARY-MATRIX
|
|
row "Session audit trail"). A face unlock is a security-relevant event
|
|
and must be in the same trail as PIN/PAM unlocks — not a separate log.
|
|
|
|
7. **Models.** Ship the ONNX models Gaze uses (SCRFD detector, ArcFace
|
|
recognizer, MiniFASNet liveness) as packaged assets, same provenance
|
|
discipline as any other binary blob in the rootfs. Pin versions; do not
|
|
fetch at runtime.
|
|
|
|
## Acceptance
|
|
|
|
- Cold boot to unlock: a face match mints through `souveraine-stepup` /
|
|
lock `LockContext` and unlocks the session, indistinguishable at the
|
|
capability layer from a PIN or fingerprint unlock. Same audit row shape.
|
|
- On the lock surface (post-TASK-02 glance): a face mint reveals message /
|
|
notification bodies in place via `hasFreshGrant('read')` **without**
|
|
unlocking the session. Lock is still requested; compositor still secure.
|
|
- SSH session, lid closed, camera gone, too dark, or liveness fail →
|
|
`Unavailable` → no unlock, no reveal, no audit "success." Fall through to
|
|
PIN silently.
|
|
- Enrollment, model swap by tier, and embedding storage live entirely
|
|
in-tree under the secrets discipline. No `gazed` process, no
|
|
`/var/lib/gaze`, no GunduLabs D-Bus name on the bus.
|
|
|
|
## Do not
|
|
|
|
- **Do not vendor or wrap `gazed`.** It's a reference. We own the source
|
|
for anything in the permission-gating path. The PAM shim, the daemon, the
|
|
policy — all in-tree.
|
|
- **Do not make face a new trust mechanism.** No new tier, no new grant
|
|
type. It is a factor behind `PamContext` / `StepUpAuth`, exactly as
|
|
fingerprint is framed in the doctrine. The "two factors" framing is
|
|
explicitly rejected in SESSION-AUTHORITY-DOCTRINE — don't reintroduce it.
|
|
- **Do not gate personal content on face alone.** The reveal is
|
|
`hasFreshGrant('read')`; whether that grant came from face, fingerprint,
|
|
or PIN is irrelevant to `LockContentPolicy`. Keep the policy
|
|
factor-agnostic.
|
|
- **Do not special-case face in the audit trail.** Same JSONL, same hash
|
|
chain, factor recorded as a field — not a parallel log.
|
|
|
|
## Connects to
|
|
|
|
- SESSION-AUTHORITY-DOCTRINE §2-3 (the slot this fills — "gated on the
|
|
fingerprint sensor coming online"; face is unblocked by any camera).
|
|
- TRUST-BOUNDARY-MATRIX (`session.unlock`, `StepUpAuth.requestAuth`,
|
|
`Session audit trail` rows).
|
|
- TASK-02 lockscreen glance (in-place reveal surface).
|
|
- TASK-08 device-state manager (lid-closed / camera-present gate).
|
|
- TASK-11 souveraine-secrets (embedding at-rest).
|
|
- SETTINGS-APP-PLAN (enrollment UI).
|
|
- **Blocker, per-target:** laptop front camera is straightforward; Pixel 3
|
|
(blueline) front-camera bring-up is not yet solved — face auth is moot on
|
|
the phone until a userspace camera device exists for the daemon to grab.
|
|
Not a reason to scope down; a reason to land laptop-first and have the
|
|
phone light up when the camera does.
|
|
|
|
## Reference provenance
|
|
|
|
- Upstream: `https://github.com/GunduLabs/gaze`, commit `c9c8ffb`, MIT.
|
|
- Local clone: `~/Projects/gaze` (reference only; not a submodule, not a
|
|
build input).
|
|
- Cite file:line when borrowing a design decision (not code) from it.
|