NixOS containers in ChromeOS
Chromebooks have a reputation of being secure devices:
- Verified Boot ties the integrity of the OS to the underlying hardware. Googleâs marketing material describes it as âa read-only operating systemâ, which reduces the chances of malware surviving a reboot.
- Defense in depth makes it hard to compromise the OS in the first place, by stacking protections: the Chrome sandbox, the OS userspace, the kernel, the firmware, and the hardware.
Because of all this, users can get a trustworthy computing environment by powerwashing their Chromebook (lingo for a deep but fast wipe) back to a pristine state. On paper, this is awesome! It makes powerwashed Chromebooks ideal to handle security-sensitive tasks. The downside is that a powerwashed device lacks all tools and configuration. It requires going through initial setup, then logging into online services, configuring tools, and finally getting to work.
I would like to skip all this and be productive as quickly as possible. Better yet, Iâd like to periodically throw away any persisted state and restart from scratch, spinning up a new instance every time I need it (e.g., one for personal life, one for work, or even one for each project).
This post describes my way to achieve that goal through a combination of hardware security keys and secure, reproducible, throwaway containers.
# The requirements
I donât need much to be productive: a web browser and a shell. The browser is my gateway to most apps and services. Even when native or Electron apps are available, I prefer to run things directly in the browser to leverage its sandbox, adding another layer to defense in depth. The shell usually provides all the rest, including an editor and access to other hosts.
A good shell setup should feel like home: for instance, my text editor should
be ready with all the plugins I use; git
should know who I am and how I
prefer to fetch branches or sign commits. It should be easy to update, audit,
and (re)build the full configuration from scratch. Deploying it to a clean
device should be fast (minutes). Importantly, the environment should not
bundle any secret (e.g., passwords or cryptographic keys) or confidential
information, nor should it have access to any long-lived credential.
The lack of secrets and credentials is important for safety:
- If the environment is compromised at rest, there is nothing to exfiltrate. Plus, we do not need to worry about delivering it privately to the Chromebook, where we store it, if it leaks, and when to dispose it.
- Similarly, an attacker that compromises a running system (e.g., through a malicious executable), will not find any long-lived credential to steal.
# The solution
My solution builds on:
- Hardware keys, holding login credentials and cryptographic keys.
- NixOS containers, running under Linux on ChromeOS (Crostini).
# Hardware keys
Hardware keys create a physical security boundary for secrets. They ensure that credentials and cryptographic keys never leave the hardware device and are never exposed to the host.
Through hardware keys I:
- Login to online services (thanks to WebAuthn and passkeys).
- Prove second factors (âsomething I haveâ).
- Authenticate to other hosts (through SSH) and sign messages.
A good number of online services allow passwordless login through passkeys. There is no need to type (or remember) my username and password. I visit their website through Chrome, unlock the hardware key through its PIN, and login.
In some cases, a hardware key can only be used as a second factor. When that
happens, I use it to decrypt the password from a vault (e.g. using
passage
in the shell, or
authenticating to a password manager with a passkey). As more services embrace
passkeys, the need for passwords will hopefully become less frequent.
Lastly, the hardware key holds SSH keys used both for authentication (ssh
)
and signing (e.g., git commit
) from the shell.
# NixOS containers
One of the killer features of Chromebooks is that they have good support for
running Linux without compromising on security. Technically, a system called
Crostini runs a Linux VM (booting a hardened kernel), which in turn runs lxc
containers. The default container is Debian.
What makes Crostini great as opposed to SSH into a remote system is that it has
first-class integration with ChromeOS. You can run Linux GUI apps and they will
show up alongside all other Chrome apps; you can open a URL in Chrome from the
Linux container; you can share the clipboard between Linux and ChromeOS;
non-priveleged ports on the container are even forwarded from localhost
on
the host.
The default Debian container ships a few services that make that magic happen. Here are the most useful two:
garcon
provides bidirectional communication between ChromeOS and the container. The Terminal app uses it to connect to the container console, the Files app to browse container files, and Chrome to open URLs from the container.sommelier
implements clipboard sharing and lets the container launch GUI applications in ChromeOS.
The obvious downside of the Debian container is that it is âvanillaâ and
requires customization before feeling like home. Thatâs where NixOS makes a
difference: it makes it easy to build a container image that includes all
required tools (think git
, ssh
, nvim
, even the AWS CLI) and their
configuration (.dotfiles
, profiles, etc.).
To get NixOS running under Crostini:
- I prepared a custom NixOS image that included my dotfiles and the tools I usually need.
- I included and configured
garcon
andsommelier
to integrate nicely with ChromeOS. - I figured out how to get the image on the Chromebook and run it.
# How-to: Preparing the image
Nix makes it relatively easy to build a custom lxc
image. But Nix can also be
a pretty deep rabbit hole, which would require more than one blog post to
explain. Instead, I have prepared a simple quick start that
includes a sample configuration and can be useful to both new Nix users and
veterans to get up and running.
The repository also includes the magic glue that makes this work well: the crostini.nix
module, which runs garcon
and sommelier
through
systemd
. The ChromeOS source code and the cros-container-guest-tools-git
AUR
package
were invaluable in making this happen.
After you import this module in your configuration and build the image, the next step is to get it on your Chromebook. There are a few ways to do this, including building it in the default Debian container, copying it over through a USB stick, and uploading it to Drive.
If you have another NixOS instance handy, you can push it to an LXD image
server
behind Tailscale. To do that, first enable lxd
:
virtualisation.lxd.enable = true;
Then, enable the image server as follows:
# `lxd` can't be configured declaratively in NixOS, go figure!
sudo lxc config set core.https_address :8443
You can now import the image with and get it ready for the Chromebook.
# Replace `lxc-metadata` and `lxc` with the directories where you built the metadata and the RootFS.
lxc image import --public --alias lxc-nixos ${lxc-metadata}/tarball/*.tar.xz ${lxc}/tarball/*.tar.xz
# How-to: Deploying the image
If you havenât already, enable Linux on ChromeOS. When asked, choose the same username you will use within the container. I usually allocate 32GB of storage.
Now open crosh
(Ctrl+Alt+T), then:
vmc destroy termina # Not strictly required, but better start clean.
vmc start termina
If you are using an image server behind Tailscale, install the Tailscale app from the Play Store and use the hardware key to authenticate. Otherwise, follow one of the approaches described here to deploy the image to the Chromebook.
From inside termina
:
# Assuming `tropic` is the hostname of the `lxd` server you have configured before.
lxc remote add tropic https://tropic:8443 --public
# Ensure you can see the image listed.
lxc image list tropic:
# Download the image and setup the container.
# NOTE: Transfer speed into `termina` depends on the Chromebook.
# I try to keep the image small so that this is fast.
lxc init tropic:lxc-nixos lxc-nixos --config security.nesting=true
I have sometimes seen --config security.privileged=true
recommended as well.
Donât use it! If you do, you will spend a couple hours (as I did) trying
to figure out why USB devices correctly show up in lsusb
but then error with
âpermission deniedâ when you try accessing them. In my experience, there is
no need for that flag.
The security.nesting=true
, instead, is required to run nix
in the
container. It is part of the default configuration that Crostini uses to init
containers, and it is the right choice.
At this point, if you want, you can skip directly to âAdd the container to ChromeOSâ.
Read on, instead, if youâd like to understand how this works under the hood.
You can now start the container and, after a few seconds, it should get an IP through DHCP:
lxc start lxc-nixos
# Peek at the console logs, if you want
lxc console --show-log lxc-nixos
# Wait until `lxc list` shows an IP.
lxc list
At this point you can exec
into the container and play around with it:
lxc exec lxc-nixos bash
We are not done yet, though! First, we donât need to be logging in as root.
Second, garcon
will not work yet, because it will be missing a required file:
/dev/.container_token
. As far as I can tell, to get .container_token
we
need to start the container from crosh
. So:
# From Termina (ctrl-d if you are still in `lxc-nixos`)
lxc stop --force lxc-nixos
# From crosh (ctrl-d from `termina`)
# This will ensure `/dev/.container_token` exists within the container
vmc container termina lxc-nixos
This command will likely error out, complaining that the container cannot be
found. Fear not! The container has started in the background. Once it gets an
IP, youâll get a shell by re-running the same command. You should then be
able to check that garcon
and sommelier
are correctly running.
vmc container termina lxc-nixos
# Now, in `lxc-nixos`
systemctl --user status garcon.service
systemctl --user status [email protected]
systemctl --user status [email protected]
We can also make a couple of tests:
# This should populate your ChromeOS clipboard (you can check it with launcher-v or by pasting somewhere).
# Under the hood, it uses `sommelier` through the Wayland protocol.
echo "Clipboard works!" | wl-copy
# This should have a pair of goggly eyes pop on your screen and is testing X proxying through `sommelier-x`
nix run nixpkgs#xorg.xeyes
# How-to: Add the container to ChromeOS
ChromeOS provides an experimental UI for creating and managing multiple Crostini containers. When enabled, it significantly improves UX! It allows to:
- Launch our container by clicking on its name in the terminal, instead of
going through
crosh
. If the VM is off, it will launch it as well. - Mount folders into the container from the Files application.
- Browse the container user home directory through Files.
To enable it, navigate to: chrome://flags/#crostini-multi-container
, switch
the drop-down to âEnabledâ and then restart.
Now, go to: Settings â Linux â Manage extra containers â Create. Fill in
the âContainer nameâ with lxc-nixos
and click on Create (importantly, do this
after you have created the container from crosh
). If the container was
previously running, stop it first with lxc stop
. You can now start it from
Terminal.
The experimental UI makes it seamless to start and access the container from
Terminal.
Use Files to browse the container home and mount directories into it.
# How-to: USB forwarding
In order to use hardware keys within the container, you will also need to set up USB forwarding.
Every time you plug a USB device in, ChromeOS should prompt you whether you want to connect it to Android or Linux. Connecting it to Linux this way has never worked for me, possibly because this method attach the device to the VM, but not to the container. Instead, I just use the CLI.
Insert the device and then navigate to chrome://usb-internals
. In the
devices
tab, note the Bus number and Port number of your device.
dmesg
in crosh
will provide the same information, if you prefer.
Now open a new crosh
shell and attach the USB to the container:
# Replace <bus> and <port> with the Bus and Port number from above.
vmc usb-attach termina <bus>:<port> lxc-nixos
The container name at the end of the usb-attach
command is fundamental!
Without it, the security key will show up in the container but you will not be
able to use it.
Under the hood
It ensures that lxc
will add the following to the container configuration:
/dev/bus/usb/001/011:
major: "189"
minor: "10"
mode: "0666"
path: /dev/bus/usb/001/011
type: unix-char
The Smart Card Connector app can hold a lock on the hardware key, making the above command fail. I recommend disabling it and only enable it when needed (e.g., for SSH access through Terminal).
Symptoms
If Smart Card
Connector is holding a lock on the device, the usb-attach
command below might
fail and looking at /var/log/messages
would show this message: Verdict for
/dev/bus/usb/002/004: DENY
.
In the container, lsusb
should show the device as ready for use. If you
configured it for SSH authentication, ssh-add -L
should show your keys.
If lsusb
detects the device, but the hardware key does not work when queried
for keys (e.g., with ssh-add -L
), restart the pcscd
service and try again.
# How-to: SSH into the container
If you your container ships an SSH server, you can connect to it through the
built-in Terminal application. Use the containerâs IP (ip addr show
) or the
domain lxc-nixos.termina.linux.test
(this is hit or miss, sometimes
cicerone
will not correctly detect the IP and the hostname wonât resolve).
Getting SSH from ChromeOS to work required me to jump through so many hoops that the effort is not worth the result. I do not recommend it, but I have left this note in case it is useful to you.
Remember! The Smart Card Connector app required for SSH through ChromeOS conflicts with USB forwarding. Disable it when not using it.
# How-to: Root login
I either SSH as root
or use lxc exec
to escalate privileges easily and
safely.
# Conclusion
Software-wise, my crostini.nix
module handles the heavy lifting and gets the
things I need to work. I havenât tested hardware acceleration, audio, and
thereâs probably a few more things that do not work yet (when compared to
Debian). I can always add those things when the need arises. Clipboard sharing
between Chrome and Crostini is probably my most used feature, in addition to
opening URLs in Chrome from the container.
Hardware-wise, Chromebooks are great âcouch-computingâ or travel devices. They are underpowered with respect to other machines (e.g., an M4 MacBook). But they are lighter and cheaper, and their battery is OK considering they are âLinuxâ devices (ARM Chromebooks can easily last 12 hours on battery). I wish the display was a bit brighter, especially under direct sunlight.
Overall, after using this setup for a few weeks I am satisfied with it â I
even wrote this blog post on a Chromebook! It does what I need, strikes a good
security posture, and I like being able to go from zero to productive in a
couple minutes. Once the container boots, I immediately feel at home. I can
quickly get ahead and write my thoughts, hack on a new project, or put off the
occasional fire at work. Having a full system bottled up and ready to deploy
also gives me confidence that I could recover quickly in case of disaster
(fire, natural disaster, theft, etc.), removing any specific machine as a
single point of failure. Lastly, this setup is trivial to deploy to a different
system: I have had some fun playing with AI agents in a qemu
VM built using
the same tools.
Thanks for reading, and âtil next time!
The ChromiumOS team is experimenting with a way (codename baguette
) to run
containers without a KVM. If that happens and this guide becomes outdated,
reach out! We will figure out how to make it work there as well.
# References
- ChromeOS Security Whitepaper
- Crostini Developer Guide
- Running Custom Containers Under ChromeOS
- Port forwarding and tunneling in ChromeOS
- Crosh â The ChromiumOS shell
- ArchLinux wiki: ChromeOS Devices
- NixOS wiki: Installing Nix on Crostini
- Chrome internals: DNS (copy/paste to your browser URL bar)
- Logging on ChromeOS
/var/log/messages
from Chrome