Nix, Nix och NixOS – Säkerhet genom reproducerbarhet
“Insanity is doing the same thing over and over again expecting different results” är ett citat som ibland felaktigt tillskrivs bland annat Albert Einstein. Vem som först yttrade ordspråket är inte helt klarlagt, men det är lätt att se en uppenbar sanning i det. För säkerligen bör en process leda till samma resultat oavsett vem som följer den?
Inom IT är det dock inte ovanligt att göra samma sak om och om igen och få olika resultat. Till exempel vid kodbyggen.
Memes åsido, reproducerbarhet är något som har diskuterats under decennier. Men vad innebär det att ett kodbygge är reproducerbart?
Om reproducerbara kodbyggen
Ett reproducerbart kodbygge innebär att en byggprocedur givet en viss indata alltid kommer att resultera i exakt samma resultat, till exempel en programfil. Detta är långt ifrån givet med de flesta kompilatorer och byggsystem som används idag, eller som historiskt har använts. Vi kommer att titta på ett exempel lite senare i detta blogginlägg.
Reproducerbarhet inget nytt
GNU-projektet har arbetat på att möjliggöra reproducerbara byggen sedan tidigt på 90-talet, och en mängd andra projekt och initiativ har arbetat med frågan för att öka tillförlitligheten i deras kodbyggen. Genom försäkran att en viss mjukvaruversion är fullt reproducerbar möjliggörs mer effektiv felsökning och testning vilket kan leda till kostnadsbesparingar och effektivitetsvinster.
Säkerhet
Reproducerbara kodbyggen är också viktiga av säkerhetsskäl. Främst är det en tillitsfråga, att möjliggöra för en användare att helt säkert kunna härleda ett kodbygge till en viss källkod. Detta är särskilt viktigt när förkompilerad mjukvara distribueras, och att bygget är reproducerbart öppnar för möjligheten att verifiera det. Eftersom reproducerbara kodbyggen genererar identiskt lika programfiler är det till exempel möjligt att verifiera dem med hashsummor. En angripare som modifierar programfilen genom att exempelvis gömma skadlig kod i den kan upptäckas då filens hashsumma inte längre stämmer.
Projekt som Tor och Tails har av säkerhetsskäl arbetat med att säkerställa att deras mjukvara är reproducerbar. Tails-projektet skriver:
“Most aspects of software verification are done on source code, as that is what humans can reasonably understand. But most of the time, computers require software to be first built into a long string of numbers to be used. With reproducible builds, multiple parties can redo this process independently and ensure they all get exactly the same result. We can thus gain confidence that a distributed binary code is indeed coming from a given source code.“
https://tails.net/contribute/build/reproducible/
Exempel på icke-reproducerbart bygge med cargo
För att illustrera hur kodbyggen kan resultera i olika resultat använder vi här cargo, Rusts pakethanterare. Vi skapar två olika kataloger på filsystemet, exempelvis /tmp/test/ och /dev/shm/test. Navigera till respektive mapp och kör:
cargo new hello && cargo build && md5sum target/debug/hello
Cargo skapar då ett enkelt “Hello, world!”-program under ./src/main.rs, bygger det och räknar ut binärens md5-hashsumma. På mitt system erhåller jag två olika hashsummor:
25499639de892735ae196a0c825366ff target/debug/hello
e7d37bf43e8b07e1aea862ec85a25497 target/debug/hello
Hur kommer det sig att programmens hashsummor skiljer sig när det rör sig om ett program bestående av tre rader kod?
När en byggprocess är “oren” tar den hänsyn till omständigheter som kan skilja sig från en byggmiljö från en annan. Reproducible Builds beskriver inte mindre än 16 typer av avvikelser som kan förändra utfallet av en byggprocess, bland annat tidsstämplar, filrättigheter, metadata och byggprocessens “build path”.
“Build path” avser sökvägen som programmet har när bygget genomförs, och som kan inspekteras genom att köra Unix-programmet strings mot programfilen. Genom att använda ett byggsystem vars mål är att säkerställa repoducerbarhet kan vi undvika att resultatet influeras på ett olämpligt sätt och bara beror på dess indata. Det finns flera sådana byggsystem, men veckans blogginlägg är inriktat på Nix.
Nix & Nix
Nix är en pakethanterare som skapades av Eelco Dolstra som ett led i sin doktorsavhandling “The Purely Functional Software Deployment Model” från 2006. Nix är även det funktionella programmeringsspråk som används för att instruera Nix hur kodbyggen skall gå till. Nix påminner om en hybrid mellan filformatet JSON och språkfamiljen LISP, och har beskrivits som “JSON med funktioner”.
Nix kan användas på Linux, MacOS och WSL2 och är mycket enkelt att installera.
Gabriel Fontes beskriver på sin blogg hur Nix kan användas för att bygga Rust-program. Läsare som vill prova på att paketera ett eget paket rekommenderas att läsa inlägget i sin helhet.
Nedan är ett exempel på en Nix-fil för att möjliggöra detta:
{ pkgs ? import <nixpkgs> { } }:
pkgs.rustPlatform.buildRustPackage rec {
pname = "foo-bar";
version = "0.1";
cargoLock.lockFile = ./Cargo.lock;
src = pkgs.lib.cleanSource ./.;
}
Nix bestyckades 2021 med en ny funktion som kallas “flakes“, som ytterligare ökar reproducerbarheten i pakethanteraren. En flake är en nix-fil som innehåller upp till fyra topp-attribut. De viktigaste av dessa är inputs, det vill säga vad pakethanteraren använders som indata i bygget, och outputs vilket är beskrivningen av vad kodbygget skall generera. De övriga två attributen är en beskrivning av aktuell flake och en extra instruktion med flake-specifika instruktioner till pakethanteraren.
Nedan flake är även den tagen med en mindre modifikation från Gabriel Fontes blogginlägg och inlägget rekommenderas igen för exempel på hur en flake kan skrivas för att bli ännu mer lättläst och följa DRY-principen (Don’t repeat yourself).
{
description = "nixtest";
inputs = {
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
};
outputs = { self, nixpkgs }:
let
supportedSystems = [ "x86_64-linux" ];
forAllSystems = nixpkgs.lib.genAttrs supportedSystems;
pkgsFor = nixpkgs.legacyPackages;
in {
packages = forAllSystems (system: {
default = pkgsFor.${system}.callPackage ./. { };
});
};
}
Med ovan nix-filer på plats kan “Hello, world!”-exemplet byggas reproducerbart. Detta görs genom att navigera till projektmappen där ovan nix-filer är placerade och köra:
nix build
Nix bygger då paketet till mappen result, och programfilerna återfinns i undermappen bin.
Efter att ha byggt samma program med Nix stämmer nu hashsummorna överens, trots att de har olika build paths:
2f703faa777635cf7c5870bf2aba0c3c result/bin/nixtest
2f703faa777635cf7c5870bf2aba0c3c result/bin/nixtest
Att använda Nix som pakethanterare innebär inte att projektet kan byggas på sedvanligt vis, utan är ett intressant komplement för den som vill möjliggöra reproducerbara byggen och installera systempaket på Nix vis.
NixOS
NixOS är en Linux-distribution som byggs och konfigureras av Nix. Detta öppnar för möjligheten att betrakta ett operativsystem i sig som reproducerbart. För den som är van vid infrastruktur som kod-lösningar såsom Terraform/OpenTofu, Ansible och SaltStack kommer förfarandet att kännas väldigt bekant.
NixOS 1.0 släpptes 2012-05-11 och dess senaste stabila version är 23.05. NixOS stabila “kanal” uppdateras en gång i halvåret, och NixOS-unstable har ett rullande uppdateringsschema.
NixOS har ökat i användning de senaste åren trots att distributionen har funnits länge och distributionen är vid tidpunkten då detta blogginlägg skrevs rankad på 24:e plats på DistroWatch. NixOS har ihop med Home Manager, en modul för managering av hemkataloger och användarkonfiguration, blivit mycket populär för ricing då det är lätt att dela med sig av konfigurationsfiler, och väva in andra personers konfigurationer och projekt i sina egna.
Här följer ett exempel på en flake som använder nixpkgs officiella kodvalv samt Github-kodvalv för Home Manager och en Wayland compositor som kallas Hyprland.
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
nixpkgs-stable.url = "github:nixos/nixpkgs/nixos-23.05";
home-manager = {
url = "github:nix-community/home-manager";
inputs.nixpkgs.follows = "nixpkgs";
};
hyprland.url = "github:hyprwm/Hyprland";
};
Då NixOS bygger på Nix är hjärtat i operativsystemet dess deklarativa konfigurationsfiler och sökvägen /nix/store. All modifiering av systemet, såsom installation och konfiguration av program sker genom ändringar av konfigurationsfilerna (som av konvention oftast finns under /etc/nixos). Dessa appliceras därefter systemet med kommandot (som root eller sudo):
nixos-rebuild switch
Alla förändringar byggs som paket och sparas under /nix/store, och länkas symboliskt till relevanta sökvägar på filsystemet. Detta är för det mesta transparent för användaren men är snabbt uppenbart om till exempel miljövariabler inspekteras. Nedan följer delar av en $PATH-variabel på ett NixOS-system:
/nix/var/nix/profiles/default/bin:/run/current-system/sw/bin:/nix/store/5rykrx2q8y58zkkpd5968iaprb33xa9l-binutils-wrapper-2.40/bin:/nix/store/qsipg2kf0b4bfq4g6wd5v18f6bp0b4nz-pciutils-3.10.0/bin
Då /nix/store är oföränderligt (av engelskans immutable) och det mesta av operativsystemet är symboliskt länkat till paket där är systemet mer motståndskraftigt mot otillbörlig påverkan. Vid varje körning av nixos-rebuild skapas en generation som får ett eget inlägg i systemets bootloader, som då blir valbart i samband med systemuppstart. Detta gör det svårt (men inte omöjligt) att försätta systemet i ett okörbart läge, och det går enkelt att backa tillbaka till tidigare generationer om någonting slutar fungera. NixOS måste dock i regel inte startas om för att en ny generation ska kunna användas.
En styrka med att konfigurera program genom Nix är att det går att versionshantera och kommentera konfigurationsfilerna. Det gör det möjligt att iterera över tid och långsamt konfigurera systemet så det uppnår önskad härdningsgrad och beteende. Brandväggsregler kan till exempel konfigureras så här:
{config, pkgs, ... }:
{
networking.firewall = {
enable = true;
allowedTCPPorts = [ 80 443 ];
allowedUDPPortRanges = [
{ from = 4000; to = 4007; }
{ from = 8000; to = 8010; }
];
};
}
Nedan följer ett exempel på hur Firefox kan installeras och konfigureras:
{config, pkgs, ... }:
{
programs.firefox = {
enable = true;
preferencesStatus = "default";
policies = {
ExtensionSettings = {
"uBlock0@raymondhill.net" = {
"installation_mode" = "force_installed";
"install_url" = "https://addons.mozilla.org/firefox/downloads/latest/ublock-origin/latest.xpi";
};
};
ExtensionUpdate = true;
OfferToSaveLogins = false;
DisableProfileImport = true;
DisableTelemetry = true;
DNSOverHTTPS = {
Enabled = false;
};
preferences = {
"app.normandy.enabled" = false;
"experiments.enabled" = false;
};
Nix-konfigurationen ovan ser till att Nix installerar Firefox, installerar reklamblockeraren uBlock Origin och stänger av flera telemetrifunktioner, den inbyggda lösenordshanteraren samt DNS över HTTPS. Preferences-blocket fungerar precis som om webbläsaren konfigurerats genom about:config.
Systemet kan enkelt konfigureras så att Firefox körs i en sandbox, till exempel Firejail:
programs.firejail.enable = true;
programs.firejail.wrappedBinaries = {
firefox = {
executable = "${pkgs.lib.getBin pkgs.firefox}/bin/firefox";
profile = "${pkgs.firejail}/etc/firejail/firefox.profile";
};
};
NixOS kan även konfigureras för impermanence, vilket innebär att hela eller stora delar av operativsystemet helt sonika raderas vid varje omstart. NixOS deklarativa konfigurationsfiler och /nix/store gör det möjligt för systemet att återbildas till ett helt rent stadium vid varje uppstart. För exempel på hur detta kan uppnås se till exempel “Erase your darlings” av Graham Christensen.
Dessvärre är Secure Boot ännu inte fullt stabiliserat i NixOS. Projektet Lanzaboote arbetar för fullt med att rätta till detta och det finns en förhoppning om att det ska finnas med som en preview i NixOS 23.11. Det går redan nu att använda, men kräver en handpåläggning av användaren.
Detta blogginlägg har bara skrapat på ytan både vad gäller Nix och NixOS. För den som är nyfiken på NixOS och vill prova distributionen finns en bra nybörjarguide här.
Logotypen för veckans blogg är hämtad från NixOS Github-kodvalv och är designad av Tim Cuthbertson.