Security
GrapheneOS-style security posture for a multi-device Linux desktop: person-scoped account keys, TPM without hardware lock-in, and unlocking that costs attestability instead of wiping your data.
Desktop Linux is mostly insecure by default. Linux runs on whatever hardware you already have, so a TPM can’t be assumed the way it can on a Mac or a Windows 11 machine. Disk encryption is at best a checkbox most people skip, and in many distributions it’d mean a second password at every boot by default. Secure Boot didn’t exist for most of Linux’s existence, and has generally been an afterthought since it does. sudo turns one password into unlimited root for fifteen minutes (which, stepping back for a second, doesn’t seem like the cleanest security model). Sandboxing is opt-in. This is hard, the hardware isn’t uniform, and on a machine that already works, TPM policy is rarely the most useful thing a volunteer maintainer could be doing that week.
The model worth copying is GrapheneOS. Not the mechanisms — most are Android-specific and don’t transfer — but the security posture: decide what you are defending against, build for that, and say plainly what you are not defending against.
tilo has to do three things GrapheneOS doesn’t, and each makes the problem harder.
It has to run normal Linux binaries. GrapheneOS inherits Android’s application model, which was built around sandboxing from the start: applications ship as packages with declared permissions, and the platform has always mediated their access. Linux’s model is an ELF binary with the full run of your filesystem. I can’t replace it — an OS that won’t run the software you already use isn’t something a Linux user would switch to, and being worth switching to for someone already on Linux is the bar I’m aiming for. So the sandbox has to confine a format I don’t control and didn’t design, without an interpreter or translation layer in between, because that would defeat the point of compatibility and performance.
It has to cross the device boundary. Lose your GrapheneOS phone’s PIN and you’re done, which is a reasonable trade for a phone with a security-focused OS where there are no accounts. tilo is collaborative and multi-device on purpose, so your identity has to outlive any particular machine without being cloud-based. It can’t live only inside one machine’s secure hardware.
It can’t feel gimmicky. Locked-down systems tend to feel like they’re arguing with you. Android and ChromeOS both do (not even beginning to talk about iOS here…): the OS has opinions about what you’re allowed to do with hardware you paid for, and getting past them makes you feel that you are using the system in a way it is not meant to, if allowed at all. I want security that’s invisible when you aren’t fighting it and steppable-around when you are — without the step being “turn it all off”, because that just means I’d have to run an insecure OS because I wanted to change a font, which feels like a really, really stupid tradeoff.
The TPM is not the root of trust
Nearly every “secure boot plus encrypted disk” design ends with the hardware as the root: the disk key is sealed to the TPM, the TPM is the machine, the machine is you. It’s clean and easy to implement, but it means a dead motherboard is dead data.
So the root here is a person-scoped account key with a recovery path built in. Devices are authorised by the account, not the other way round. The TPM holds a key that can’t be copied off the machine, which lets the machine verify itself without asking you anything — but it never decides whether you still own your data.
The part I care most about: a machine without a TPM is not a second-class machine. It costs you convenience, not security guarantees. Same keys, same revocation, same peer identity, same access control — you just type a passphrase at boot instead of the machine handling it all silently.
The security chain:
account key person-scoped, recoverable, never on one device alone
└── device cert per machine, hardware-bound where hardware allows, revocable
├── disk unlock
├── login
├── peer identity (who you are on the network)
├── document authorship (who wrote this edit)
└── access subject (what you may open)
Adding a device means scanning a QR code from one you already have; the existing device signs a certificate for the new one, and from then on both are you. Losing a device means revoking it from any other device you own — the signed revocation propagates over the same peer-to-peer sync layer that carries workspace membership. Losing every device at once is what the recovery password is for, and you get it when you create the account, not as a prompt you can dismiss later.
One secret, day to day
Ask for two credentials and people route around both. One PIN, every boot and every unlock. Entering it releases the device key, and that one key unlocks the disk, authorises the session and opens the credential store — rather than three subsystems each demanding the same secret because none of them knows about the others.
A longer passphrase shows up in one case: a machine with no TPM. Nothing throttles guessing there, so the secret itself has to be hard to guess. Still one prompt at boot. The recovery password is for recovery — dead TPM, forgotten PIN, lost every device — and shouldn’t be typed in normal use.
Keeping it out of normal use takes work. A TPM unlocks the disk by checking measurements of what booted; when firmware updates, those measurements change, and a disk sealed to the old ones stops opening. You get a passphrase prompt out of nowhere. So the seal is to a signature over the expected measurements rather than to the measurements themselves, and an update ships a new signature alongside the new firmware. Without that, people meet their recovery password often enough to start keeping it somewhere convenient, and then it isn’t a recovery password.
Boot
The keys are yours. GrapheneOS’s best idea is that attestation is over whatever key the owner enrolled, not a vendor key. Most verified-boot systems make you pick: build your own images, or be able to prove what you’re running. Concretely, here: you can build tilo yourself, sign it with a key you generated, and a collaborator who decides to trust that key can still verify what your machine booted. You don’t have to choose.
The kernel, the initramfs and the kernel command line are one signed file. Normally the initramfs sits next to the kernel as a separate file, handed over after signature checking — and the initramfs is what unlocks your encrypted disk. So anyone who can write to the boot partition gets to run code before the disk opens, with Secure Boot on and every signature valid (this is why Secure Boot is relatively pointless on non-image-based Linux distros). The command line is a text file in the same place, so any hardening option can be deleted the same way. Bundling all three into one signed image closes both.
The root filesystem is sealed. It’s read-only, every read is integrity-checked rather than just the boot being checked once, and the installed system is identified by a hash of its own contents.
Old versions stop being able to unlock automatically. The last few OS versions can open the disk via the TPM seal; older ones can’t, so rolling back to a known-vulnerable image requires the recovery password rather than silently unsealing the disk.
One gap: Android refuses to boot a rolled-back image, because the version counter lives in storage the bootloader reads before running anything. Mine is checked after the bootloader has already handed off, so an old image runs — it just can’t reach your data. Closing that properly is on the list.
Unlocking
This needs explaining, because Linux doesn’t usually have the concept. On a normal Linux machine you’re already root, or one sudo away. There’s nothing to unlock, because nothing is locked.
Here the system is sealed: the root filesystem is read-only and verified, the boot chain is signed, and normal administration goes through specific brokered operations rather than a general-purpose root shell. So there has to be a deliberate way to take that off — to replace the kernel, patch a system binary, or do the kind of thing that a sealed system by definition won’t let you do. That’s unlocking. Android has the same idea as bootloader unlocking.
Android’s version wipes your data. That protects the vendor’s threat model at your expense, and it makes the safest path the one where you never learn how your computer works.
Here it costs attestability instead. An unlocked machine records that it’s unlocked, somewhere the running system can’t rewrite, and reports it to peers when they ask what it’s running. So nobody can unlock your machine behind your back and hand it to you looking untouched — and unlocking your own machine to experiment doesn’t destroy your files. Relock and the machine is fully verifiable again.
Most tinkering shouldn’t need unlocking at all. If installing a package or reading a log needs full control, everyone unlocks on day one and the fine-grained permissions were pointless. So there are two other things. A work layer: a shell where you’re root, apt works, and every change lands in a filesystem overlay you can inspect, keep or throw away — no privilege granted, and a wrecked layer can’t survive a reboot. And individual leased capabilities for the rest, granted per session.
None of those leases can run a command as root. A capability takes a unit name to restart, or the name of a signed module to load — never an arbitrary program. sudo and pkexec fall over here: the moment a mechanism can run any program you name, its fine granularity collapses back into full root.
Writing to the system directory can’t be a lease at any granularity, because a session that can write there can leave a binary the next boot runs with no sandbox at all. That one is the unlock.
ChromeOS separates these two well and it’s worth copying as a rule: wanting a traditional package manager must never put you on the path that weakens the OS.
Applications
A denial should look like something the application already handles. A permission denied message would either tell the software something you don’t want (that there may be something there, but it can’t see it – which is information worth protecting as well) or just break an app that doesn’t handle EACCES. “The folder is empty”, “there’s no network”, “no such file” are conditions everything already mostly handles correctly, because they happen for ordinary reasons all the time, so we just give that instead.
That’s GrapheneOS’s Storage Scopes idea, and it’s nearly free here because tilo already owns the filesystem: a sandboxed application sees a filtered view where the directories you granted exist and the rest simply aren’t in the listing. An application that enumerates your files finds what you gave it.
Denying network access means an empty network namespace, so connections fail as unreachable instead of hanging. That has to cover local addresses too, or a blocked application just talks to a service on your own machine instead.
File access is a handle, not a path. You pick a file in a dialog the application doesn’t draw and can’t influence, and it receives an already-open file. Its permission is to ask, not to read — so there’s no path for it to race, and no ambient access it can re-derive later.
Permissions have two layers. The package manifest is signed and fixed, and lists the most an application can ever ask for. The prompt you answer at runtime grants some subset of that, per workspace, and you can take it back. An application that never declared microphone access can’t get it, however the dialog is worded.
Collaboration
Two separate checks, answering different questions.
Admission: may this peer sync this space at all? That rests on who the network connection authenticated, not on what a message claims about itself. Incoming changes are checked before being merged, against a copy, and a batch that fails is rejected whole — checking after the merge isn’t checking.
Authorship: was this specific change something its author was allowed to make? Checked per change, against the author’s key, regardless of who passed it along. Admission can’t answer this, because a relay forwards changes it didn’t write; without per-change proof, all you know is that the relay sent it.
Remote input is its own thing. A peer typing into your session is a second person at your keyboard, with none of the cues you’d get from someone actually standing there. So it’s granted per session, revocable on its own, and shown continuously while it’s active. Your own keyboard and mouse always win, and one keystroke disconnects every peer, drops their grants and locks the screen.
Asking a collaborator’s machine what it’s running
This one is designed and not built, and attestation is usually oversold, so here is exactly what it could do.
A machine can be asked to prove which OS image it booted and which key signed it. Your machine checks that against what it saw the first time you connected to that peer. So it detects change: a collaborator’s laptop reimaged, their signing key replaced, a rollback to an older version, or — the one I’d actually defend — a machine that’s been unlocked trying to present itself as locked, which it can’t, because unlock state is recorded where the running system can’t rewrite it.
What it cannot tell you is whether that machine is currently trustworthy. The measurements are taken while booting and nothing re-measures afterwards, so a machine compromised an hour after boot attests perfectly. It also says nothing about individual applications: a TPM has no idea which program is asking, so anything on that machine that can reach it speaks for the whole machine. And the first connection is unverified — you’re trusting what you first see, then noticing if it changes.
So it’s a change detector rather than a guarantee, which is what attestation amounts to anywhere. That’s still most of what you need when deciding whether a peer may watch, edit, or type into your session: is this the same machine running the same thing as last time? A machine that won’t prove anything — one with no TPM, say — still joins, with fewer privileges. If it couldn’t, a TPM would have quietly become mandatory.
The check is against the key the machine’s owner enrolled, not one we hold. Otherwise building your own images means losing attestation, and that trade is the thing I most wanted to avoid.
What this doesn’t do
tilo is early alpha. Some of this runs, some is written but unproven, one part is out of scope on purpose.
Working and verified. The identity chain. The compiler and kernel hardening — including one embarrassment: an environment variable was silently replacing the entire hardening flag list, so all eleven shipped binaries were built without any of it, and the check I’d written for exactly that is what found it. Filtered filesystem views and unreachable-instead-of-blocked networking, with a test that compares the error codes for a hidden file and a file that never existed, and fails if they differ. A sealed root checked on every read. A boot where the OS version number is genuinely inside the signature and inside the TPM measurement, confirmed by replaying the measurement log and getting the same value the chip reports. Rolling back across three OS versions.
Written, not proven end to end. Owner-enrolled Secure Boot is tooling; nothing enforces it yet. An encrypted install boots as far as the password prompt — a bug meant the password you were given was never actually added to the disk as a key, which is fixed and tested in the code but not yet watched opening a real machine.
Designed, not built. Asking a peer’s machine what it’s running, described above. The measurement it would rely on works and is verified; what’s missing is provisioning a key for it and the protocol between two machines.
Out of scope for now. Code that arrives from a peer to be rendered on your machine is not isolated to the standard everything else here claims. Namespaces share a kernel with the code they’re confining; doing it properly needs a microVM or WASM with no host syscalls. Until that exists, this is a gap and not a feature.
Nobody can do this. Redaction can’t un-share data. It’s a cooperative protocol: peers that cooperate honour it, peers that don’t can’t be made to, and a product claiming otherwise is claiming something that isn’t possible. Nor does any of this stop someone photographing a screen.
Something I didn’t expect to spend most of my time on: five separate verification scripts here reported success when they couldn’t actually see what they were checking. A file listing that needed privileges the build didn’t have. A log written to a console nobody was watching. An emulated firmware with no TPM support, so every measurement read as zero. Each was written specifically to prevent false confidence and produced it instead. If a check can’t distinguish “this is missing” from “I couldn’t look”, it isn’t checking anything — and that has been a far more common failure here than a missing mechanism.