tau-guard 0.4.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tau_guard-0.4.0/.gitignore +29 -0
- tau_guard-0.4.0/LICENSE +21 -0
- tau_guard-0.4.0/PKG-INFO +330 -0
- tau_guard-0.4.0/README.md +296 -0
- tau_guard-0.4.0/pyproject.toml +51 -0
- tau_guard-0.4.0/src/tau_guard/__init__.py +72 -0
- tau_guard-0.4.0/src/tau_guard/audit.py +236 -0
- tau_guard-0.4.0/src/tau_guard/camera.py +358 -0
- tau_guard-0.4.0/src/tau_guard/cli.py +302 -0
- tau_guard-0.4.0/src/tau_guard/cli_face.py +984 -0
- tau_guard-0.4.0/src/tau_guard/cli_window.py +125 -0
- tau_guard-0.4.0/src/tau_guard/config.py +212 -0
- tau_guard-0.4.0/src/tau_guard/engine.py +544 -0
- tau_guard-0.4.0/src/tau_guard/enroll.py +274 -0
- tau_guard-0.4.0/src/tau_guard/failures.py +77 -0
- tau_guard-0.4.0/src/tau_guard/liveness.py +442 -0
- tau_guard-0.4.0/src/tau_guard/local.py +231 -0
- tau_guard-0.4.0/src/tau_guard/localauth.py +307 -0
- tau_guard-0.4.0/src/tau_guard/models/LICENSE-yunet +21 -0
- tau_guard-0.4.0/src/tau_guard/models/face_detection_yunet_2023mar.onnx +0 -0
- tau_guard-0.4.0/src/tau_guard/overlay.py +1600 -0
- tau_guard-0.4.0/src/tau_guard/presence.py +90 -0
- tau_guard-0.4.0/src/tau_guard/routes.py +166 -0
- tau_guard-0.4.0/src/tau_guard/runner.py +462 -0
- tau_guard-0.4.0/src/tau_guard/service.py +1099 -0
- tau_guard-0.4.0/src/tau_guard/state.py +1155 -0
- tau_guard-0.4.0/src/tau_guard/store.py +501 -0
- tau_guard-0.4.0/src/tau_guard/strings.py +736 -0
- tau_guard-0.4.0/src/tau_guard/testing.py +715 -0
- tau_guard-0.4.0/src/tau_guard/verify.py +614 -0
- tau_guard-0.4.0/src/tau_guard/window.py +821 -0
- tau_guard-0.4.0/src/tau_guard/worker.py +1190 -0
- tau_guard-0.4.0/tests/conftest.py +39 -0
- tau_guard-0.4.0/tests/guard_helpers.py +181 -0
- tau_guard-0.4.0/tests/hub_process.py +46 -0
- tau_guard-0.4.0/tests/test_audit.py +162 -0
- tau_guard-0.4.0/tests/test_camera.py +332 -0
- tau_guard-0.4.0/tests/test_cli.py +223 -0
- tau_guard-0.4.0/tests/test_cli_face.py +758 -0
- tau_guard-0.4.0/tests/test_cli_window.py +184 -0
- tau_guard-0.4.0/tests/test_end_to_end.py +197 -0
- tau_guard-0.4.0/tests/test_engine.py +278 -0
- tau_guard-0.4.0/tests/test_enroll.py +192 -0
- tau_guard-0.4.0/tests/test_integration.py +655 -0
- tau_guard-0.4.0/tests/test_liveness.py +572 -0
- tau_guard-0.4.0/tests/test_localauth.py +354 -0
- tau_guard-0.4.0/tests/test_models.py +265 -0
- tau_guard-0.4.0/tests/test_package.py +166 -0
- tau_guard-0.4.0/tests/test_presence_config.py +179 -0
- tau_guard-0.4.0/tests/test_restart.py +57 -0
- tau_guard-0.4.0/tests/test_service.py +633 -0
- tau_guard-0.4.0/tests/test_state.py +892 -0
- tau_guard-0.4.0/tests/test_store.py +385 -0
- tau_guard-0.4.0/tests/test_touch_id_hub.py +766 -0
- tau_guard-0.4.0/tests/test_touch_id_worker.py +637 -0
- tau_guard-0.4.0/tests/test_verify.py +513 -0
- tau_guard-0.4.0/tests/test_window.py +851 -0
- tau_guard-0.4.0/tests/test_worker.py +440 -0
- tau_guard-0.4.0/tests/test_worker_ops.py +681 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
.DS_Store
|
|
2
|
+
.env
|
|
3
|
+
.env.*
|
|
4
|
+
!.env.example
|
|
5
|
+
*.local.env
|
|
6
|
+
__pycache__/
|
|
7
|
+
*.py[cod]
|
|
8
|
+
|
|
9
|
+
# Python / uv
|
|
10
|
+
.venv/
|
|
11
|
+
.pytest_cache/
|
|
12
|
+
.ruff_cache/
|
|
13
|
+
dist/
|
|
14
|
+
build/
|
|
15
|
+
*.egg-info/
|
|
16
|
+
|
|
17
|
+
# Personal facts about the user stay local (persona/user.example.md is the tracked template)
|
|
18
|
+
persona/user.md
|
|
19
|
+
# The public note for other taus (ADR 0007); persona/net.example.md is the tracked template
|
|
20
|
+
persona/net.md
|
|
21
|
+
# Sub-tau definitions are the owner's files (ADR 0012); packages/tau-sub/examples/ has an example
|
|
22
|
+
persona/subs/
|
|
23
|
+
|
|
24
|
+
# Tools tau wrote itself, waiting for approval
|
|
25
|
+
tools/_pending/*
|
|
26
|
+
!tools/_pending/.gitkeep
|
|
27
|
+
|
|
28
|
+
# MkDocs build output
|
|
29
|
+
site/
|
tau_guard-0.4.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 fport
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
tau_guard-0.4.0/PKG-INFO
ADDED
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: tau-guard
|
|
3
|
+
Version: 0.4.0
|
|
4
|
+
Summary: The owner guard for tau: the hub's guard service with reflex locks, a worker subprocess per check, protected /guard routes, an audit log and the tau guard command.
|
|
5
|
+
Project-URL: Homepage, https://github.com/fport/tau
|
|
6
|
+
Project-URL: Documentation, https://docs.tau.getporti.com
|
|
7
|
+
Project-URL: Issues, https://github.com/fport/tau/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/fport/tau/releases
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: agent,assistant,face,guard,macos,presence,tau
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Operating System :: MacOS
|
|
17
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Topic :: Home Automation
|
|
21
|
+
Classifier: Topic :: Security
|
|
22
|
+
Requires-Python: >=3.12
|
|
23
|
+
Requires-Dist: click>=8.1
|
|
24
|
+
Requires-Dist: cryptography>=44
|
|
25
|
+
Requires-Dist: keyring>=25; sys_platform == 'darwin'
|
|
26
|
+
Requires-Dist: numpy>=2
|
|
27
|
+
Requires-Dist: opencv-python-headless<5,>=4.14
|
|
28
|
+
Requires-Dist: pyobjc-framework-avfoundation>=12; sys_platform == 'darwin'
|
|
29
|
+
Requires-Dist: pyobjc-framework-cocoa>=12; sys_platform == 'darwin'
|
|
30
|
+
Requires-Dist: pyobjc-framework-localauthentication>=12; sys_platform == 'darwin'
|
|
31
|
+
Requires-Dist: pyobjc-framework-quartz>=12; sys_platform == 'darwin'
|
|
32
|
+
Requires-Dist: tau-core>=0.4.0
|
|
33
|
+
Description-Content-Type: text/markdown
|
|
34
|
+
|
|
35
|
+
# tau-guard
|
|
36
|
+
|
|
37
|
+
The owner guard for tau: tau's guarded surfaces (`tau tui`, `tau chat`, tau Desktop) work only
|
|
38
|
+
while the owner is verified at the Mac. The decision and its threat model are in
|
|
39
|
+
[ADR 0008](../../docs/adr/0008-owner-guard.md).
|
|
40
|
+
|
|
41
|
+
tau-guard is its own distribution (`tau-guard`, import `tau_guard`). It uses only the public API
|
|
42
|
+
of `tau-core`, whose `tau_core.guard` owns the `Guard` protocol, the reason lists and the
|
|
43
|
+
`/guard/*` route contract, and plugs in through two entry points:
|
|
44
|
+
|
|
45
|
+
| group | name | what |
|
|
46
|
+
|---|---|---|
|
|
47
|
+
| `tau.hub_services` | `guard` | the guard service inside `tau hub run` |
|
|
48
|
+
| `tau.commands` | `guard` | `tau guard setup`, `enroll`, `verify`, `forget`, `doctor`, `status`, `lock`, `log` and `window` |
|
|
49
|
+
|
|
50
|
+
Channels never import this package: they talk to the hub through tau-core's `HubGuard`, so no
|
|
51
|
+
camera library ever loads in a channel process.
|
|
52
|
+
|
|
53
|
+
## What it does
|
|
54
|
+
|
|
55
|
+
- **Inert until switched on.** Without the marker `TAU_DATA_DIR/guard/enrolled` and without
|
|
56
|
+
`[component.guard] enabled = true`, the service only reports `guard: {state: "off"}` in
|
|
57
|
+
`/status`. It starts no thread, adds no route and imports neither OpenCV nor pyobjc.
|
|
58
|
+
- **Locked at every start.** An unlock lives in memory only; a hub start, even after
|
|
59
|
+
`kill -9`, is `locked` with trigger `startup`.
|
|
60
|
+
- **One check at a time, in a worker.** Each unlock, presence recheck or step-up runs in its own
|
|
61
|
+
short-lived subprocess (`python -P -m tau_guard.worker`, run in `TAU_DATA_DIR/guard/`).
|
|
62
|
+
The hub kills it on cancel, lock, timeout or hub stop (SIGTERM, then SIGKILL after 1 s). Its stderr goes to
|
|
63
|
+
`TAU_DATA_DIR/guard/worker.log`, never to a terminal. Concurrent unlocks share one attempt;
|
|
64
|
+
a step-up waits behind a running check.
|
|
65
|
+
- **Reflex locks without an LLM or the cloud.** The service locks on the longest unlock time,
|
|
66
|
+
on HID idle, when the screen locks and after the Mac slept. It checks these at every admission
|
|
67
|
+
and every verification, again when a worker's verdict arrives and when a client reads it,
|
|
68
|
+
and a reflex loop checks them every 2 s. A lock that comes while a check runs cancels it, so
|
|
69
|
+
its late match counts for nothing. If the loop stops ticking, admission refuses. No check
|
|
70
|
+
starts while the screen is locked.
|
|
71
|
+
- **Presence rechecks.** After a few minutes of idle since the last check (a face match, or
|
|
72
|
+
Touch ID in a session unlocked with it), or after `presence_recheck_minutes`, admission
|
|
73
|
+
answers `recheck`, and the channel runs a quick passive check (Touch ID in a Touch ID
|
|
74
|
+
session). A non-matching face locks, and so does a failed step-up or re-unlock: any check that
|
|
75
|
+
sees someone else while tau is unlocked.
|
|
76
|
+
- **Cooldown.** Three failed matches (`no_match`, `spoof`, `multiple_faces`) within 10 minutes
|
|
77
|
+
start a 30 s cooldown. The cooldown doubles with each further failure, up to 15 minutes.
|
|
78
|
+
The count and the cooldown survive a hub restart (`TAU_DATA_DIR/guard/failures.json`, 0600).
|
|
79
|
+
- **An audit log.** `TAU_DATA_DIR/guard/audit.jsonl` (0600, in a 0700 directory) records every
|
|
80
|
+
check, lock, unlock and cooldown. It never holds an embedding, a frame or anything about
|
|
81
|
+
another person. It is rotated at 1 MiB, with 3 files kept. The hub log gets no verdicts and
|
|
82
|
+
no scores.
|
|
83
|
+
|
|
84
|
+
## Touch ID
|
|
85
|
+
|
|
86
|
+
Touch ID is the guard's second factor at the Mac (issue 114). It runs in the worker like the
|
|
87
|
+
camera, through LocalAuthentication, so its sheet appears on the Mac's screen, and like the
|
|
88
|
+
face check it can only narrow: it never produces an approval.
|
|
89
|
+
|
|
90
|
+
- **Two policies.** *Touch ID only* (LocalAuthentication's biometrics policy): no "Use
|
|
91
|
+
Password…" button, a reuse duration of 0, and the enrolled fingerprint set pinned: its
|
|
92
|
+
`domainState` (as a sha256) is stored in the encrypted template at enrollment, and a
|
|
93
|
+
different set (someone who knows the Mac password added a finger) is refused with
|
|
94
|
+
`touch_id_failed`, before the prompt and again after it. *Touch ID or the password* (the
|
|
95
|
+
device-owner policy, which macOS also lets a paired Apple Watch answer) has no pin: whoever
|
|
96
|
+
could change the fingerprints knows the password.
|
|
97
|
+
- **Every prompt ends.** Each `LAContext` is used once and invalidated after 30 s, after an
|
|
98
|
+
answer and at once when the attempt is cancelled.
|
|
99
|
+
- **Unavailable.** A closed lid (the built-in sensor is off), a Mac without a sensor or with no
|
|
100
|
+
enrolled finger, a disconnected Touch ID keyboard, a lockout after failed tries or a policy
|
|
101
|
+
that cannot be evaluated give `touch_id_unavailable` for Touch ID only.
|
|
102
|
+
- **Enrollment and forget.** The first enrollment (decided from the Keychain item, never from
|
|
103
|
+
the template file) uses Touch ID or the password. Re-enrollment, `enroll --add` and `forget`
|
|
104
|
+
use Touch ID only with the pin when the Mac has Touch ID, refuse while it cannot be used (open
|
|
105
|
+
the lid), and use the password only on a Mac without Touch ID. An enrollment pins the
|
|
106
|
+
fingerprints whenever Touch ID is available, and a pinned enrollment is only ever authorised
|
|
107
|
+
by Touch ID with its pin: removing every finger refuses (`touch_id_failed`) instead of
|
|
108
|
+
bringing the password back. A template that cannot be read refuses Touch ID only too, since
|
|
109
|
+
the pin cannot be compared (a Mac with no Touch ID sensor keeps the password).
|
|
110
|
+
- **Unlock fallback** (`unlock_fallback`, default `touch_id`). After two failed face unlocks
|
|
111
|
+
within 10 minutes, or a verdict that says the face check cannot work (`camera_*`, or
|
|
112
|
+
`unavailable` / `model_changed` for a missing or changed face model), the locked status
|
|
113
|
+
carries `fallback`, and a channel may ask for it (`POST /guard/verify {..., fallback: "touch_id"}`,
|
|
114
|
+
`Guard.unlock(method="touch_id")`). `touch_id` is Touch ID only; `touch_id_or_password` takes
|
|
115
|
+
the password (or an approval from a paired Apple Watch, which macOS's device-owner policy
|
|
116
|
+
also accepts) too, the opt-in for a closed lid. The stricter of the hub's and the client's
|
|
117
|
+
`unlock_fallback` applies: a client that says `touch_id` gets Touch ID only from a hub that
|
|
118
|
+
says `touch_id_or_password`, and one that says `none` gets no offer at all (`HubGuard`
|
|
119
|
+
narrows the status the same way). The hub does not probe Touch ID for the offer: on a Mac
|
|
120
|
+
without Touch ID the default still offers it, the attempt answers `touch_id_unavailable`,
|
|
121
|
+
and the doctor warns about the setting. A pass unlocks with `method = "touch_id"`
|
|
122
|
+
(status and audit), ends a face cooldown and clears the offer; a Touch ID failure never
|
|
123
|
+
counts toward the face cooldown. Touch ID has its own limit: after five prompts that did
|
|
124
|
+
not pass (a wrong finger, a cancel, a timeout) within 10 minutes, a new Touch ID unlock or
|
|
125
|
+
recheck is refused with `cooldown` until the oldest is 10 minutes old. A Touch ID unlock is
|
|
126
|
+
presence but not a face: rechecks follow it (and are Touch ID checks in a Touch ID session,
|
|
127
|
+
so a covered camera does not fail every later turn), while `face_fresh` (for voice) still
|
|
128
|
+
needs a face.
|
|
129
|
+
- **Step-up** (`step_up`, default `face`). `face`: the head-turn challenge (or a passive match
|
|
130
|
+
in the freshness window). `face_or_touch_id`: Touch ID only when the face check could not
|
|
131
|
+
work (`too_dark`, `no_face`, `timeout`, `face_too_small`, `permission_pending`, a
|
|
132
|
+
`camera_*` verdict, or a missing or changed face model: the pin is read with the model the
|
|
133
|
+
enrollment was made with); a counted failure never falls back. `touch_id`: Touch ID only, the
|
|
134
|
+
camera never opens (the accessible choice for an owner who cannot turn their head). `none`:
|
|
135
|
+
no fresh check, only when both the hub's and the client's `tau.toml` say `none`; a client
|
|
136
|
+
whose `tau.toml` leaves `step_up` out uses `face` (the doctor warns about `none`). A step-up
|
|
137
|
+
or a recheck whose Touch ID prompt refused the finger or found another fingerprint set
|
|
138
|
+
locks tau, like a face that does not match; a cancelled prompt does not.
|
|
139
|
+
- **Audit.** Each prompt writes a `touch_id` record (`policy`, the outcome as `reason`, the
|
|
140
|
+
attempt's kind as `trigger`), and checks and unlocks carry `method`.
|
|
141
|
+
|
|
142
|
+
If the fingerprints changed since the enrollment (or the enrollment cannot be read),
|
|
143
|
+
`tau guard doctor` says so and how to start over: remove the template key (`security delete-generic-password -s tau-guard -a
|
|
144
|
+
template-key-v1`), then run `tau guard setup` at the Mac.
|
|
145
|
+
|
|
146
|
+
## The face check
|
|
147
|
+
|
|
148
|
+
Everything runs in the worker, on this Mac; frames exist only in the worker's memory and are
|
|
149
|
+
never saved.
|
|
150
|
+
|
|
151
|
+
- **Camera.** Only the built-in camera: the AVFoundation device of type
|
|
152
|
+
`AVCaptureDeviceTypeBuiltInWideAngleCamera` on transport `bltn`. Continuity Camera and
|
|
153
|
+
virtual cameras (OBS) are never picked, because keeping them out is the only defence against
|
|
154
|
+
injected frames. The OpenCV index is the device's place in the uniqueID-sorted list of video
|
|
155
|
+
and muxed devices, the order OpenCV's AVFoundation backend uses; the list is read again right
|
|
156
|
+
after the open, and a change aborts with `camera_unavailable`. The worker checks the camera
|
|
157
|
+
permission itself (`permission_pending` asks macOS, `camera_denied`), drops 5 warm-up frames,
|
|
158
|
+
keeps the camera on for at most 20 s and releases it on every path, a kill included.
|
|
159
|
+
- **Models.** YuNet 2023mar finds faces and five landmarks. SFace 2021dec turns a face into an
|
|
160
|
+
L2-normalised 128-d embedding, compared with the template by cosine similarity. Both files
|
|
161
|
+
are sha256-checked at every load. Only `opencv-python-headless` and `numpy` are needed.
|
|
162
|
+
- **Quality gates.** A frame counts when the detector score is at least 0.90, the eye distance
|
|
163
|
+
at least 40 px (hint `face_too_small`), the brightness at least 60 (hint `too_dark`) and the
|
|
164
|
+
sharpness at least 30. A second face counts only when its eye distance is at least 45 % of
|
|
165
|
+
the owner's and at least 40 px, so a photo on the wall does not block; a counted one aborts
|
|
166
|
+
with `multiple_faces`.
|
|
167
|
+
- **Match.** At least 3 of the last 5 quality frames reach the threshold: `unlock_threshold`
|
|
168
|
+
0.50 (never below 0.45), `step_up_threshold` 0.55 for step-ups and presence rechecks.
|
|
169
|
+
- **Head-turn challenge.** After a frontal match, 2 random steps for an unlock and 3 for a
|
|
170
|
+
step-up. The head's yaw comes from `cv2.solvePnP`, which fits YuNet's five landmarks to a
|
|
171
|
+
generic 3D head (degrees, relative to the frontal frames of the attempt): turn left or right
|
|
172
|
+
by at least `max(12°, half your calibrated turn)`, hold for a random 0.3-0.6 s, come back
|
|
173
|
+
within 6° (a swing past the centre on the way back is fine). Each prompt follows a random
|
|
174
|
+
0.3-0.9 s pause and has 4 s. The check fails as `spoof`, naming the rule in the verdict and
|
|
175
|
+
the audit log: 8° the wrong way before the turn is held (`overshoot_before_target`), more
|
|
176
|
+
than 3 reversals within one step (`oscillation`), more than 10° of movement while no prompt
|
|
177
|
+
is up (`moved_between_prompts`; up to 20° on the far side of a return swing), and a step not
|
|
178
|
+
done in time (`no_turn`, `too_slow`, `no_return`). A flat photo turned about the vertical
|
|
179
|
+
axis reads a few degrees at most, and turned further its landmarks stop fitting a head, so
|
|
180
|
+
those frames count for nothing. Exactly one face stays in view (box IoU above 0.3 from frame
|
|
181
|
+
to frame, never gone for more than 0.2 s: `face_lost`), the frames near the centre between
|
|
182
|
+
the turns must match you too (`step_identity`), and a second frontal match closes the check.
|
|
183
|
+
Cancelling or locking while a turn prompt is up counts like a failed step (`abandoned`). A
|
|
184
|
+
prompt is shown only when its step can end before the camera's 20 s limit. A presence
|
|
185
|
+
recheck is a passive strict match, and so is a step-up within
|
|
186
|
+
`step_up_challenge_fresh_seconds` of a passed challenge.
|
|
187
|
+
- **Enrollment.** Guided, 15-20 s at most: look, turn left, turn right, up, down, with live
|
|
188
|
+
hints. 10-20 samples, none closer than 0.97 to another, each at least 0.55 to the mean of the
|
|
189
|
+
others (leave-one-out); the template is their renormalised mean, stored with the turn
|
|
190
|
+
calibration in degrees (an enrollment from before degrees keeps working with a 12° turn each
|
|
191
|
+
way; `tau guard enroll` measures your own range). It always runs behind LocalAuthentication (Touch ID or the Mac password, asked
|
|
192
|
+
only once the camera permission is granted), is refused over SSH, and runs through the hub
|
|
193
|
+
when a hub with an active guard runs (`POST /guard/enroll`), else in a local worker. On a
|
|
194
|
+
first `tau guard setup` the hub's guard is not active yet, so the app that runs the hub asks
|
|
195
|
+
for its own camera permission at the first unlock.
|
|
196
|
+
- **Storage.** `TAU_DATA_DIR/guard/owner.face.v1.bin` (0600, atomic) is
|
|
197
|
+
`b"TAUG1" | nonce | AES-GCM(payload)` with the model's sha256 as associated data; the 256-bit
|
|
198
|
+
key is in the macOS Keychain (service `tau-guard`, account `template-key-v1`; always keyring's
|
|
199
|
+
macOS backend, whatever `PYTHON_KEYRING_BACKEND` says) and is read only in the worker, with a
|
|
200
|
+
timeout. A copy of the data dir or a backup cannot decrypt it. The public marker
|
|
201
|
+
`guard/enrolled` holds `{v, enrollment, model, created}` and nothing biometric; a template
|
|
202
|
+
file must carry the marker's enrollment id. The template is read again for every check, so
|
|
203
|
+
`tau guard forget` works at once.
|
|
204
|
+
|
|
205
|
+
**Honest limits.** Without an infrared camera, a 2D webcam check is a presence and convenience
|
|
206
|
+
factor. It keeps a housemate out of tau's own surfaces and stops low-effort photo and screen
|
|
207
|
+
attacks. It does not stop a real-time deepfake, a determined attacker with a good video, or
|
|
208
|
+
anyone with a shell in your account: a synthetic looping clip of natural head turns passes a
|
|
209
|
+
few percent of the unlock challenges (see issue 112). That is why it is only ever required in
|
|
210
|
+
addition to the human approval.
|
|
211
|
+
|
|
212
|
+
### Models and credits
|
|
213
|
+
|
|
214
|
+
| Model | License | How it arrives |
|
|
215
|
+
|---|---|---|
|
|
216
|
+
| YuNet 2023mar (`face_detection_yunet_2023mar.onnx`, sha256 `8f2383e4...52fa4`) | MIT, © 2020 Shiqi Yu (`tau_guard/models/LICENSE-yunet`) | in the wheel |
|
|
217
|
+
| SFace 2021dec (`face_recognition_sface_2021dec.onnx`, sha256 `0ba9fbfa...34e79`) | Apache-2.0 (OpenCV Zoo) | `tau guard setup` downloads it from opencv_zoo at commit `47534e2`, or `--model-file PATH` |
|
|
218
|
+
|
|
219
|
+
## The guard window
|
|
220
|
+
|
|
221
|
+
While a check runs, the worker that holds the camera covers the main screen (below the menu
|
|
222
|
+
bar) with a window drawn in the tau brand: the mirrored picture inside the neon τ ring, the
|
|
223
|
+
step as a large triangle and an uppercase line (`TURN LEFT`), the countdown as a comet on the
|
|
224
|
+
ring, the live hint, the purpose, then the result for 1.2 s (`tau_guard/overlay.py`, pyobjc
|
|
225
|
+
AppKit imported lazily). Frames never leave the worker; the verifier gets every frame and the
|
|
226
|
+
window at most 20 a second (`tau_guard.window.WatchedSource`). During a Touch ID prompt the
|
|
227
|
+
window is a small panel at the top, so the macOS prompt stays uncovered. Esc (or a click on
|
|
228
|
+
the chip) writes `{"type": "cancel"}`, and the hub cancels the attempt through its usual path.
|
|
229
|
+
|
|
230
|
+
The hub and the CLI ask for it with a `window` object in the request (the lines already in
|
|
231
|
+
the owner's UI language: the worker never imports tau-core), from `[component.guard] window`
|
|
232
|
+
and, for the CLI's local checks, `--no-window`. Not on macOS, over SSH, without a GUI session,
|
|
233
|
+
without pyobjc's AppKit or with `TAU_GUARD_WINDOW=0` the worker runs headless and logs why.
|
|
234
|
+
`tau guard window --demo` plays a scripted check in it without the camera. The seam is
|
|
235
|
+
`tau_guard.window.GuardView`; `tau_guard.testing.RecordingView` records its calls in tests.
|
|
236
|
+
|
|
237
|
+
## Routes
|
|
238
|
+
|
|
239
|
+
All routes are protected control routes. `ControlApi` checks each request's signature and
|
|
240
|
+
signs each answer. No route accepts a verdict, an image or an embedding: a route can only ask
|
|
241
|
+
the hub to look.
|
|
242
|
+
|
|
243
|
+
| Route | Body or query | Answer |
|
|
244
|
+
|---|---|---|
|
|
245
|
+
| `GET /guard/status` | `?since=<seq>&wait=<0..25>[&attempt=<id>]` | every `GuardStatus` field (`fallback` too) plus `enrollment`, `screen`, `step_up_active` and `attempt` |
|
|
246
|
+
| `POST /guard/admit` | `{channel, session_id, client_id}` | `{allowed, reason, enrollment, state}` |
|
|
247
|
+
| `POST /guard/verify` | `{channel, client_id, kind, request_id?, tool?, policy?, fallback?}` | `202 {attempt, seq}`, or `409`/`429 {reason, retry_after?}` |
|
|
248
|
+
| `POST /guard/cancel` | `{client_id, attempt?}` | `{ok}` |
|
|
249
|
+
| `POST /guard/lock` | `{channel, client_id, trigger}` | `{state: "locked"}` |
|
|
250
|
+
| `POST /guard/enroll` | `{client_id, add?}` | `202 {attempt, seq}`; LocalAuthentication first |
|
|
251
|
+
|
|
252
|
+
A body with an unknown key or a wrong type gets `400 {reason: "error"}`. The open `/status`
|
|
253
|
+
gains only `guard: {state, enrolled, available, reason}`.
|
|
254
|
+
|
|
255
|
+
Inside the hub, `GuardService` is a `tau_core.Guard`: find it with `hub.find_service("guard")`
|
|
256
|
+
plus an `isinstance(service, Guard)` check. It also offers `subscribe(callback)` for state
|
|
257
|
+
changes and `face_fresh(seconds)`.
|
|
258
|
+
|
|
259
|
+
## Configuration
|
|
260
|
+
|
|
261
|
+
Every key is optional. A value out of range or of the wrong type makes the guard `unavailable`,
|
|
262
|
+
with the reason in the log. The guard never falls back to a weaker value.
|
|
263
|
+
|
|
264
|
+
```toml
|
|
265
|
+
[component.guard]
|
|
266
|
+
enabled = true # or the enrollment marker
|
|
267
|
+
max_unlock_hours = 8 # 1-24, on the wall clock
|
|
268
|
+
idle_lock_minutes = 10 # 1-240; also the cap while the screen state is unknown
|
|
269
|
+
recheck_after_idle_minutes = 3 # 1-240
|
|
270
|
+
presence_recheck_minutes = 30 # 1-1440
|
|
271
|
+
verify_timeout_seconds = 15 # 5-60 for the frontal match; the camera stops at 20 s anyway
|
|
272
|
+
unlock_threshold = 0.50 # 0.45-0.95
|
|
273
|
+
step_up_threshold = 0.55 # 0.45-0.95; step-ups and presence rechecks
|
|
274
|
+
step_up_challenge_fresh_seconds = 120 # 0-600; a step-up this soon after a challenge is passive
|
|
275
|
+
unlock_liveness = "challenge" # or "none" (the doctor warns: a photo could unlock)
|
|
276
|
+
step_up = "face" # face | face_or_touch_id | touch_id | none (both configs)
|
|
277
|
+
unlock_fallback = "touch_id" # touch_id | touch_id_or_password | none (both configs)
|
|
278
|
+
camera = "builtin" # or a camera's AVFoundation uniqueID
|
|
279
|
+
window = true # the guard window during a check (display only)
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
A client sends its own `step_up`, `unlock_liveness` and `unlock_fallback` values with every
|
|
283
|
+
check (the defaults, `face`, `challenge` and `touch_id`, for a key its `tau.toml` leaves out),
|
|
284
|
+
and the hub applies the stricter of its own value and the client's; it reads a key missing from
|
|
285
|
+
a client's request as that default, never as agreement. The hub's own channels follow the hub's
|
|
286
|
+
`tau.toml` alone.
|
|
287
|
+
|
|
288
|
+
## Commands
|
|
289
|
+
|
|
290
|
+
```sh
|
|
291
|
+
tau guard setup [--model-file PATH] # model, enrollment, one check, [component.guard] enabled = true
|
|
292
|
+
tau guard enroll [--add] # enroll again, or add samples under other light
|
|
293
|
+
tau guard verify [--explain[=all]] # one check; --explain highlights each prompt and prints
|
|
294
|
+
# every 3rd frame's numbers (=all: every frame)
|
|
295
|
+
# (setup, enroll, verify: --no-window for their own checks)
|
|
296
|
+
tau guard window [--demo] # can the guard window open here; --demo plays a scripted check
|
|
297
|
+
tau guard forget # remove template, marker and key; the guard is off
|
|
298
|
+
# (exit 1 if the Keychain kept the key; run it again)
|
|
299
|
+
tau guard doctor # everything the guard depends on, Touch ID included
|
|
300
|
+
tau guard status [--json] # the state as the running hub reports it
|
|
301
|
+
tau guard lock # lock every guarded channel now
|
|
302
|
+
tau guard log [--since 2h] [--json] # the audit log
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
`setup`, `enroll` and `forget` run only at the Mac (macOS, no `SSH_CONNECTION`). After
|
|
306
|
+
`setup`, restart the hub and any open `tau chat`, `tau tui` or tau Desktop window. While you
|
|
307
|
+
are away from the Mac, remote `tau tui` and `tau chat` and `tau ask` stay locked (until issue
|
|
308
|
+
117).
|
|
309
|
+
|
|
310
|
+
## Tests
|
|
311
|
+
|
|
312
|
+
`tau_guard.testing` has `FakeClock`, `FakePresence`, a fake worker
|
|
313
|
+
(`fake_worker_command(script)`) that speaks the worker protocol from a JSON script, and fakes
|
|
314
|
+
for the face check (and `RecordingView`, a guard window that records its calls): synthetic YuNet rows and embeddings (`face_row`, `identity`, `variant`), a
|
|
315
|
+
`FakeEngine`, a `FakeFrameSource`, a `SimulatedUser` who follows the prompts (with switches for
|
|
316
|
+
every way an attempt must fail), `FakeLocalAuth` (Touch ID's state and scripted answers,
|
|
317
|
+
`FINGERPRINTS` and `OTHER_FINGERPRINTS`) and `fake_deps` for running the worker's operations
|
|
318
|
+
in-process. `MacLocalAuth` is tested against a fake `LocalAuthentication` module. No test opens a camera, a window, a microphone, the Keychain or a Touch ID prompt,
|
|
319
|
+
and every macOS call sits behind a protocol and is imported lazily, so the suite runs on Linux.
|
|
320
|
+
|
|
321
|
+
The real-model tests are opt-in: they fetch SFace and a few public-domain portraits by pinned
|
|
322
|
+
URL and sha256 into a cache outside the repo (`TAU_GUARD_TEST_CACHE`, default
|
|
323
|
+
`~/.cache/tau-guard-tests`; `TAU_GUARD_SFACE=PATH` installs a local SFace file instead). No
|
|
324
|
+
face image is ever committed.
|
|
325
|
+
|
|
326
|
+
```sh
|
|
327
|
+
uv run pytest -q packages/tau-guard
|
|
328
|
+
uv run pytest -m models packages/tau-guard # opt-in, real models
|
|
329
|
+
uv build --package tau-guard
|
|
330
|
+
```
|