-
-
Notifications
You must be signed in to change notification settings - Fork 68
Expand file tree
/
Copy pathtinytouch
More file actions
executable file
·1297 lines (1148 loc) · 51.7 KB
/
Copy pathtinytouch
File metadata and controls
executable file
·1297 lines (1148 loc) · 51.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env python3
"""tinyTouch pre-production setup and support utility."""
from __future__ import annotations
import argparse
import base64
import filecmp
import getpass
import glob
import os
import plistlib
import re
import secrets
import shlex
import shutil
import subprocess
import sys
import termios
import tempfile
import time
from pathlib import Path
FROZEN = bool(getattr(sys, "frozen", False))
ROOT = Path(sys.executable).resolve().parent if FROZEN else Path(__file__).resolve().parent
PIV_PROJECT = ROOT / "firmware" / "tiny_touch_smartcard"
HELPER = ROOT / "software" / "macos-helper" / "tinytouch_helper.py"
REQUIREMENTS = ROOT / "software" / "macos-helper" / "requirements.txt"
VENV = ROOT / ".venv"
LAUNCH_AGENT = Path.home() / "Library" / "LaunchAgents" / "com.tinytouch.helper.plist"
SUPPORT_DIR = Path.home() / "Library" / "Application Support" / "tinyTouch"
INSTALLED_SERVICE = SUPPORT_DIR / "tinytouch-service"
PAIRING_SERVICE = "tinyTouch-pairing"
PASSWORD_SERVICE = "tinyTouch"
ACCOUNT = "tinyTouch"
DEFAULT_DEVICE_ACCOUNT = "B8F862FB478C"
CLI_INSTALL_DIR = Path.home() / ".local" / "bin"
CLI_INSTALL_PATH = CLI_INSTALL_DIR / "tinytouch"
VERBOSE = False
FACTORY_FLASH_URL = "https://alpacaengineer.ing/tinytouch/batch-0/flash/"
class ToolError(RuntimeError):
pass
def say(message: str = "") -> None:
print(message, flush=True)
def show_startup_mark() -> None:
if not sys.stdout.isatty():
return
# Mechanically rasterized from https://alpacaengineer.ing/assets/alpaca.svg
# (source SHA-256 fe0152b54901fbb7f5baa54363e17d1ed0831d1883806aacc5aa305979522eda).
say("⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣰⣷⣼⣇⡀⠀")
say("⠀⠀⠀⠀⢀⣀⣀⡀⠀⠀⣿⣿⡟⠟⢡⡄")
say("⠀⠀⠀⣤⣿⣯⣽⢿⣤⠀⣿⣿⣿⡟⠋⠀")
say("⠀⣰⣟⡛⢛⡛⢛⣛⢛⣻⣿⣿⣿⡇⠀⠀")
say("⠸⣿⣿⣷⣿⣿⣾⣿⣾⣿⣿⣿⣿⡇⠀⠀")
say("⠀⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠃⠀⠀")
say("⠀⢸⣿⣿⣿⡿⠉⠉⣿⣿⡏⣿⠁⠀⠀⠀")
say("⠀⠀⢸⡇⣿⡇⠀⠀⢸⣿⢸⡇⠀⠀⠀⠀")
say(" tinyTouch")
say()
def verbose(message: str) -> None:
if VERBOSE:
say(f"[verbose] {message}")
def step(number: int, total: int, title: str) -> None:
say()
say(f"Step {number} of {total}: {title}")
def install_command_if_needed() -> None:
"""Install the frozen CLI on PATH without interrupting the requested command."""
if not FROZEN:
return
source = Path(sys.executable).resolve()
installed = False
if source != CLI_INSTALL_PATH.resolve() and (
not CLI_INSTALL_PATH.exists() or not filecmp.cmp(source, CLI_INSTALL_PATH, shallow=False)
):
CLI_INSTALL_DIR.mkdir(parents=True, exist_ok=True)
temporary = CLI_INSTALL_PATH.with_suffix(".tmp")
shutil.copy2(source, temporary)
temporary.chmod(0o755)
try:
os.removexattr(temporary, "com.apple.quarantine")
except (AttributeError, OSError):
pass
temporary.replace(CLI_INSTALL_PATH)
installed = True
path_added = False
profile = Path.home() / ".zprofile"
existing = profile.read_text(encoding="utf-8") if profile.exists() else ""
if ".local/bin" not in existing:
with profile.open("a", encoding="utf-8") as handle:
if existing and not existing.endswith("\n"):
handle.write("\n")
handle.write('export PATH="$HOME/.local/bin:$PATH" # tinyTouch\n')
path_added = True
local_bin = str(CLI_INSTALL_DIR)
if local_bin not in os.environ.get("PATH", "").split(os.pathsep):
os.environ["PATH"] = local_bin + os.pathsep + os.environ.get("PATH", "")
if installed or path_added:
say("Installed command: tinytouch")
if path_added:
say("Future Terminal windows can run it from anywhere.")
def ask(prompt: str, *, noninteractive_hint: str | None = None) -> str:
try:
return input(prompt)
except EOFError as exc:
hint = noninteractive_hint or (
"This step needs an answer, but Terminal input is unavailable. "
"Open Terminal and run the command there."
)
raise ToolError(hint) from exc
def human_device_error(line: str) -> str:
translations = {
"ERR STATUS sensor": (
"The tinyTouch firmware responded, but the fingerprint sensor did not. "
"Check sensor power and UART wiring: sensor TX to GPIO44 and sensor RX to GPIO43."
),
"ERR CONFIG_UNLOCK fingerprint": (
"The fingerprint was not recognized before the authorization window expired. "
"Lift your finger, run the command again, and touch with an enrolled finger."
),
"ERR CONFIG_UNLOCK sensor": (
"The firmware is running, but the fingerprint sensor cannot authorize configuration. "
"Check 3.3 V, ground, sensor TX to GPIO44, and sensor RX to GPIO43."
),
"ERR CONFIG_LOCKED run=CONFIG_UNLOCK": "Device configuration is locked. Authorize it with an enrolled fingerprint and try again.",
"ERR HID_KEY": (
"The ESP32-S3 could not store the HID pairing key in NVS. "
"Run 'tinytouch status --verbose'; factory-reset the device if NVS remains unavailable."
),
"ERR PROVISION_COMMIT": (
"The ESP32-S3 could not validate or save the generated PIV identity. "
"The previous identity was preserved. Run setup again with --verbose."
),
"ERR PROVISION_CHUNK": (
"The ESP32-S3 rejected part of the generated PIV identity before saving it. "
"The previous identity was preserved. Run setup again with --verbose."
),
"ERR FACTORY_RESET": (
"Factory reset did not complete. The enrolled fingerprint may not have authorized it, "
"or the fingerprint sensor/NVS did not respond. Run 'tinytouch status --verbose'."
),
"ERR DELETE_ALL": (
"The fingerprint sensor could not erase its enrolled fingerprints. "
"Check the sensor connection and run the command again."
),
"ERR PAIRING_MODE fingerprint": (
"Pairing was not authorized before the fingerprint window expired. "
"Run 'tinytouch pair' again and touch an enrolled finger when instructed."
),
"ERR UNKNOWN_COMMAND": (
"The connected firmware does not support the command sent by this CLI. "
f"Update the firmware at {FACTORY_FLASH_URL} and try again."
),
}
if line in translations:
return translations[line]
if line.startswith("ERR ENROLL"):
return (
"Fingerprint enrollment did not complete. Lift your finger fully, run the command "
"again, and follow each touch/lift instruction."
)
if line.startswith("ERR DELETE"):
return (
"The fingerprint sensor could not delete that slot. Confirm the slot is valid, "
"check the sensor connection, and try again."
)
if line.startswith("ERR MODE"):
return (
"The device could not switch modes. Configuration must be fingerprint-authorized, "
"and HID needs a pairing key before HID mode can be selected."
)
return f"The device reported an error: {line[4:] if line.startswith('ERR ') else line}"
def show_device_line(line: str) -> None:
verbose(f"device: {line}")
prompts = {
"PROMPT TOUCH": "Touch the fingerprint sensor now.",
"PROMPT LIFT": "Lift your finger from the sensor.",
"PROMPT TOUCH_AGAIN": "Place the same finger on the sensor again.",
}
if line in prompts:
say(f" → {prompts[line]}")
def run(
command: list[str], *, cwd: Path | None = None, capture: bool = False,
display: str | None = None,
) -> str:
verbose(f"command: {display if display is not None else ' '.join(command)}")
try:
result = subprocess.run(
command,
cwd=cwd,
check=True,
text=True,
stdout=subprocess.PIPE if capture else None,
stderr=subprocess.PIPE if capture else None,
)
except FileNotFoundError as exc:
raise ToolError(f"Required command not found: {command[0]}") from exc
except subprocess.CalledProcessError as exc:
detail = (exc.stderr or exc.stdout or "").strip()
raise ToolError(f"Command failed: {' '.join(command)}" + (f"\n{detail}" if detail else "")) from exc
return result.stdout.strip() if capture else ""
def require_macos() -> None:
if sys.platform != "darwin":
raise ToolError("This pre-production utility currently supports macOS only.")
def choose_mode(value: str | None) -> str:
if value:
return value
say("Choose how tinyTouch should authenticate on this Mac:")
say(" 1. PIV — macOS login and sudo using the native smart-card system")
say(" 2. HID — type this Mac account's password after a fingerprint match")
while True:
answer = ask(
"> ",
noninteractive_hint=(
"Mode selection needs Terminal input. Run 'tinytouch setup --mode piv' "
"or 'tinytouch setup --mode hid'."
),
).strip().lower()
if answer in {"1", "piv"}:
return "piv"
if answer in {"2", "hid"}:
return "hid"
say("Enter 1 or 2.")
def detect_ports() -> list[str]:
patterns = ("/dev/cu.usbmodem*", "/dev/cu.usbserial*")
return sorted({path for pattern in patterns for path in glob.glob(pattern)})
def choose_port(explicit: str | None, *, wait_seconds: int = 0) -> str:
if explicit:
return explicit
deadline = time.monotonic() + wait_seconds
while True:
ports = detect_ports()
verbose(f"detected serial ports: {', '.join(ports) if ports else 'none'}")
if len(ports) == 1:
return ports[0]
if len(ports) > 1:
say("Detected serial ports:")
for index, port in enumerate(ports, 1):
say(f" {index}. {port}")
answer = ask(
"> ",
noninteractive_hint=(
"Multiple devices were found. Run the command again with "
"--port /dev/cu.usbmodem..."
),
).strip()
try:
return ports[int(answer) - 1]
except (ValueError, IndexError):
raise ToolError("Invalid serial-port selection.")
if time.monotonic() >= deadline:
raise ToolError(
"No USB serial device was found. Connect tinyTouch with a USB data cable, "
"wait five seconds, and try again. If this is a blank ESP32-S3, flash the "
f"factory firmware first at {FACTORY_FLASH_URL}."
)
time.sleep(0.5)
def port_usb_location(port: str) -> str | None:
try:
import serial.tools.list_ports # type: ignore
except ImportError:
return None
item = next((item for item in serial.tools.list_ports.comports() if item.device == port), None)
return item.location if item else None
def port_is_download_mode(port: str) -> bool:
try:
import serial.tools.list_ports # type: ignore
except ImportError:
return False
item = next((item for item in serial.tools.list_ports.comports() if item.device == port), None)
return bool(item and item.vid == 0x303A and item.pid in {0x0009, 0x1001})
def serial_failure_message(port: str, error: BaseException) -> str:
detail = str(error)
verbose(f"serial failure on {port}: {type(error).__name__}: {detail}")
lowered = detail.lower()
error_number = getattr(error, "errno", None)
if error_number == 2 or "no such file" in lowered:
return (
f"The serial device {port} disappeared. The ESP32-S3 may have rebooted or the "
"USB cable was disconnected. Unplug tinyTouch, reconnect it, wait five seconds, "
"and run the command again."
)
if error_number in {6, 19} or "device not configured" in lowered:
return (
f"macOS lost {port} while the command was running. Unplug tinyTouch, reconnect it, "
"wait five seconds, and run the command again."
)
if error_number in {16} or any(word in lowered for word in ("busy", "already open")):
return (
f"The serial device {port} is busy. Close Arduino Serial Monitor, ESP-IDF monitor, "
"browser flashing tabs, and other tinyTouch commands, then try again."
)
return (
f"macOS could not communicate with {port}. Unplug tinyTouch, reconnect it with a USB "
"data cable, and try again. Run the command with --verbose for the technical detail."
)
def wait_for_download_port(
runtime_port: str, *, location: str | None = None, wait_seconds: int = 20
) -> str:
"""Wait for the same physical ESP to re-enumerate in ROM download mode."""
try:
import serial.tools.list_ports # type: ignore
except ImportError as exc:
raise ToolError("pyserial is required to follow the device into download mode.") from exc
deadline = time.monotonic() + wait_seconds
while time.monotonic() < deadline:
candidates = [
item
for item in serial.tools.list_ports.comports()
if item.vid == 0x303A and item.pid in {0x0009, 0x1001}
]
if location:
at_same_location = [item for item in candidates if item.location == location]
if at_same_location:
return at_same_location[0].device
if len(candidates) == 1:
return candidates[0].device
time.sleep(0.25)
raise ToolError(
"The ESP32-S3 did not appear in firmware download mode. Hold BOOT, tap RESET, "
"release BOOT, and try again. Also confirm that the USB cable carries data."
)
def wait_for_runtime_port(*, location: str | None = None, wait_seconds: int = 20) -> str:
try:
import serial.tools.list_ports # type: ignore
except ImportError as exc:
raise ToolError("pyserial is required to find the restarted tinyTouch.") from exc
deadline = time.monotonic() + wait_seconds
while time.monotonic() < deadline:
candidates = [
item
for item in serial.tools.list_ports.comports()
if item.vid == 0x303A and item.pid == 0x4001
]
if location:
at_same_location = [item for item in candidates if item.location == location]
if at_same_location:
return at_same_location[0].device
if len(candidates) == 1:
return candidates[0].device
time.sleep(0.25)
raise ToolError(
"Flashing finished, but the normal tinyTouch USB interface did not appear. Unplug "
"the board, reconnect it, wait five seconds, then run 'tinytouch status'."
)
def keychain_set(service: str, account: str, value: str) -> None:
run(
["security", "add-generic-password", "-U", "-a", account, "-s", service, "-w", value],
display=f"security add-generic-password -U -a {account} -s {service} -w [REDACTED]",
)
def keychain_delete(service: str, account: str) -> None:
subprocess.run(
["security", "delete-generic-password", "-a", account, "-s", service],
check=False,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
def keychain_exists(service: str, account: str) -> bool:
result = subprocess.run(
["security", "find-generic-password", "-a", account, "-s", service],
check=False,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
return result.returncode == 0
def pairing_account_for_port(port: str | None) -> str:
if not port:
return DEFAULT_DEVICE_ACCOUNT
try:
import serial.tools.list_ports # type: ignore
for item in serial.tools.list_ports.comports():
if item.device == port and item.serial_number:
identity = re.sub(r"[^A-Za-z0-9_.-]", "", item.serial_number).upper()
if identity:
return identity
except ImportError:
pass
return re.sub(r"[^A-Za-z0-9_.-]", "", Path(port).name).upper() or DEFAULT_DEVICE_ACCOUNT
def ensure_helper_environment() -> Path:
if FROZEN:
return Path(sys.executable)
python = VENV / "bin" / "python"
if not python.exists():
say("Creating helper environment...")
run([sys.executable, "-m", "venv", str(VENV)])
dependencies = subprocess.run(
[str(python), "-c", "import serial"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
if dependencies.returncode != 0:
run([str(python), "-m", "pip", "install", "-q", "-r", str(REQUIREMENTS)])
return python
def reexec_in_environment_if_needed() -> None:
if FROZEN:
return
if Path(sys.prefix).resolve() == VENV.resolve():
return
python = ensure_helper_environment()
os.execv(str(python), [str(python), str(Path(__file__).resolve()), *sys.argv[1:]])
def launch_agent_contents(python: Path) -> bytes:
arguments = [str(INSTALLED_SERVICE), "_helper"] if FROZEN else [str(python), str(HELPER)]
payload = {
"Label": "com.tinytouch.helper",
"ProgramArguments": arguments,
"RunAtLoad": True,
"KeepAlive": True,
"StandardOutPath": "/tmp/tinytouch-helper.log",
"StandardErrorPath": "/tmp/tinytouch-helper.err",
}
return plistlib.dumps(payload, sort_keys=False)
def unload_helper() -> bool:
if not LAUNCH_AGENT.exists():
return False
subprocess.run(["launchctl", "bootout", f"gui/{os.getuid()}", str(LAUNCH_AGENT)], check=False,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
return True
def remove_helper() -> None:
if unload_helper():
LAUNCH_AGENT.unlink(missing_ok=True)
def load_helper() -> None:
subprocess.run(["launchctl", "bootout", f"gui/{os.getuid()}", str(LAUNCH_AGENT)], check=False,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
run(["launchctl", "bootstrap", f"gui/{os.getuid()}", str(LAUNCH_AGENT)])
def install_helper(python: Path) -> None:
unload_helper()
if FROZEN:
SUPPORT_DIR.mkdir(parents=True, exist_ok=True)
temporary = INSTALLED_SERVICE.with_suffix(".tmp")
shutil.copy2(Path(sys.executable).resolve(), temporary)
temporary.chmod(0o755)
try:
os.removexattr(temporary, "com.apple.quarantine")
except (AttributeError, OSError):
pass
temporary.replace(INSTALLED_SERVICE)
LAUNCH_AGENT.parent.mkdir(parents=True, exist_ok=True)
LAUNCH_AGENT.write_bytes(launch_agent_contents(python))
load_helper()
def prompt_password() -> str:
say("HID mode needs the password it should type after a fingerprint match.")
say("It will be stored only in this Mac user's Keychain, separately for this device.")
try:
first = getpass.getpass("Enter this Mac account's password: ")
second = getpass.getpass("Enter it again to confirm: ")
except EOFError as exc:
raise ToolError(
"Password entry needs an interactive Terminal. Open Terminal and run setup again."
) from exc
if not first:
raise ToolError("Password cannot be empty.")
if first != second:
raise ToolError("Passwords did not match.")
return first
def generate_piv_bundle() -> dict[str, str]:
common_name = re.sub(r"[^A-Za-z0-9_. -]", "", getpass.getuser()) or "user"
openssl = Path("/usr/bin/openssl")
if not openssl.exists():
raise ToolError("macOS OpenSSL is unavailable, so PIV keys cannot be generated.")
with tempfile.TemporaryDirectory(prefix="tinytouch-piv-") as temporary_name:
temporary = Path(temporary_name)
outputs: dict[str, str] = {}
for slot, label in (("9a", "authentication"), ("9d", "key management")):
key = temporary / f"key-{slot}.pem"
cert = temporary / f"cert-{slot}.pem"
run(
[
str(openssl), "req", "-newkey", "rsa:2048", "-nodes",
"-keyout", str(key), "-x509", "-sha256", "-days", "3650",
"-out", str(cert), "-subj", f"/CN=tinyTouch {common_name} {label}/",
],
capture=True,
)
outputs[f"key_{slot}"] = key.read_text(encoding="utf-8")
outputs[f"cert_{slot}"] = cert.read_text(encoding="utf-8")
return outputs
def run_idf(arguments: list[str]) -> None:
if shutil.which("idf.py"):
run(["idf.py", *arguments], cwd=PIV_PROJECT)
return
candidates = [
Path(os.environ.get("IDF_PATH", "")) / "export.sh" if os.environ.get("IDF_PATH") else None,
Path.home() / "esp" / "esp-idf" / "export.sh",
]
export = next((path for path in candidates if path and path.exists()), None)
if export is None:
raise ToolError("idf.py is not available. Source ESP-IDF's export.sh or set IDF_PATH.")
shell_command = "source {} >/dev/null && idf.py {}".format(
shlex.quote(str(export)), " ".join(shlex.quote(item) for item in arguments)
)
run(["/bin/zsh", "-lc", shell_command], cwd=PIV_PROJECT, display=f"idf.py {' '.join(arguments)}")
def flash_piv(port: str) -> str:
run_idf(["set-target", "esp32s3"])
run_idf(["build"])
location = port_usb_location(port)
if not port_is_download_mode(port):
try:
fields = status_fields(port)
if fields.get("firmware") == "unified":
unlock_configuration(port)
except ToolError:
pass
try:
serial_command(port, "BOOTLOADER", timeout=3)
except ToolError:
# USB can detach before the final acknowledgment reaches the host.
pass
port = wait_for_download_port(port, location=location)
run_idf(["-p", port, "flash"])
return wait_for_runtime_port(location=location)
def serial_command(port: str, command: str, *, timeout: float = 20.0) -> list[str]:
try:
import serial # type: ignore
except ImportError as exc:
raise ToolError("pyserial is required. Run 'tinytouch setup' or install requirements.txt.") from exc
was_loaded = unload_helper()
lines: list[str] = []
try:
try:
with serial.Serial(port, 115200, timeout=0.25, write_timeout=2) as device:
time.sleep(0.4)
device.reset_input_buffer()
device.write((command + "\n").encode("ascii"))
device.flush()
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
raw = device.readline()
if not raw:
continue
line = raw.decode("utf-8", "replace").strip()
if not line:
continue
lines.append(line)
show_device_line(line)
if line.startswith(("OK ", "ERR ")) or line in {"PONG", "OK"}:
break
except (OSError, serial.SerialException, termios.error) as exc:
raise ToolError(serial_failure_message(port, exc)) from exc
finally:
if was_loaded:
load_helper()
if not lines:
if port_is_download_mode(port):
raise ToolError(
"The ESP32-S3 is in firmware download mode, so the tinyTouch application is not "
f"running. Flash it at {FACTORY_FLASH_URL}, then unplug and reconnect it."
)
raise ToolError(
"A serial device was found, but it did not answer the tinyTouch command within "
"the timeout. Confirm that this is the tinyTouch port, unplug and reconnect the "
"device, then run 'tinytouch status --verbose'."
)
if lines[-1].startswith("ERR "):
raise ToolError(human_device_error(lines[-1]))
return lines
def provision_piv_keys(port: str, bundle: dict[str, str]) -> str:
say("Creating this device's unique smart-card identity. This does not reflash firmware.")
serial_command(port, "PROVISION_BEGIN", timeout=3)
names = {
"cert_9a": "cert9a",
"key_9a": "key9a",
"cert_9d": "cert9d",
"key_9d": "key9d",
}
for source_name, device_name in names.items():
labels = {
"cert_9a": "authentication certificate",
"key_9a": "authentication private key",
"cert_9d": "key-management certificate",
"key_9d": "key-management private key",
}
say(f" • Storing {labels[source_name]}...")
encoded = base64.b64encode(bundle[source_name].encode("utf-8")).decode("ascii")
for offset in range(0, len(encoded), 480):
serial_command(
port,
f"PROVISION_CHUNK {device_name} {encoded[offset:offset + 480]}",
timeout=3,
)
serial_command(port, "PROVISION_COMMIT", timeout=5)
say(" ✓ Unique PIV identity stored on tinyTouch.")
location = port_usb_location(port)
try:
serial_command(port, "USB_RECONNECT", timeout=3)
except ToolError:
# A quick USB detach can race the final acknowledgment.
pass
# USB_RECONNECT is acknowledged before the USB detach. Do not rediscover the
# still-present old device node during that short interval.
time.sleep(1.0)
return wait_for_runtime_port(location=location)
def unlock_configuration(port: str) -> None:
say("Authorizing configuration changes.")
say("If fingerprints are already enrolled, touch the sensor when instructed.")
serial_command(port, "CONFIG_UNLOCK", timeout=15)
say(" ✓ Configuration authorized for two minutes.")
def provision_hid_key(port: str, key: bytes) -> None:
if len(key) != 32:
raise ToolError("The HID pairing key must be 32 bytes.")
serial_command(port, f"HID_KEY {key.hex()}", timeout=3)
say(" ✓ A unique HID pairing key is stored on tinyTouch.")
def pair_piv(requested_identity: str | None = None, port: str | None = None) -> None:
say("Waiting for macOS to discover tinyTouch's smart-card identity...")
output = ""
for _ in range(20):
try:
output = run(["sc_auth", "identities"], capture=True)
except ToolError:
output = ""
identities = []
for line in output.splitlines():
match = re.search(r"\b([0-9A-Fa-f]{40})\b\s*(.*)", line)
if match:
identities.append((match.group(1), match.group(2).strip()))
authentication_identities = [
item for item in identities if "authentication" in item[1].lower()
]
if authentication_identities:
identities = authentication_identities
identities = list(dict.fromkeys(identities))
hashes = [identity_hash for identity_hash, _ in identities]
if hashes:
if requested_identity:
matches = [value for value in hashes if value.lower() == requested_identity.lower()]
if not matches:
raise ToolError(
f"Identity {requested_identity} is not currently available.\n{output.strip()}"
)
identity = matches[0]
elif len(hashes) == 1:
identity = hashes[0]
else:
say("Multiple smart-card identities are available:")
for index, (value, label) in enumerate(identities, 1):
say(f" {index}. {value} {label}".rstrip())
choice = ask(
"Identity to pair [1]: ",
noninteractive_hint=(
"Multiple smart-card identities were found. Run 'tinytouch pair "
"--identity HASH' with the identity shown above."
),
).strip() or "1"
try:
identity = hashes[int(choice) - 1]
except (ValueError, IndexError):
raise ToolError("Invalid identity selection.") from None
say(f"Pairing with macOS account '{getpass.getuser()}'.")
if port:
say("Touch your enrolled finger when requested.")
serial_command(port, "PAIRING_MODE", timeout=15)
say()
say("macOS may show three prompts:")
say(" 1. Terminal Password — your normal Mac password")
say(" 2. Smart Card PIN — 000000 (six zeros)")
say(" 3. Keychain/System Password — your normal Mac password again")
try:
try:
run(["sudo", "sc_auth", "pair", "-u", getpass.getuser(), "-h", identity])
except ToolError as exc:
verbose(f"pairing failure: {exc}")
detail = str(exc)
if "6982" in detail:
raise ToolError(
"macOS pairing was not authorized in time. Run 'tinytouch pair' "
"again, touch an enrolled finger when instructed, and use 000000 "
"at the smart-card PIN prompt."
) from exc
if "CryptoTokenKit" in detail:
raise ToolError(
"macOS lost communication with the smart card. Unplug tinyTouch, "
"reconnect it, wait five seconds, and run 'tinytouch pair' again."
) from exc
raise ToolError(
"macOS could not pair the smart-card identity. Run the command again "
"with --verbose for diagnostic details."
) from exc
finally:
if port:
try:
serial_command(port, "PAIRING_MODE_OFF", timeout=3)
except ToolError:
pass
say(" ✓ This macOS account is paired with tinyTouch.")
return
time.sleep(0.5)
raise ToolError(
"macOS did not discover the smart-card identity. Unplug tinyTouch, reconnect it, "
"wait a few seconds, and run 'tinytouch pair'."
)
def install_factory_firmware(port: str) -> str:
if FROZEN:
raise ToolError(
"This ESP32-S3 does not have compatible unified firmware. Flash it at "
f"{FACTORY_FLASH_URL}, unplug and reconnect it, then run 'tinytouch setup' again."
)
ensure_helper_environment() # provides pyserial for configuration commands
say("Installing the unified factory firmware (development operation)...")
return flash_piv(port)
def status_fields(port: str) -> dict[str, str]:
lines = serial_command(port, "STATUS", timeout=3)
status_line = next((line for line in reversed(lines) if line.startswith("OK STATUS ")), "")
if not status_line:
raise ToolError(
"The device answered, but its STATUS response is not a recognized tinyTouch "
"response. It may be running unrelated or very old firmware."
)
fields = dict(re.findall(r"([A-Za-z_]+)=([^ ]+)", status_line))
if "mode" not in fields:
raise ToolError(
"The device returned an incomplete STATUS response without a runtime mode. "
"Reflash the unified firmware and try again."
)
return fields
def require_unified_firmware(status: dict[str, str]) -> None:
if status.get("firmware") == "unified":
return
if status.get("mode") in {"hid", "piv"}:
raise ToolError(
"Older tinyTouch firmware was detected. It cannot provision unique keys or switch "
f"modes at runtime. Flash the unified firmware at {FACTORY_FLASH_URL}, unplug and "
"reconnect the device, then run setup again."
)
raise ToolError(
"The connected serial device is not reporting itself as unified tinyTouch firmware. "
"If this is the correct ESP32-S3, flash the factory firmware and try again."
)
def require_fingerprint_sensor(status: dict[str, str]) -> None:
if status.get("sensor") == "ok":
return
raise ToolError(
"The unified tinyTouch firmware is running, but the fingerprint sensor did not respond.\n"
"Check the sensor connections:\n"
" 1. Sensor power is 3.3 V and ground is connected.\n"
" 2. Sensor TX connects to ESP32-S3 GPIO44.\n"
" 3. Sensor RX connects to ESP32-S3 GPIO43.\n"
"TOUCH_OUT on GPIO2 is not required for this status check."
)
def configure_hid_credentials(port: str, status: dict[str, str]) -> None:
python = ensure_helper_environment()
account = pairing_account_for_port(port)
device_has_key = status.get("hid_key") == "configured"
local_has_key = keychain_exists(PAIRING_SERVICE, account)
local_has_password = keychain_exists(PASSWORD_SERVICE, account)
if device_has_key and not local_has_key:
say("This tinyTouch already has an HID key, but this Mac does not have the matching key.")
answer = ask(
"Replace the device key and configure HID for this Mac? This disconnects any "
"previous HID helper. [y/N]: ",
noninteractive_hint="HID re-keying requires confirmation in an interactive Terminal.",
).strip().lower()
if answer not in {"y", "yes"}:
raise ToolError("HID setup cancelled; the existing device key was preserved.")
if not device_has_key or not local_has_key:
key = secrets.token_bytes(32)
provision_hid_key(port, key)
keychain_set(PAIRING_SERVICE, account, key.hex())
say(" ✓ Matching HID key stored in this Mac user's Keychain.")
if not local_has_password:
password = prompt_password()
keychain_set(PASSWORD_SERVICE, account, password)
del password
install_helper(python)
say(" ✓ Background HID service installed for this Mac user.")
def configure_unified_device(
args: argparse.Namespace, mode: str, port: str, status: dict[str, str],
*, first_step: int, total_steps: int,
) -> None:
require_unified_firmware(status)
require_fingerprint_sensor(status)
try:
fingerprint_count = int(status.get("fingerprints", "0"))
except ValueError:
fingerprint_count = 0
local_hid_ready = False
if mode == "hid":
account = pairing_account_for_port(port)
local_hid_ready = (
status.get("hid_key") == "configured"
and keychain_exists(PAIRING_SERVICE, account)
and keychain_exists(PASSWORD_SERVICE, account)
)
changes_needed = (
status.get("keys") != "nvs"
or status.get("mode") != mode
or (not args.skip_enroll and fingerprint_count == 0)
or (mode == "hid" and not local_hid_ready)
)
step(first_step, total_steps, "Secure this device")
if changes_needed:
unlock_configuration(port)
if status.get("keys") != "nvs":
say("Creating this device's unique keys...")
port = provision_piv_keys(port, generate_piv_bundle())
status = status_fields(port)
if status.get("keys") != "nvs":
raise ToolError("The device did not load its provisioned PIV keys.")
if mode == "hid":
configure_hid_credentials(port, status)
step(first_step + 1, total_steps, "Set up fingerprint")
if not args.skip_enroll:
if fingerprint_count > 0:
say(f" ✓ Preserving {fingerprint_count} enrolled fingerprint(s).")
else:
say("Enroll your first fingerprint.")
serial_command(port, "ENROLL 1", timeout=45)
say(" ✓ Fingerprint enrolled in slot 1.")
else:
say("Fingerprint enrollment was skipped as requested.")
step(first_step + 2, total_steps, f"Finish {mode.upper()} setup")
if status.get("mode") != mode:
serial_command(port, f"MODE {mode}", timeout=3)
say(f" ✓ {mode.upper()} mode enabled.")
if mode == "piv" and not args.no_pair:
remove_helper()
pair_piv(port=port)
elif mode == "piv":
say("macOS smart-card pairing was skipped as requested.")
say()
say(f"Setup complete — {mode.upper()} mode is ready.")
say("Unplug tinyTouch, reconnect it, then run: tinytouch test")
def command_setup(args: argparse.Namespace) -> None:
require_macos()
say("tinyTouch setup")
mode = choose_mode(args.mode)
step(1, 4, "Connect tinyTouch")
port = choose_port(args.port)
say(f"Found tinyTouch on {port}.")
if port_is_download_mode(port):
port = install_factory_firmware(port)
status = status_fields(port)
else:
try:
status = status_fields(port)
except ToolError as exc:
raise ToolError(
f"tinyTouch was found on {port}, but setup could not read its status.\n"
f"Reason: {exc}"
) from exc
if status.get("firmware") != "unified":
if FROZEN:
require_unified_firmware(status)
port = install_factory_firmware(port)
status = status_fields(port)
require_unified_firmware(status)
require_fingerprint_sensor(status)
configure_unified_device(args, mode, port, status, first_step=2, total_steps=4)
def command_mode(args: argparse.Namespace) -> None:
require_macos()
say(f"Switch tinyTouch to {args.mode.upper()} mode")
port = choose_port(args.port)
say(f"Found tinyTouch on {port}.")
status = status_fields(port)
require_unified_firmware(status)
require_fingerprint_sensor(status)
configure_unified_device(args, args.mode, port, status, first_step=1, total_steps=3)
def command_status(args: argparse.Namespace) -> None:
ports = detect_ports()
say("tinyTouch status")
say()
if not ports and not args.port:
say("Device: not detected")
say("Connect tinyTouch with a USB data cable, then run this command again.")
else:
port = choose_port(args.port)
say(f"Device: connected on {port}")
if port_is_download_mode(port):
say("Firmware: ESP32-S3 download mode; the tinyTouch application is not running")
say(f"Action: flash the unified firmware at {FACTORY_FLASH_URL}, then reconnect it.")
fields = None
else:
fields = None
try:
if not port_is_download_mode(port):
fields = status_fields(port)
except ToolError as exc:
say(f"Device check failed: {exc}")
if fields is not None:
if fields.get("firmware") == "unified":
firmware_label = "unified and ready"
elif fields.get("mode") in {"hid", "piv"}:
firmware_label = "older firmware; update required"
else:
firmware_label = "incompatible"
sensor_ready = fields.get("sensor") == "ok"
say(f"Firmware: {firmware_label}")
say(f"Current mode: {fields.get('mode', 'unknown').upper()}")
say(f"Fingerprint sensor: {'ready' if sensor_ready else 'not responding'}")
say(f"Enrolled fingerprints: {fields.get('fingerprints', 'unknown')}")
say(
"PIV identity: "