diff --git a/README.md b/README.md index 50b99c54..334b9187 100644 --- a/README.md +++ b/README.md @@ -264,6 +264,10 @@ the same Mac; guest SSH authentication is still required. ### Touch ID for sudo +Guest clock recovery handles time lost during Mac sleep so fresh signed +approvals remain usable after wake. Existing VMs need the +[guest clock recovery installer](docs/guest-clock-recovery.md). + The native authentication bridge can enroll this Mac and use Touch ID as a sufficient authentication method for guest `sudo`. Open **Omarchy Menu → Setup → Security → Touch ID for sudo**, or run: diff --git a/docs/guest-clock-recovery.md b/docs/guest-clock-recovery.md new file mode 100644 index 00000000..98b6dd06 --- /dev/null +++ b/docs/guest-clock-recovery.md @@ -0,0 +1,62 @@ +# Guest clock recovery after Mac sleep + +A host sleep can pause guest execution without advancing Linux's system clock by +all the elapsed wall time. The virtual PL031 hardware clock can remain correct +while `timedatectl` still reports `NTPSynchronized=yes` from an earlier successful +network synchronization. Signed Touch ID approvals then appear to come from the +future and are correctly rejected. Network timers and other applications can +also be affected. + +The guest clock recovery timer checks every ten seconds of guest runtime, with +one second of timer accuracy allowance. On a normally scheduled guest this gives +recovery shortly after wake; it is not an instantaneous host wake notification. +The service also runs shortly after guest boot. + +The helper only acts with the Try Omarchy kernel marker and the expected PL031 +RTC. It reads the RTC through sysfs and advances system time only when it is more +than five seconds behind. Whole-second RTC precision and slow samples are +accounted for; samples taking over two seconds are rejected. It never writes to +the RTC or moves system time backward. Small offsets and backward corrections +remain the responsibility of network time synchronization. Following a forward +step, it requests a restart of the already-running time-sync service. + +This relies on the VM's virtual hardware clock tracking host time. It does not +establish an independent trusted time source if the Mac clock is wrong. No +network service, authentication approval, or guest application supplies the +correction. The helper runs as root with only `CAP_SYS_TIME` retained. Signed +approval verification and expiry windows are unchanged. + +## Existing guests + +App updates do not install new services inside an existing VM. Copy this checkout +into the guest and run: + +```sh +sudo guest/scripts/install-clock-recovery.sh +``` + +If clock drift is already preventing Touch ID sudo approval, use the guest +password fallback for this installation. The installer immediately runs recovery, +enables the timer for later boots, and retains replaced files under +`/var/lib/try-omarchy/clock-recovery-backup.*`. It does not modify PAM or enrollment. + +Check the result with: + +```sh +date +timedatectl show -p TimeUSec -p RTCTimeUSec -p NTPSynchronized +systemctl status try-omarchy-clock-recovery.timer +journalctl -u try-omarchy-clock-recovery.service -b +``` + +After a real Mac sleep/wake cycle, compare system time with the hardware clock +again, allow a timer interval, and test Touch ID authentication. A successful +fingerprint read alone does not prove the guest accepted the signed approval. + +To disable only this recovery mechanism: + +```sh +sudo systemctl disable --now try-omarchy-clock-recovery.timer +``` + +Network time synchronization remains enabled. diff --git a/guest/native-overlay/usr/lib/systemd/system/try-omarchy-clock-recovery.service b/guest/native-overlay/usr/lib/systemd/system/try-omarchy-clock-recovery.service new file mode 100644 index 00000000..1dccf5eb --- /dev/null +++ b/guest/native-overlay/usr/lib/systemd/system/try-omarchy-clock-recovery.service @@ -0,0 +1,16 @@ +[Unit] +Description=Recover Try Omarchy guest time after host sleep +ConditionKernelCommandLine=omarchy.qemu_virgl=1 +ConditionPathExists=/sys/class/rtc/rtc0/since_epoch +After=systemd-timesyncd.service + +[Service] +Type=oneshot +ExecStart=/usr/local/lib/try-omarchy/guest-clock-recover +TimeoutStartSec=10 +NoNewPrivileges=yes +CapabilityBoundingSet=CAP_SYS_TIME +ProtectSystem=strict +ProtectHome=yes +PrivateTmp=yes +RestrictAddressFamilies=AF_UNIX diff --git a/guest/native-overlay/usr/lib/systemd/system/try-omarchy-clock-recovery.timer b/guest/native-overlay/usr/lib/systemd/system/try-omarchy-clock-recovery.timer new file mode 100644 index 00000000..e06bf742 --- /dev/null +++ b/guest/native-overlay/usr/lib/systemd/system/try-omarchy-clock-recovery.timer @@ -0,0 +1,11 @@ +[Unit] +Description=Check Try Omarchy guest clock for host sleep drift + +[Timer] +OnBootSec=5s +OnUnitInactiveSec=10s +AccuracySec=1s +Unit=try-omarchy-clock-recovery.service + +[Install] +WantedBy=timers.target diff --git a/guest/native-overlay/usr/local/lib/try-omarchy/guest-clock-recover b/guest/native-overlay/usr/local/lib/try-omarchy/guest-clock-recover new file mode 100755 index 00000000..ebfd34bb --- /dev/null +++ b/guest/native-overlay/usr/local/lib/try-omarchy/guest-clock-recover @@ -0,0 +1,52 @@ +#!/usr/bin/python3 -I +"""Recover guest wall-clock time lost while the Mac paused QEMU.""" + +from pathlib import Path +import os +import subprocess +import sys +import time + +RTC = Path("/sys/class/rtc/rtc0") +MAX_SAMPLE_SECONDS = 2 +FORWARD_GAP_SECONDS = 5 + + +def recover(): + if os.geteuid() != 0: + raise PermissionError("clock recovery requires root") + if "omarchy.qemu_virgl=1" not in Path("/proc/cmdline").read_text().split(): + return False + if not (RTC / "name").read_text().startswith("rtc-pl031 "): + return False + + start = time.monotonic() + rtc_seconds = int((RTC / "since_epoch").read_text().strip()) + wall_seconds = time.time() + elapsed = time.monotonic() - start + if rtc_seconds <= 0 or not 0 <= elapsed <= MAX_SAMPLE_SECONDS: + raise ValueError("unusable virtual RTC sample") + gap = rtc_seconds - wall_seconds + if gap <= FORWARD_GAP_SECONDS: + return False + + # The RTC has whole-second precision. Never write the lagging system time + # back to it; it is the independent host-time reference across a VM pause. + time.clock_settime(time.CLOCK_REALTIME, rtc_seconds + elapsed) + print(f"Recovered guest clock forward by approximately {gap:.0f} seconds.", flush=True) + subprocess.run(["/usr/bin/systemctl", "--no-block", "try-restart", + "systemd-timesyncd.service"], check=True, timeout=5) + return True + + +def main(): + try: + recover() + return 0 + except (OSError, ValueError, subprocess.SubprocessError) as error: + print(f"Guest clock recovery failed: {error}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/guest/scripts/finalize-rootfs.sh b/guest/scripts/finalize-rootfs.sh index 013beae2..3fc9af43 100755 --- a/guest/scripts/finalize-rootfs.sh +++ b/guest/scripts/finalize-rootfs.sh @@ -24,6 +24,7 @@ passwd --lock root >/dev/null systemctl enable NetworkManager.service systemctl enable systemd-resolved.service systemctl enable systemd-timesyncd.service +systemctl enable try-omarchy-clock-recovery.timer # Avoid a systemctl introspection path that crashes under some ARM container # runtimes after it has already written the link. diff --git a/guest/scripts/install-clock-recovery.sh b/guest/scripts/install-clock-recovery.sh new file mode 100755 index 00000000..72c517ca --- /dev/null +++ b/guest/scripts/install-clock-recovery.sh @@ -0,0 +1,28 @@ +#!/bin/bash +set -euo pipefail + +(( $# == 0 )) || { echo 'Usage: sudo guest/scripts/install-clock-recovery.sh' >&2; exit 64; } +(( EUID == 0 )) || { echo 'Run this installer with sudo.' >&2; exit 1; } +source_dir=$(cd "$(dirname "$0")/../native-overlay" && pwd) +helper=/usr/local/lib/try-omarchy/guest-clock-recover +service=/usr/lib/systemd/system/try-omarchy-clock-recovery.service +timer=/usr/lib/systemd/system/try-omarchy-clock-recovery.timer +for file in "$helper" "$service" "$timer"; do + [[ -f $source_dir$file && ! -L $source_dir$file && ! -L $file ]] || { + echo "Unsafe or missing installation path: $file" >&2 + exit 1 + } +done +install -d -m 0755 /var/lib/try-omarchy /usr/local/lib/try-omarchy +backup=$(mktemp -d /var/lib/try-omarchy/clock-recovery-backup.XXXXXXXX) +for file in "$helper" "$service" "$timer"; do + if [[ -f $file ]]; then cp -p "$file" "$backup/$(basename "$file")"; fi +done +install -o root -g root -m 0755 "$source_dir$helper" "$helper" +install -o root -g root -m 0644 "$source_dir$service" "$service" +install -o root -g root -m 0644 "$source_dir$timer" "$timer" +systemctl daemon-reload +systemctl enable --now systemd-timesyncd.service +systemctl start try-omarchy-clock-recovery.service +systemctl enable --now try-omarchy-clock-recovery.timer +printf 'Guest clock recovery enabled. Previous files retained in %s\n' "$backup" diff --git a/guest/tests/test_guest_clock_recovery.py b/guest/tests/test_guest_clock_recovery.py new file mode 100644 index 00000000..4e2d3c91 --- /dev/null +++ b/guest/tests/test_guest_clock_recovery.py @@ -0,0 +1,81 @@ +from importlib.machinery import SourceFileLoader +from pathlib import Path +import unittest +from unittest import mock + +HELPER = Path(__file__).resolve().parents[1] / 'native-overlay/usr/local/lib/try-omarchy/guest-clock-recover' +clock = SourceFileLoader('guest_clock_recover', str(HELPER)).load_module() + + +class ClockRecoveryTests(unittest.TestCase): + def setUp(self): + self.files = {'/proc/cmdline': 'root=/dev/vda omarchy.qemu_virgl=1', + '/sys/class/rtc/rtc0/name': 'rtc-pl031 9010000.pl031\n', + '/sys/class/rtc/rtc0/since_epoch': '1789044748\n'} + def patch(target, **kwargs): + p = mock.patch(target, **kwargs) + result = p.start() + self.addCleanup(p.stop) + return result + patch('os.geteuid', return_value=0) + patch('pathlib.Path.read_text', autospec=True, + side_effect=lambda p: self.files[str(p)]) + self.wall = patch('time.time', return_value=1789044748) + self.monotonic = patch('time.monotonic', side_effect=[100, 100.1]) + self.step = patch('time.clock_settime', create=True) + self.restart = patch('subprocess.run') + if not hasattr(clock.time, 'CLOCK_REALTIME'): + p = mock.patch.object(clock.time, 'CLOCK_REALTIME', 0, create=True) + p.start(); self.addCleanup(p.stop) + + def test_overnight_pause_recovers_even_without_network_time(self): + self.wall.return_value -= 7 * 3600 + 22 * 60 + self.assertTrue(clock.recover()) + self.step.assert_called_once_with(clock.time.CLOCK_REALTIME, 1789044748.1) + self.restart.assert_called_once_with( + ['/usr/bin/systemctl', '--no-block', 'try-restart', 'systemd-timesyncd.service'], + check=True, timeout=5) + + def test_small_drift_and_backward_corrections_are_left_to_ntp(self): + for offset in [-3600, -1, 0, 1, 5]: + with self.subTest(offset=offset): + self.wall.return_value = 1789044748 - offset + self.monotonic.side_effect = [100, 100.1] + self.assertFalse(clock.recover()) + self.step.assert_not_called() + self.restart.assert_not_called() + + def test_wrong_guest_and_wrong_rtc_do_not_change_time(self): + for path, value in [('/proc/cmdline', 'root=/dev/vda'), + ('/sys/class/rtc/rtc0/name', 'unrelated-clock')]: + with self.subTest(path=path), mock.patch.dict(self.files, {path: value}): + self.assertFalse(clock.recover()) + self.step.assert_not_called() + + def test_slow_sample_is_rejected_instead_of_applying_stale_time(self): + self.wall.return_value -= 3600 + self.monotonic.side_effect = [100, 104] + with self.assertRaises(ValueError): + clock.recover() + self.step.assert_not_called() + + def test_bad_rtc_data_does_not_change_time(self): + self.files['/sys/class/rtc/rtc0/since_epoch'] = 'invalid' + with self.assertRaises(ValueError): + clock.recover() + self.step.assert_not_called() + + def test_failed_clock_set_does_not_report_success_or_restart_ntp(self): + self.wall.return_value -= 3600 + self.step.side_effect = PermissionError('clock denied') + self.assertEqual(clock.main(), 1) + self.restart.assert_not_called() + + def test_unprivileged_invocation_is_rejected(self): + with mock.patch('os.geteuid', return_value=1000): + self.assertEqual(clock.main(), 1) + self.step.assert_not_called() + + +if __name__ == '__main__': + unittest.main()