Presence unlock — your phone is the key
aphrOS can open the encrypted disk and the lock screen only while your phone is in the room, and ask for a passphrase the moment it is not.
No app to install on the phone. No cloud service. No account. The phone is a plain Bluetooth device that was paired once; everything else happens on the laptop.
flowchart TD
A[power on] --> B{phone within radio range?}
B -- yes --> C[disk unlocks, no passphrase]
B -- no --> D[ordinary LUKS passphrase prompt]
C --> E{phone still here?}
D --> E
E -- yes --> F[lock screen opens on Enter]
E -- no --> G[ordinary password prompt]Both gates fail towards the password. Losing the phone, running its battery flat, or leaving it behind costs you a passphrase — never access. (Switching Bluetooth off in iOS Control Centre does not count; see the limits below.)
The app
There is a window for all of this, so you never have to take its word for it:
It says whether your phone is here right now, what will happen the next time you start the machine and at the lock screen, and gives you two buttons — scan now, pair a phone. It deliberately does not change settings: this machine is defined by a Nix flake, so it hands you the lines to paste instead of writing behind the flake's back.
What you need
- A machine running aphrOS with LUKS full-disk encryption
- A Bluetooth Low Energy phone (tested on iPhone; any BLE phone that supports pairing will do)
- Five minutes
Quick start
1. Pair the phone. On the laptop:
$ sudo aphros-bond-presence-device
==> preparing hci0
The machine is now discoverable for three minutes. Open Settings → Bluetooth on the phone, tap this machine's name, and accept the pairing request — check the confirmation codes match before you do. The tool answers the request on the laptop side for you and exits as soon as the bond appears.
When it finishes it has written two files:
| File | What it is |
|---|---|
/etc/secrets/presence-device | the phone's identity address — a label |
/etc/secrets/presence-irk | the Identity Resolving Key — the actual credential |
2. Turn the feature on in your host configuration:
aphros.presence.enable = true; # lock screen
aphros.presence.gateBoot = true; # disk unlock at boot
3. Rebuild. sudo nixos-rebuild switch, then reboot with the phone next to you. The disk should open without asking.
That is the whole setup.
How it works
Why your phone's address is not enough
An iPhone does not broadcast a fixed MAC address. It advertises a Resolvable Private Address that changes roughly every fifteen minutes, specifically so that shops and stalkers cannot follow it around. Watching for a known address therefore cannot work, and should not work.
What pairing gives you is the Identity Resolving Key. A rotating address is built as prand || hash, where hash = ah(IRK, prand) and ah is one AES-128 block. Given the key, you can test any address in microseconds and know whether it belongs to your phone. Without the key you cannot, which is the entire point.
This is why the IRK — not the address — is the secret. Anyone holding it can prove "the phone is here" to your laptop. aphrOS keeps it in a root-only file and, for the boot gate, inside the initrd, which lives on the unencrypted boot partition. Treat physical access to the boot partition as equivalent to holding that key.
Stage 1 — the lock screen
aphros-presenced ──> /run/aphros-presence/state ──> aphros-presence-check
(root daemon) present= rssi= ts= (pam_exec, runs as you)
│
auth sufficient, order 11600
pam_unix is 11700 ─┘
A small daemon scans continuously and writes a status file. PAM's decision is a file read, not a scan — a Bluetooth discovery window needs seconds to be reliable, and doing that inside the auth stack would freeze the lock screen before it would accept a keystroke.
The ordering is the fallback mechanism: presence succeeds → the password step is skipped; presence fails → control falls straight through to pam_unix and you type your password as usual.
Worth knowing: while the phone is present the lock screen opens on any keypress, including a wrong password. Presence is the credential in that moment; the password field is simply never consulted. Measured through the real PAM stack:
| Phone | Empty | Wrong password | Correct password |
|---|---|---|---|
| present | opens | opens | opens |
| absent | refused | refused | opens |
The status file carries a timestamp taken from CLOCK_BOOTTIME, and the check rejects anything older than maxAgeSeconds. A daemon that dies cannot leave behind a file that says "they're here" forever.
Stage 2 — the disk
The boot gate runs in the initrd, before the disk is opened, and it does not reimplement unlocking. It never asks for anything, never draws anything, and never touches a secret. It decides exactly one thing: whether the unattended unlock is allowed to happen.
How it enforces that depends on which initrd you use, and the module supports both.
Under a systemd initrd (recommended, and what aphrOS ships)
Unlocking here is a publish/subscribe protocol ([systemd.io/PASSWORD_AGENTS]): systemd-cryptsetup posts one query, and any number of agents race to answer it. Two of them matter:
| Agent | Answers with |
|---|---|
clevis-luks-askpass.service | the key, fetched from a [Tang] server — unattended |
systemd-ask-password-plymouth.service | whatever you type, in the splash |
So the gate writes a flag file and lets systemd do the rest:
# what the module adds — the entire gate, as far as the disk is concerned
services.clevis-luks-askpass.unitConfig.ConditionPathExists =
"/run/aphros-presence-ok";
Phone present → the gate creates the flag → clevis is allowed to race → the disk opens unattended. Phone absent → no flag → that unit is skipped, and the only agent left is the passphrase screen.
The interactive prompt is therefore never suppressed and never competed with. It is simply the remaining answer. That is the whole design, and it is why the gate script is a fifth the size of the one it replaced.
Under the scripted initrd (legacy)
There is no agent protocol, so [clevis] is wired in as a plain keyfile producer and the gate's only lever is whether that keyfile can be produced:
preOpenCommands: clevis decrypt < /etc/clevis/cryptroot.jwe > /clevis-cryptroot/decrypted
keyFile: /clevis-cryptroot/decrypted
Phone absent → the gate moves the JWE aside → clevis fails → the keyfile is empty → cryptsetup rejects it → NixOS' generated stage-1 falls through to its ordinary passphrase prompt. The withheld secret is preserved as cryptroot.jwe.withheld inside the initrd, so nothing is destroyed by a gate that guessed wrong.
⚠️ nixpkgs has deprecated the scripted initrd and scheduled it for removal in 26.11. New installations should not use this path.
The gate always exits 0, on both paths. A gate that can fail the boot is worse than no gate at all: every failure path degrades to "ask for the passphrase", which is always answerable — at the keyboard or over SSH.
[clevis]: https://github.com/latchset/clevis [Tang]: https://github.com/latchset/tang [systemd.io/PASSWORD_AGENTS]: https://systemd.io/PASSWORD_AGENTS/
The passphrase screen
When the gate does not open the disk, you get a passphrase field — on a wallpaper, in the same theme the rest of the boot uses, with no console text anywhere on the way in or out.
Nothing in aphrOS draws it. systemd-ask-password-plymouth.service renders the query inside the plymouth theme, which is the mechanism every other distribution uses; aphrOS only supplies the theme:
boot.plymouth = {
enable = true;
theme = "aphros";
themePackages = [ pkgs.aphros-plymouth-theme ];
};
aphros.bootScreen.enable = true; # see below — two races that break this
An earlier version of aphrOS hand-rolled this, driving plymouth ask-for-password from the unlock loop. It is deleted. Reimplementing an agent protocol in shell produced a keyboard shared between two readers and a passphrase that silently truncated; the write-up in wiki/boot-screen-research.md traces each symptom to its cause.
⚠️
aphros.bootScreen.enableis not cosmetic. Two races will otherwise leave you with a black screen full of console text instead of this field — plymouth losing its framebuffer to the native GPU driver before it can write its pidfile, and a 90-second device timeout that ends the boot while you are still typing. Both are measured and argued incode/nix/modules/initrd-boot-screen.nix.
On a boot your phone unlocks, both agents are watching the same query and clevis wins it. Whether the field appears for a moment first is a genuine race — clevis has to get a DHCP lease and reach Tang before it can answer, and the plymouth agent has nothing to wait for. If you see a brief flash on an unattended boot, that is what it is, and it is cosmetic: nothing is reading your keyboard, and the query is gone the instant Tang replies.
plymouth.enable=0 on the kernel command line is honoured, and drops you to the ordinary text prompt.
The escape hatch
Add aphros.unattended to the kernel command line and the probe is skipped entirely. That is the deliberate remote-maintenance path: arm a one-shot boot entry carrying it, reboot from anywhere, and the machine comes up once without anybody standing next to it.
$ sudo bootctl set-oneshot <entry>.conf # with aphros.unattended in its options
$ sudo systemctl reboot
It is one-shot by construction — it lives in a boot entry, not in a file on disk, so it cannot silently persist.
Configuration reference
| Option | Default | What it does |
|---|---|---|
aphros.presence.enable | false | The lock-screen gate and the daemon |
aphros.presence.gateBoot | false | The disk gate in the initrd |
aphros.presence.rssiThreshold | -70 | Signal strength that counts as "at the machine" |
aphros.presence.bootRssiThreshold | = rssiThreshold | The same, for the boot gate |
aphros.presence.bootTimeout | 10 | Seconds the boot gate may spend looking |
aphros.presence.maxAgeSeconds | 15 | How stale a lock-screen sighting may be |
aphros.presence.pollSeconds | 3 | Daemon polling interval |
aphros.presence.breadcrumbDevice | "" | ESP partition to write a boot-gate log to (scripted initrd) |
aphros.bootScreen.enable | false | The two fixes that let the passphrase screen actually appear — enable this |
aphros.initrdDiagnostics.enable | false | Write the initrd journal to the ESP when a boot ends in emergency |
On thresholds. Roughly: -50 is on the desk, -70 is the same room, -90 is anywhere in the building. Presence has to mean at the laptop, not merely at home — otherwise the gate protects nothing while you are asleep upstairs.
The boot gate is allowed a more forgiving number than the lock screen, and usually deserves one: the lock screen samples every few seconds and can afford to insist, while the boot gate gets one ten-second window from a radio that was powered on moments earlier.
Testing it
Three suites ship with the flake. All of them are honest about what they measure — each asserts an outcome, not the code's opinion of itself.
# The decision, against a real LUKS2 volume and a real Tang-bound secret
$ sudo GATE=code/nix/scripts/aphros-presence-boot-gate.sh \
bash code/nix/tests/test-luks-presence-gate.sh
# The gate, running under the initrd's own busybox shell, in a chroot of the
# actual initrd that will boot
$ sudo INITRD_SYS=/nix/var/nix/profiles/system-N-link \
GATE_SRC=code/nix/scripts/aphros-presence-boot-gate.sh \
bash code/nix/tests/test-initrd-userland.sh
# The prober against the three conditions that only exist at boot:
# no controller yet, controller unpowered, module reloaded underneath it
$ sudo PROBE=/path/to/aphros-presence-probe \
bash code/nix/tests/test-presence-probe-cold.sh
The prober also verifies its own cryptography:
$ aphros-presence-probe --selftest
ok aes128 matches FIPS-197 C.1
ok rpa resolution matches the Core spec sample, rejects a tampered hash
That check runs at build time too. A subtly wrong cipher never matches anything and is indistinguishable from a phone that is not there — exactly the kind of bug that would be discovered while standing in front of a locked machine.
Testing the refusal, deterministically
Proving that it opens when the phone is there is easy. Proving that it refuses when the phone is not is harder than it sounds, because a phone is difficult to switch off convincingly (see the Control Centre note below) and walking out of range is not a repeatable experiment.
Set the boot threshold somewhere the phone cannot reach, and everything else stays real — the probe runs, sees the phone, and rejects it on distance:
aphros.presence.bootRssiThreshold = -30; # nothing is ever this close
Rebuild, reboot, and the breadcrumb log shows the whole path:
7.46 | adapter present; about to run probe
17.47 | probe returned: absent ← exactly the 10-second budget
17.47 | device not found within 10s; passphrase required
Then put the threshold back.
Watching a real boot
Set breadcrumbDevice to your EFI system partition and the gate writes each step to /boot/aphros-boot-gate.log, sync'd as it goes, so the last line survives a hang and a hard reset:
=== boot gate start ===
7.16 | gate entered
7.16 | identity=set irk=set
7.16 | waiting for adapter
7.16 | adapter appeared after 0s; probe budget 10s
7.16 | adapter present; about to run probe
7.62 | probe returned: present
7.62 | device present; unlocking unattended
When something goes wrong
The machine asks for a passphrase every time, even with the phone on the desk. Check the breadcrumb log first — its last line names the step that decided. Then run the prober by hand:
$ sudo aphros-presence-probe \
--identity-file /etc/secrets/presence-device \
--irk-file /etc/secrets/presence-irk \
--timeout 10 --rssi-min -80 --verbose
It hangs before the passphrase prompt. Hard reset and pick the previous generation from the boot menu; nothing about the gate is persistent. Then read the breadcrumb log, which survives the reset.
You are locked out entirely. You are not: the LUKS passphrase always works, at the keyboard or over the initrd's SSH server. On the scripted initrd, a withheld Tang secret is also preserved as cryptroot.jwe.withheld and can be fed back in by hand.
Remote unlock over initrd SSH. Under a systemd initrd, become an agent and answer the pending query — the same protocol the splash uses, so there is no race to lose:
$ ls /run/systemd/ask-password/ # ask.NNN + its sck.NNN
ask.4247583497175384726 sck.bd8e03109af420cd
$ systemd-ask-password --list # what is being asked, if anything
$ /nix/store/*-systemd-*/lib/systemd/systemd-reply-password 1 \
/run/systemd/ask-password/sck.bd8e03109af420cd <<<'your-passphrase'
The leading 1 means "here is an answer" (0 would decline). Verify with systemctl is-active [email protected] and dmsetup ls.
⚠️ Answer promptly, or unlocking is not enough. If the boot has already given up waiting for the root device, systemd discards the jobs that would have used it — the disk then opens correctly and the machine still sits in the initrd.
aphros.bootScreen.enableremoves that deadline; without it you have 90 seconds. If you hit it anyway,systemctl start initrd-root-fs.targetand thensystemctl defaultwill usually pick the boot back up.
On the scripted initrd there is no agent to answer, and the stage-1 prompt loop rewrites /crypt-ramfs/passphrase with an empty file every second nobody types — so a single remote write usually loses the race. Offer it repeatedly:
$ for i in $(seq 1 60); do
cp /key /crypt-ramfs/.pp && mv /crypt-ramfs/.pp /crypt-ramfs/passphrase
dmsetup ls | grep -q cryptroot && break
sleep 0.2
done
Honest limits
- Proximity is not identity. Anyone holding your unlocked phone is, to this laptop, you. It is a convenience gate on top of encryption, not a second factor in the strict sense.
- The IRK is the credential, and the boot gate keeps a copy in the initrd on an unencrypted partition. Someone with the laptop can extract it and impersonate the phone. If that is in your threat model, run the lock-screen gate only and leave
gateBootoff. - RSSI is a weak distance estimate. Walls, pockets and orientation move it by 20 dB. Choose a threshold by measuring where you actually sit, not by theory.
- A bonded phone that is connected to the laptop may stop advertising. The daemon treats a live encrypted link as presence for exactly this reason; the boot gate cannot, because nothing is connected that early.
- ⚠️ Switching Bluetooth off in iOS Control Centre does NOT stop the phone advertising. That toggle only disconnects current devices; the radio keeps emitting resolvable advertisements for Handoff, AirDrop and Find My. Measured on aphrOS: with Control Centre showing Bluetooth off, the machine still unlocked unattended at −73 dBm. Only Settings → Bluetooth → off stops it. If you want presence unlock disabled while you keep the phone with you, turn the feature off in the configuration — do not rely on the phone's toggle.
- This does not defend against a running machine. Presence gates entry, not what happens afterwards.
What it is built from
| Piece | Where |
|---|---|
| NixOS module, options, PAM wiring | code/nix/modules/presence.nix |
| Boot gate (runs in the initrd) | code/nix/scripts/aphros-presence-boot-gate.sh |
| Prober (static, no dependencies) | code/nix/pkgs/aphros-presence-probe/mgmt.c |
| Pairing tool | code/nix/scripts/aphros-bond-presence-device.sh |
| Graphical status/setup app | code/nix/pkgs/aphros-presence-ui/ |
| Tests | code/nix/tests/ |
The prober talks to the kernel's Bluetooth management socket — the same interface BlueZ uses — rather than raw HCI. It never takes the adapter down and never claims exclusive access, because the initrd is a place where a blocked ioctl has no recovery.
For the full engineering account, including every trap that had to be found the hard way, see wiki/presence-gated-unlock.md.