GrapheneOS's auto reboot feature for Linux laptops

Featured image

TLDR: I patched i3lock to update a file when I unlock my laptop. I wrote a tool monitoring this file. If it is not modified for a specific time, the daemon executes a kill switch command. As my system does not support hibernation, I implemented my own solution using cryptsetup luksSuspend. The source code can be found on Github.


On my laptop I wanted to have two security features:

hibernate with linux-hardened

When you suspend your system, the RAM keeps its power and its content. When you hibernate, the content of the RAM gets dumped into your (encrypted) swap file. To resume from hibernate on a system using full disk encryption, you have to enter your full disk encryption password first. That’s a nice protection from cold boot attacks. But, for security reasons hibernation is not available on Arch Linux when used with the linux-hardened kernel (Arch Linux Issue, blog post).

kmille@spring:~ systemctl hibernate
Call to Hibernate failed: Sleep verb 'hibernate' is not configured or configuration is not supported by kernel

Hibernate is nice because you can remove the full disk encryption key from memory and keep the state of the system. If your system does not support hibernate, you can build something similar with cryptsetup luksSuspend and cryptsetup luksResume. Let’s think about this simple script:

cryptsetup luksSuspend root
echo mem > /sys/power/state
cryptsetup luksResume root

cryptsetup luksSuspend blocks all I/O operations from and to the root filesystem and removes the full disk encryption key from memory. echo mem > /sys/power/state uses the kernel interface to hibernate the system. Later, when the laptop wakes up again, it executes cryptsetup luksResume root. You will see your shell asking for the password. Until the root device gets unlocked, most of the system/desktop environment just hangs.

Now to be honest with you: The script above does not work. The second invocation of cryptsetup does not work, as the access to the binary is still blocked by the first cryptsetup call. There are some workarounds for this. For example, you can create a chroot environment in /boot, like described in this blog post (I would not recommend). There are projects using the shutdown initrd hook that copy the content of the extracted initramfs to /run/initramfs. There is a bash script based implementation and one written in go. Unfortunately, these implementations are not stable and sometimes lead to kernel hangs (see my Github issue), even though they stop some systemd services before luksSuspending.

I ended up solving this issue by having a system with two partitions: One for / and one for /home. This is my working solution:

loginctl lock-sessions
mount -o remount,nobarrier /home
sync
cryptsetup luksSuspend home

echo mem > /sys/power/state

cryptsetup luksResume --tries 10 home
mount -o remount,barrier /home

The mount -o remount,nobarrier /home is needed. Without the system hangs after “echo mem”. I got it from the go implementation go-luks-suspend. More information can be found in mount’s man page.

This is how it looks like on a dirty laptop display when called with my wrapper (more on that below, ignore the “Device is not authorized” message):

Auto reboot feature for Linux laptops

GrapheneOS started to implement an Auto reboot feature:

GrapheneOS provides an auto-reboot feature which reboots locked devices after a set period of time to put data at rest. A countdown timer is started each time the device is locked, and the device will reboot if a successful unlock doesn’t occur before the timer reaches zero.

Inactivity Reboot then came to iOS 18 and finally also to normal Android devices. So it’s time to bring it to Linux laptops. The logic I use is a bit different, as we use laptops in a different way compared to smartphones.

First, I patched my screen locker i3lock: Every time I unlock my laptop, i3lock writes the current date to /run/user/$userid/i3/unlocked.txt.

commit 63863697eaa9c227f21c77badc7e04dc652e1224
Author: kmille <github@androidloves.me>
Date:   Sat Mar 15 21:41:25 2025 +0100

    Log current time to state file after successful authentication

diff --git a/i3lock.c b/i3lock.c
index bcd6976..53e712b 100644
--- a/i3lock.c
+++ b/i3lock.c
@@ -294,6 +294,21 @@ static void input_done(void) {
         DEBUG("successfully authenticated\n");
         clear_password_memory();
 
+        FILE *fp;
+        char state_file[50];
+        time_t now = time(NULL);
+
+        sprintf(state_file, "/run/user/%d/i3/unlocked.txt", getuid());
+        fp = fopen(state_file, "w");
+        if(fp == NULL) {
+            DEBUG("Could not not open file '%s'\n", state_file);
+        } else {
+            struct tm *local_time = localtime(&now);
+            fprintf(fp, "%s", asctime(local_time));
+            fclose(fp);
+            DEBUG("successfully updated state file '%s': %s\n", state_file, asctime(local_time));
+        }
+
         /* PAM credentials should be refreshed, this will for example update any kerberos tickets.
          * Related to credentials pam_end() needs to be called to cleanup any temporary
          * credentials like kerberos /tmp/krb5cc_pam_* files which may of been left behind if the

Then I wrote a simple daemon in go. I called it inactivityd. It checks the modified timestamp of the monitored file. Configuration is done by environment variables (file to monitor, timeout, command to execute). If it does not change for a period of time, the daemon executes the configured command. Instead of powering off the system, I like to keep my state and use my luksSuspend implementation. In this case the timeout is set to two hours. This presumes that you take breaks or go to the toilet often and also lock your screen (both good habits 😄). After booting the laptop, the monitored file does not exist. In this case the tool uses the uptime.

This is how it looks like when you start the daemon:

DEBUG: Enabling debug log
DEBUG: env STATE_FILE="/run/user/1001/i3/unlocked.txt"
DEBUG: env TIMEOUT="2h0m0s" (7200 seconds)
DEBUG: env COMMAND="sudo /usr/local/bin/luks-suspend-openvt-wrapper"
INFO: Successfully started
DEBUG: Checking inactivity (/run/user/1001/i3/unlocked.txt)
DEBUG: State file was modified at "2025-05-05 15:08:16.408385026 +0200 CEST"
DEBUG: Triggering command after "2025-05-05 17:08:16.408385026 +0200 CEST" (timeout 2h0m0s)
DEBUG: Now we're at "2025-05-05 15:16:45.609396789 +0200 CEST m=+0.000436884"
... (sleeping again)

You can find the source code of the whole project on Github: https://github.com/kmille/auto-reboot-linux

Auto hibernate with systemd timer

A friend of mine came up with a different solution: Use a systemd timer that wakes up the laptop every morning and hibernates.

[Unit]
Description=auto hibernate at a specific time
[Timer]
OnCalendar=*-*-* 04:00:00
Unit=systemd-hibernate.service
WakeSystem=true
Persistent=false

[Install]
WantedBy=timers.target

Appendix: Putting everything together (how to use it)

Note: This project is built for Arch Linux.

1) Patching i3lock

First, clone the repository and patch i3lock:

git clone https://github.com/kmille/auto-reboot-linux.git
cd auto-reboot-linux/i3lock
kmille@spring:i3lock make buildInstall   
makepkg --syncdeps --install                 
==> Making package: i3lock 2.15-3 (Wed May  7 13:44:15 2025)
...
==> Finished making: i3lock 2.15-3 (Wed May  7 13:44:20 2025)
==> Installing package i3lock with pacman -U...
loading packages...
warning: i3lock-2.15-3 is up to date 
resolving dependencies...
looking for conflicting packages...

Packages (1) i3lock-2.15-3

Total Installed Size:  0.05 MiB
Net Upgrade Size:      0.00 MiB

:: Proceed with installation? [Y/n]
...

kmille@spring:i3lock make clean
rm -rf src pkg *.zst keys *.xz *.asc

This patches, builds and installs i3lock version 2.15-3 (current upstream is 2.15-2). You may need a gpg --recv-keys 424E14D703E7C6D43D9D6F364E7160ED4AC8EE1D before. You have to update i3lock every time an update is available. Fortunately, this is happening not super often. I monitor these updates with nvchecker. You can use this config:

kmille@spring:i3lock cat nvchecker.toml 
[i3lock]
source = "github"
github = "i3/i3lock"
use_max_tag = true

Now, let’s test if the patched version works. Run i3-lock --debug and unlock. You will see:

...
[i3lock-debug] successfully authenticated
[i3lock-debug] successfully updated state file '/run/user/1001/i3/unlocked.txt': Wed May  7 13:50:21 2025
...

kmille@spring:i3lock cat /run/user/1001/i3/unlocked.txt   
Wed May  7 13:50:21 2025

2) Building the inactivity daemon

Let’s cd daemon and use the Makefile to build and run the daemon:

export STATE_FILE := /run/user/1001/i3/unlocked.txt
export TIMEOUT := 20s
export COMMAND := date >> /tmp/inactivity.txt
export DEBUG := 1

run:
        go run ./main.go
...
kmille@spring:daemon make run
go run ./main.go
2025/05/07 13:58:03 DEBUG: Enabling debug log
2025/05/07 13:58:03 DEBUG: env STATE_FILE="/run/user/1001/i3/unlocked.txt"
2025/05/07 13:58:03 DEBUG: env TIMEOUT="20s" (20 seconds)
2025/05/07 13:58:03 DEBUG: env COMMAND="date >> /tmp/inactivity.txt"
2025/05/07 13:58:03 INFO: Successfully started
2025/05/07 13:58:03 DEBUG: Checking inactivity (/run/user/1001/i3/unlocked.txt)
2025/05/07 13:58:03 DEBUG: State file was modified at "2025-05-07 13:50:21.416868945 +0200 CEST"
2025/05/07 13:58:03 DEBUG: Triggering command after "2025-05-07 13:50:41.416868945 +0200 CEST" (timeout 20s)
2025/05/07 13:58:03 DEBUG: Now we're at "2025-05-07 13:58:03.283652172 +0200 CEST m=+0.000297479"
2025/05/07 13:58:03 INFO: Running command: "date >> /tmp/inactivity.txt"

As we didn’t lock the screen for over twenty seconds, the date command is executed (every twenty seconds):

kmille@spring:daemon cat /tmp/inactivity.txt 
Mon May  7 13:58:03 CEST 2025
Mon May  7 13:58:23 CEST 2025
...

The repo also contains an inactivityd.service to use it as systemd service. There is also a PKGBUILD to build and install inactivityd as Arch package. Just check the Makefile.

3) Using inactivityd with lukSuspend

I will show you my current configuration. I need to test it, so I may change some parts of it in the future:

kmille@spring:daemon sudo cat /etc/inactivity.env
DEBUG="1"
STATE_FILE="/run/user/1001/i3/unlocked.txt"
TIMEOUT="2h"
COMMAND="/usr/local/bin/luks-suspend-openvt-wrapper"

In the systemd unit I use EnvironmentFile=/etc/inactivity.env. As trigger command, I use a wrapper:

kmille@spring:daemon cat /usr/local/bin/luks-suspend-openvt-wrapper
/usr/bin/openvt --wait --switch /usr/local/bin/luks-suspend.sh
kmille@spring:daemon cat /usr/local/bin/luks-suspend.sh
#!/usr/bin/env sh
set -x

if [[ $EUID -ne 0 ]]; then
   echo "$0 must be run as root"
   exit 1
fi

loginctl lock-sessions
mount -o remount,nobarrier /home
sync
cryptsetup luksSuspend home

echo mem > /sys/power/state

cryptsetup luksResume --tries 10 home
mount -o remount,barrier /home

Some explanation: /usr/bin/openvt --wait --switch executes a command in a new virtual terminal (ctrl + alt + Fx). Try the following:

sudo /usr/bin/openvt --wait --switch bash

Why do I have an additional wrapper? Because I also have a shortcut executing the wrapper (and thus luksSuspending the system). But openvt needs root permissions. That’s why I added a sudo exception, to use it without entering a password:

root@spring:~ cat /etc/sudoers.d/luks-suspend
kmille ALL = NOPASSWD: /usr/local/bin/luks-suspend-openvt-wrapper

The use of openvt is totally optionally, but I like it in terms of system integration. By the way, I also execute the wrapper if someone is plugging in non-whitelisted USB devices, check my fork of the silk-guardian project.