Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
62 changes: 62 additions & 0 deletions docs/guest-clock-recovery.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
52 changes: 52 additions & 0 deletions guest/native-overlay/usr/local/lib/try-omarchy/guest-clock-recover
Original file line number Diff line number Diff line change
@@ -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())
1 change: 1 addition & 0 deletions guest/scripts/finalize-rootfs.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
28 changes: 28 additions & 0 deletions guest/scripts/install-clock-recovery.sh
Original file line number Diff line number Diff line change
@@ -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"
81 changes: 81 additions & 0 deletions guest/tests/test_guest_clock_recovery.py
Original file line number Diff line number Diff line change
@@ -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()
Loading