Arch Linux laptop OS with hardened defaults, a launcher monopoly, per-app scopes, and Helium as the browser. Hyprland desk (dock + clickable Wi‑Fi).
Not GrapheneOS. Not AOSP. Graphene’s permission model inspired the scopes
sheet; the code is Arch packages + Cicada wrappers (cicada-run, bwrap), which
is weaker than Android UIDs. How much boot trust you get is set by hardware —
see Hardware tiers.
Pre-alpha: Download ISO · Site · Install
Site: site/ — download / install / features (GitHub Pages).
Method: Arch is the package engine. Cicada owns defaults, channel, shell, and profiles.
Everything here follows one rule, and most of the bugs found so far were violations of it:
Fail closed on anything that would leak a secret. Degrade visibly on anything that only reduces protection.
Knowing the time leaks nothing, so a network that blocks authenticated NTP gets
you unauthenticated time and a yellow line in cicada-status — not a laptop
that cannot validate a certificate. A VPN kill switch, by contrast, tears the
tunnel down rather than let one packet out.
A protection users switch off protects nobody. Defaults are chosen to survive contact with campus Wi-Fi.
The software is identical across tiers. What changes is which root of trust
exists. cicada-hw-trust reports the tier a machine is actually on.
| Tier | Hardware | Unlock secret | Evil maid |
|---|---|---|---|
| 0 | Apple EFI Macs, pre-2016 x86 | 80–100 bit passphrase, no rate limiting | undetectable |
| 1 | TPM2, vendor Secure Boot | short PIN, throttled in hardware | detectable |
| 2 | TPM2 + your own SB keys + signed UKI | short PIN + PCR 0+7+11 | prevented (boot chain) |
| 3 | + Heads / PureBoot / coreboot | same, on firmware you can build | vendor trust removed |
The inversion is the important part. On Tier 0 the passphrase must be long because nothing can rate-limit an offline attack on a disk image. On Tier 1+ the TPM checks the PIN itself and its dictionary-attack lockout counts failures in silicon, so a short PIN is genuinely safe — the same property that makes a 6-digit PIN safe on a Pixel.
Tier 2 is the strongest commodity-x86 story Cicada aims for (your keys + PCR binding). Still missing versus a Pixel: a discrete secure element, and per-app hardware attestation. Do not call that “GrapheneOS.”
Full detail: docs/THREAT_MODEL.md.
Disk & memory
- LUKS2 + Argon2id, memory cost scaled to the machine (¼ RAM, capped 1 GiB)
cicada-installgenerates an 80/100-bit passphrase rather than accepting a short one; self-chosen needs 6+ words or 24+ chars- TPM2 sealing behind a PIN (
cicada-tpm-enroll) — never PIN-less, which would make the disk unlock itself on power-on - USB unlock token as a second factor (
cicada-keyfile-enroll),--andmode requires two tokens so a lost stick cannot destroy the disk - LUKS header backup/verify/restore (
cicada-luks-header) — refuses to write to the disk it protects, and says out loud that the backup also undoes the duress and attempt-cap wipes - No disk swap ever; zram (RAM-only, no writeback) for memory relief
- Core dumps refused at both systemd and kernel level
noatime, free-RAM scrub at shutdown
Boot chain
- Unified kernel images (
cicada-uki) — kernel, initramfs, cmdline and os-release in one signed PE. Without this, Secure Boot signsvmlinuzand nothing else: an initramfs is a cpio and a loader entry is a text file, so an evil maid editsoptions … init=/bin/shon the plaintext ESP and the firmware boots it, having verified only the component nobody needed to touch - That is also what creates PCR 11, so
cicada-tpm-enroll --pcrs 0+7+11binds the disk key to this exact kernel+initramfs+cmdline - Type-1 entries are removed only after every UKI is built and verified to be
a real PE carrying
.linux/.initrd/.cmdline;cicada-uki restorereverses it - Your own Secure Boot keys via
cicada-sbctl-enroll; kernel upgrades rebuild and re-sign the UKI through a pacman hook - Releases are GPG-signed — a published sha256 authenticates nothing, since whoever can swap the ISO can swap the hash beside it
Anti-forensics (mapped against The Law Enforcement and Forensic Examiner's Introduction to Linux, which is the actual playbook)
- Shell history to
/dev/null, journalStorage=volatile - Thumbnail cache and
recently-used.xbelwiped at boot - Amnesic live mode: overlay in RAM, USB yank triggers panic reboot
AFU / seizure
- Suspend and hibernation disabled — a suspended laptop holds the volume key in DRAM indefinitely
- Lid close locks immediately; BFU reboot after 20 min closed, 30 min open
- Hardware watchdog enforces that window even if userspace is compromised; a systemd timer cannot survive an attacker with code execution
- USB
authorized_default=0while locked — the kernel refusing new devices, not userspace policy. Already-authorized devices keep working, so the built-in keyboard survives - Duress credential at both the LUKS prompt and the lock screen — coercion usually happens to a machine that is already running
Network
- nftables default-deny inbound; VPN kill switch with no
ct establishedexemption in the output chain - MAC randomization, and DHCP hostname/client-id/DUID withheld so a stable identifier does not undo it
- Quad9 DoT system-wide + DoH in the browser, with ECH; degrades DoH→DoT→plain
- Tor as a first-class
cicada-runscope — a network namespace whose only route is Tor, enforced by the kernel, not by a proxy setting an app can ignore - chrony with NTS from three jurisdictions
Sandboxing
cicada-rundefault-denies network, files, camera, mic, USB, sensors- bubblewrap +
xdg-dbus-proxy, per-app scopes - A seccomp filter, not just namespaces. bubblewrap installs no syscall
filter unless handed one, and a namespace hides resources without shrinking
the kernel API behind them.
cicada-seccomp-gen.shbuilds the program at boot from the running kernel's own syscall table — keyring,userfaultfd,perf_event_open,process_vm_readv,open_by_handle_at,pidfd_getfdand the rest denied with EPERM, never a kill --new-session(no TIOCSTI back into the launching terminal) and--unshare-ipc(no SysV shared memory between scopes)- hardened_malloc preloaded,
linux-hardenedavailable as a boot entry - Browser policy is managed (non-overridable): JIT off, WebGPU off, site isolation, post-quantum key agreement
Tamper evidence
- Prevention needs a root of trust; detection only needs somewhere the
attacker does not control.
cicada-beaconsigns a hash over every file on the EFI system partition that decides what the machine boots and pushes it to one device paired by hand (cicada-link) — over LoRa, a USB stick, or a QR code on the screen. On Tier 0, where/bootis plaintext and unmeasured, this is the only measurement of the boot chain that exists - The witness compares against the last statement it saw: a boot hash that moved on a boot with no kernel upgrade, a seal log whose sequence went backwards, a hardware tier that dropped. It carries no messages, has no account or server, and cannot receive — a witness that could talk back would be a remote-administration channel on a machine designed to have none
- No transport delivered it → exit 2, never a fake success. Someone under coercion acts on the belief that the signal went out
Messaging
- Cicada does not ship a messenger, and will not: the hard parts of Signal are the network and the metadata, not the ratchet, and Matrix's exposure is in the federation design rather than in any client
- What it does instead is host one honestly.
cicada-comms bindmoves a message store into an encrypted profile, socicada-lockfreezing that profile puts the history at rest — the thing Electron'ssafeStoragecannot do for itself on a session with no keyring, where it falls back to a constant key compiled into the binary cicada-comms doctorreports which of those is true on your machine.shrederases LUKS keyslots when it can and says plainly that it was only an unlink when it cannot
Attack surface
- archiso's installer-ISO services carved out: hypervisor guest agents (a host→guest control channel by design), cloud-init, ModemManager, mirror fetch, pcscd
- 8 setuid binaries stripped, re-applied by a pacman hook after every upgrade
vivid(CVE-2019-18683) and unused filesystem parsers blacklisted- Cicada's own root daemons are confined: every unit runs with
NoNewPrivileges, a named capability set (the watchdog holdsCAP_SYS_BOOTand nothing else; the filter generator holds none), and a syscall filter where one cannot silently break an emergency path. The exceptions are written down in the units themselves with the reason - AppArmor is put in the kernel's LSM stack (
lsm=on the command line). Arch's stock kernel compiles it in but leaves it out of the default list, so enabling the service alone yields an LSM that enforces nothing
| Command | Does |
|---|---|
cicada-status |
The posture in force — time, DNS, MAC, VPN, Tor, swap, core dumps |
cicada-verify |
Full boot checklist, ordered so failures explain the ones below |
cicada-hw-trust |
Which tier this machine is on, and the next command to climb one |
cicada-tor |
status / check / bridges / limits |
cicada-portal |
Time-boxed plaintext DNS for a captive portal; self-reverting |
cicada-logs |
Seal log + journal; --verify checks the chain |
cicada-run |
Launch an app in its scope |
cicada-profile |
Work / Personal / Burner compartments; end puts one back at rest |
cicada-firstrun |
One-time wizard: TPM PIN on Tier 1+, USB token on Tier 0 |
cicada-wifi-diag |
Why there is no Wi-Fi |
cicada-link |
Pair the one device that witnesses this laptop |
cicada-beacon |
Signed boot-chain statement out of band; verify on the witness |
cicada-comms |
Where a messenger's history lives, and what that is worth |
Escape hatches, typed at the boot menu (e on the entry):
cicada.nomalloc disables hardened_malloc; cicada.nowatchdog disables the
watchdog. Every control that can render the machine unusable has one.
On Apple Silicon this cross-builds an Intel/x86_64 image:
./scripts/build-iso-docker.sh # → out/cicada-*.isoThen flash, boot the Mac with Option, and install to an external SSD first.
Details in docs/BUILD.md and docs/INSTALL.md.
The image is 1.90 GiB — one file, under GitHub's 2 GiB per-asset cap, so it
downloads as a single ISO you can hand straight to Etcher. It was 2.95 GiB and
shipped as .part-00/.part-01, which put a cat command between the user and
a bootable stick as step one of the install. Three measured cuts, not guesses:
| Cut | Saved | Why it was safe |
|---|---|---|
linux-hardened off the live ISO |
645 MiB | Neither live loader entry can boot it — both name vmlinuz-linux. It was a kernel, a 233 MiB initramfs, and a second copy of both inside the embedded efiboot.img. Installed systems still get it. |
linux-firmware-nvidia |
320 MiB | 110 MiB of it sat inside every initramfs. Discrete NVIDIA on Wayland is not the daily driver; the laptop still runs on its iGPU. |
linux-firmware-marvell |
78 MiB | Enterprise NICs and embedded parts. |
Both are one command away (pacman -S linux-firmware-nvidia). What stayed is
every firmware a laptop actually boots on: intel, amdgpu, atheros, realtek,
mediatek, broadcom, radeon, cirrus.
Every ISO carries /usr/share/cicada/BUILD-ID with the commit and whether the
tree was dirty. Check it against git rev-parse --short HEAD before concluding
anything from a test — a stale ISO is how a fixed bug gets re-reported.
cicada-install (or the Etch Cicada icon) writes LUKS2 + btrfs +
systemd-boot. Three things it does that it did not before, because "hardened"
and "installable by a person who is not us" are not in tension:
- No network required. It copies the system you are already running rather
than
pacstrap-ing 1.2 GiB from the internet. That is faster, it gives you exactly the image that was tested rather than a fresh resolve of it, and it means a laptop whose Wi-Fi is the thing you need Cicada to fix can still be installed.--source networkrestores the old behaviour. - Keyboard, timezone and language are asked for — and the keymap is applied
before the passphrase prompt. These were hardcoded to
us/UTC/en_US, which quietly meant every non-US owner composed their LUKS passphrase on a layout that did not match their keycaps. The keymap is a security setting on this OS: it decides which bytes the physical keys produce, and a mismatch between install time and boot time makes the disk unopenable by anybody. - It can keep the OS already on the disk.
--partition /dev/sdXN --esp /dev/sdXMinstalls into one partition, leaves the partition table alone and reuses the existing ESP without formatting it. It deliberately does not resize NTFS or APFS — the vendor tool knows things about a live filesystem that an outside resizer does not, and that is where dual-boot installers destroy data.tests/linux/install-guards.shasserts each refusal fires for its stated reason.
tests/preflight.sh # syntax, seal chain, assemble
tests/here.sh # ~90 checks: defaults, fail-closed CLIs, boot entries
tests/seam.sh # live vs installed parity — where most bugs have lived
tests/seal.sh # hash chain + tamper detection
tests/seccomp.sh # decodes and simulates the sandbox syscall filter
tests/beacon.sh # signs a statement, edits a fake ESP, requires the alarm
tests/comms.sh # the at-rest verdict, and everything cicada-comms refuses
tests/afu.sh # USB gate, gate restore, watchdog arming, escape hatches
tests/donate.sh # re-derives the donation address checksums; catches drift
tests/iso-size.sh # does the built ISO still fit one GitHub asset (2 GiB)
tests/linux.sh # needs Docker: real kernel — nftables, NTS, Tor, duress,
# the unprivileged lock-screen duress path, installer guards
tests/boot-verify.sh # run ON the machine after booting (ships as cicada-verify)These are largely structural — they prove a config says a thing. They do not
prove the kernel accepts it. A clean run is necessary, not sufficient; the last
build shipped a broken pacman hook that all four suites passed. seccomp.sh is
the exception worth copying: it decodes the emitted BPF program and runs the same
instruction machine the kernel does, so it fails on a wrong verdict rather than a
missing string. boot-verify.sh then loads the program for real.
Pre-alpha. Boots to a Hyprland desktop on an Intel MacBook Air. Much of the hardening above is verified structurally and by loading rulesets on real kernels, but has not been exercised on hardware end to end.
Known-unverified, and why. Two lists, because "unverified" was covering two different things and that hid real bugs.
Still needs the Air, cannot be reproduced anywhere else: hardened_malloc
running under Helium, whether a chipset watchdog resets this board, whether the
kernel refuses a USB device at authorized_default=0, and whether a LoRa radio
carries a beacon.
Verified on a real Linux kernel (tests/linux.sh, a privileged linux/amd64
container): the baseline firewall and the VPN kill switch load and the kernel
reports the policies claimed here; an established TCP flow stops the moment
the switch arms, which is the ct established claim above tested with packets
instead of grep; chrony's shipped config completes NTS-KE with all three
operators; Tor reaches Bootstrapped 100%; the onion namespace has one route
and no physical interface.
Verified on the hardware itself: Doom — Chocolate Doom 3.1.1 on the Freedoom
IWAD — has been played on a real machine. That matters far more than a game
running: cicada-doom execs cicada-run org.cicada.doom, so it is the only
end-to-end evidence that a demanding GPU, audio and input application works
inside a bubblewrap scope with the seccomp filter loaded. Nothing in
tests/ can produce that result; tests/tirimid.sh only checks that Doom is
wired up, never that it runs.
Verified in simulation (tests/afu.sh, tests/beacon.sh): the USB gate, the
gate's restore path, watchdog arming, the escape hatches, and the beacon's
signing, wire format and alarm.
Six more defects, found the same way. The pattern held: every one was a control that reported success while doing nothing, and in four cases a test was certifying the broken behaviour.
- Session duress was inert at the lock screen — the room it exists for. The
PAM hook ran with
seteuid, which gives the caller's effective uid: root undersudo, but the desktop user under hyprlock, which is not setuid on Arch. It could not read the 0400 verifier, let alone erase a keyslot, and it exited 0 — the same exit a wrong guess produces.tests/here.shasserted the stringseteuidwas present and concluded the wipe worked. There is now a root handler behindcicada-duress.socket, andtests/linux/duress.shdrives it as an unprivileged uid on a real kernel. - Granting an app the microphone also gave it the session bus.
cicada-runbound the whole ofXDG_RUNTIME_DIRforMIC=allow, which includesbus(making thexdg-dbus-proxyfilter decorative) andsystemd/private— i.e. start any user unit, outside the sandbox. Shipped scopes were unaffected, but the Signal scope invites the user to flip exactly that toggle. - The D-Bus filter failed open. A fixed
sleep 0.15decided the posture by timing; if the proxy was slow the app silently got the real bus. cicada-authwas bypassable. The confirmation lived incicada-profilewhile the privilege lived incicada-profile-helper, which is directly reachable via NOPASSWD sudo:cicada-profile-helper dispose workdestroyed a profile with no prompt. Same shape forcicada-usb-gate 1, which could reopen USB enumeration while the screen was still locked.- The published ISO was unsigned (fixed starting
v2026.08.19-beta; this cut ships.sha256.asc+cicada-stable.pub). - The update channel 404’d because
channel-lateststayed a draft under GitHub’s 1000-asset cap (fixed by splitting acrosschannel-latest+channel-latest-2and publishing both afterchannel-verify-release.sh). - 60 packages would have 404’d mid-upgrade. GitHub rewrites
:to.in release asset filenames — silently, still returning success — so every package carrying a pacman epoch (ffmpeg-2:9.0.1,flatpak-1:1.18.1,fontconfig-2:2.18.3, 57 more) was served under a name the signed database did not name. The release page showed all 1822 assets present either way.channel-build-repo.shnow drops the colon beforerepo-add, so the database records the name the mirror actually serves; the epoch still reaches pacman through%VERSION%, which is read from.PKGINFO, not from the filename.
The last three are not code, and no test suite was ever going to catch them.
Splitting that list is not bookkeeping — it found five defects, none of which
needed hardware to expose. The session duress credential had never worked at
all: pam_exec writes the password without a trailing newline, so read
returned non-zero, and the hook exited before it ever computed a hash. Also: the
USB gate reported success when it had written nothing; cicada-lock stranded
the gate closed if it was killed rather than exiting normally; cicada.nomalloc
could not rescue a machine that was already broken; and the beacon's Meshtastic
call could hang forever on the duress path.
A clean run is still necessary, not sufficient.
Do not rely on this for anything that matters yet. See docs/ROADMAP.md and docs/test-results.md.
- It is not GrapheneOS, and on Tier 0 hardware it cannot approach it. No secure element, no verified boot, no key-guessing rate limit.
- Evil maid is unprevented on Tier 0.
/bootis plaintext, unsigned and unmeasured; modifying the initramfs to capture the passphrase needs a screwdriver, not an exploit.cicada-beaconmakes it detectable by a device the attacker does not hold, which is a different and weaker claim: it tells you afterwards, and only if you paired a witness and read what it says. - Intel ME / AMD PSP are present on every tier and are not neutralized.
- Tor does not hide that you use Tor without a pluggable transport, and obfs4/snowflake are AUR-only — on a censored network use Tor Browser, which bundles its own.
- Messengers are confined by Flatpak, not by Cicada. Signal, SimpleX and
Element run under Flatpak's sandbox with floors written below Flathub's
defaults — not under a
cicada-runscope, and not under the syscall filter above. That boundary is deliberate and permanent, not a gap (docs/COMMS.md). What Cicada contributes is where the message store lives and what it is worth at rest. - A seized-unlocked or compromised machine makes everything above moot.
Threat model: docs/THREAT_MODEL.md · docs/ATTACKS.md · docs/GRAPHENE_PARITY.md · docs/SANDBOX.md · docs/DESIGN.md · docs/BEACON.md · docs/COMMS.md