Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,85 @@ nullboot is a boot manager for environments that do not need a boot manager.
Instead of running a boot manager at boot, it directly manages the UEFI boot
entries for you.

Use cases
---------

Usage:
```
1. nullbootctl [install] [OPTIONS]
2. nullbootctl recovery-key [OPTIONS]
```

### nullbootctl [install]

Install shim and UKI in EFI System Partition.

More specifically:
- Install shim and UKI (Unified Kernel Image) to the EFI System Partition (ESP,
ie: /boot/efi)
- Update the TPM sealing policy of the LUKS passphrase
- Set EFI variable BootNext to point to the newly installed shim (and kernel?) (TODO)

It is intended to be called upon shim and UKI update.

See `nullbootctl install -h` for options:
```
Usage of nullbootctl install:
-no-boot-next
Disables use of BootNext. This flag must be disabled in order to upgrade to a new kernel version.
-no-efivars
Do not use or update the EFI variables. Disables kernel fallback mechanism
-no-tpm
Do not do any resealing with the TPM
-output-json string
JSON file to write. Disables writing real EFI variables and enablement of the kernel fallback mechanism
```


### nullbootctl recovery-key

Manage recovery keys (LUKS passphrases) of a LUKS container.

This shall setup, list, delete and verify recovery keys for a LUKS container.

See `nullbootctl recovery-key -h` for options:
```
Usage:
nullbootctl recovery-key --create [--device <device>] [--name <name>]
--delete [--device <device>] [--name <name>]
--list [--device <device>]
--verify [--device <device>]

Options:
<device> LUKS2 device
<name> Name of the recovery key
```

Example:
```
# nullbootctl recovery-key --create --name R1
Creating recovery key 'recovery-0001' in '/dev/disk/by-label/cloudimg-rootfs-enc'
38785-16377-60025-09490-45202-37955-36799-29994

# nullbootctl recovery-key --create --name R2
Creating recovery key 'R2' in '/dev/disk/by-label/cloudimg-rootfs-enc'
43392-00597-12755-38614-06919-19535-19552-05646

# nullbootctl recovery-key --list
Listing recovery keys in '/dev/disk/by-label/cloudimg-rootfs-enc'
R1
R2

# echo 38785-16377-60025-09490-45202-37955-36799-29994 | nullbootctl recovery-key --verify
success
```

Build
-----
```
$ go build -o . ./...
```

Licensing
---------
This program is free software: you can redistribute it and/or modify it under
Expand Down
140 changes: 128 additions & 12 deletions cmd/nullbootctl/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,127 @@

package main

import "golang.org/x/sys/unix"
import "github.com/canonical/nullboot/efibootmgr"
import "flag"
import "log"
import "os"
import "syscall"

var noTPM = flag.Bool("no-tpm", false, "Do not do any resealing with the TPM")
var noEfivars = flag.Bool("no-efivars", false, "Do not use or update the EFI variables. Disables kernel fallback mechanism")
var outputJSON = flag.String("output-json", "", "JSON file to write. Disables writing real EFI variables and enablement of the kernel fallback mechanism")
var noBootNext = flag.Bool("no-boot-next", false, "Disables use of BootNext. This flag must be disabled in order to upgrade to a new kernel version.")
import (
"flag"
"fmt"
"github.com/canonical/nullboot/efibootmgr"
"github.com/canonical/nullboot/luks2"
"golang.org/x/sys/unix"
"log"
"os"
"syscall"
)

const Usage = `usage:

1. nullbootctl [install] <options>
2. nullbootctl recovery-key <options>

Commands:
install Install boot artifacts
recovery-key Manage disk encryption recovery keys

See 'nullbootctl <command> -h' for more help.
`

const UsageRecoveryKey = `Usage:
nullbootctl recovery-key --create [--device <device>] [--name <name>]
--delete [--device <device>] [--name <name>]
--list [--device <device>]
--verify [--device <device>]

Options:
<device> LUKS2 device
<name> Name of the recovery key
`

func main() {

if len(os.Args) > 1 {
if os.Args[1] == "recovery-key" {
os.Args = os.Args[1:] // Strip the first item
os.Args[0] = "nullbootctl recovery-key"
cmd_recovery_key()
return
}
if os.Args[1] == "-h" || os.Args[1] == "--help" {
fmt.Print(Usage)
return
}
if os.Args[1] == "install" {
os.Args = os.Args[1:] // Strip this command (it is the default)
}
if len(os.Args[1]) == 0 || os.Args[1][0] != '-' {
fmt.Fprintf(os.Stderr, "nullbootctl: '%v' is not a valid command. See 'nullbootctl --help'.\n", os.Args[1])
os.Exit(1)
}
}

// Default "install" command
os.Args[0] = "nullbootctl install"
cmd_default()
}

const (
default_cloudimg_encrypted_device = "/dev/disk/by-label/" + efibootmgr.RootfsLabel
default_recovery_name = "recovery-0001"
)

func usageRecoveryKey() {
fmt.Print(UsageRecoveryKey)
}

func cmd_recovery_key() {

var devicePath string
var recoveryName string
doCreate := flag.Bool("create", false, "Create and set a recovery key")
doList := flag.Bool("list", false, "List recovery keys")
doDelete := flag.Bool("delete", false, "Delete a recovery key")
doVerify := flag.Bool("verify", false, "Verify a recovery key")
flag.StringVar(&devicePath, "device", default_cloudimg_encrypted_device, "Device of the encrypted volume")
flag.StringVar(&recoveryName, "name", default_recovery_name, "Name of the recovery key")
flag.Usage = usageRecoveryKey
flag.Parse()

mutuallyExclusiveOptsCount := 0
if *doCreate {
mutuallyExclusiveOptsCount++
}
if *doDelete {
mutuallyExclusiveOptsCount++
}
if *doList {
mutuallyExclusiveOptsCount++
}
if *doVerify {
mutuallyExclusiveOptsCount++
}

if mutuallyExclusiveOptsCount > 1 {
fmt.Fprintf(os.Stderr, "Options cannot be used together: --create, --list, --delete, --verify\n")
os.Exit(1)
}

var err error
if *doList {
err = luks2.ListRecoveryKeys(devicePath)
} else if *doDelete {
err = luks2.DeleteRecoveryKey(devicePath, recoveryName)
} else if *doCreate {
err = luks2.CreateRecoveryKey(devicePath, recoveryName)
} else if *doVerify {
err = luks2.VerifyRecoveryKey(devicePath)
} else {
fmt.Fprintf(os.Stderr, "Please select at least one action: --create, --list, --delete, --verify\n")
os.Exit(1)
}

if err != nil {
fmt.Fprintf(os.Stderr, "%s\n", err.Error())
os.Exit(1)
}
}

func syncDirectory(dir string) error {
dirFd, err := syscall.Open(dir, syscall.O_RDONLY|syscall.O_DIRECTORY, 0755)
Expand All @@ -25,9 +135,15 @@ func syncDirectory(dir string) error {
return err
}

func main() {
func cmd_default() {
var assets *efibootmgr.TrustedAssets
var err error

noTPM := flag.Bool("no-tpm", false, "Do not do any resealing with the TPM")
noEfivars := flag.Bool("no-efivars", false, "Do not use or update the EFI variables. Disables kernel fallback mechanism")
outputJSON := flag.String("output-json", "", "JSON file to write. Disables writing real EFI variables and enablement of the kernel fallback mechanism")
noBootNext := flag.Bool("no-boot-next", false, "Disables use of BootNext. This flag must be disabled in order to upgrade to a new kernel version.")

flag.Parse()

const (
Expand Down
2 changes: 1 addition & 1 deletion efibootmgr/efivars.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ type EFIVariables interface {
}

// RealEFIVariables provides the real implementation of efivars
type RealEFIVariables struct {}
type RealEFIVariables struct{}

// ListVariables proxy
func (vars RealEFIVariables) ListVariables() ([]efi.VariableDescriptor, error) {
Expand Down
4 changes: 2 additions & 2 deletions efibootmgr/reseal.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ import (
const (
keyFilePath = "device/fde/cloudimg-rootfs.sealed-key"
keyringPrefix = "ubuntu-fde"
rootfsLabel = "cloudimg-rootfs-enc"
RootfsLabel = "cloudimg-rootfs-enc"
Comment thread
artiepoole marked this conversation as resolved.
)

var (
Expand Down Expand Up @@ -111,7 +111,7 @@ func resolveLink(path string) (string, error) {
}

func getPrimaryKeyFromKernel() (secboot.PrimaryKey, error) {
devPath, err := resolveLink(filepath.Join("/dev/disk/by-label", rootfsLabel))
devPath, err := resolveLink(filepath.Join("/dev/disk/by-label", RootfsLabel))
Comment thread
artiepoole marked this conversation as resolved.
if err != nil {
return nil, fmt.Errorf("cannot resolve devive symlink: %w", err)
}
Expand Down
109 changes: 109 additions & 0 deletions luks2/luks2.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
// This file is part of nullboot
// Copyright 2026 Canonical Ltd.
// SPDX-License-Identifier: GPL-3.0-only

package luks2

import (
"bufio"
"crypto/rand"
"fmt"
efi "github.com/canonical/go-efilib"
"github.com/snapcore/secboot"
_ "github.com/snapcore/secboot/luks2" // This gets the LUKS2 backend initialized
"io"
"os"
"strings"
)

var (
sbAddLUKS2ContainerRecoveryKey = secboot.AddLUKS2ContainerRecoveryKey
sbDeleteLUKS2ContainerKey = secboot.DeleteLUKS2ContainerKey
sbFindStorageContainer = secboot.FindStorageContainer
sbGetKeyFromKernel = secboot.GetKeyFromKernel
sbListLUKS2ContainerRecoveryKeyNames = secboot.ListLUKS2ContainerRecoveryKeyNames
sbParseRecoveryKey = secboot.ParseRecoveryKey
sbTestLUKS2ContainerKey = secboot.TestLUKS2ContainerKey

stdin io.Reader = os.Stdin
stdout io.Writer = os.Stdout
stderr io.Writer = os.Stderr
)

// CreateRecoveryKey creates a new recovery key for a device and prints
// it to stdout.
func CreateRecoveryKey(devicePath string, recoveryName string) error {
fmt.Fprintf(stderr, "Creating recovery key '%v' in '%v'\n", recoveryName, devicePath)
container, err := sbFindStorageContainer(efi.DefaultVarContext, devicePath)
if err != nil {
return fmt.Errorf("Cannot find storage (LUKS) container: %w", err)
}
purpose := secboot.KeyringKeyPurposeUnlock
// prefix "" defaults to "ubuntu-fde"
diskUnlockKey, err := sbGetKeyFromKernel(efi.DefaultVarContext, container, purpose, "")
if err != nil {
return fmt.Errorf("Cannot get disk unlock key from kernel: %w", err)
}

recoveryKey := secboot.RecoveryKey{} // generate random from crypto rand package
rand.Read(recoveryKey[:])

keyslotName := recoveryName // anything, stored in the token
err = sbAddLUKS2ContainerRecoveryKey(devicePath, keyslotName, diskUnlockKey, recoveryKey)
if err != nil {
return fmt.Errorf("Cannot add recovery key to LUKS container: %w", err)
}

fmt.Fprintf(stdout, "%s\n", recoveryKey.String())

return nil
}

// VerifyRecoveryKey reads a recovery from stdin and verifies if it can unlock a device.
func VerifyRecoveryKey(devicePath string) error {
reader := bufio.NewReader(stdin)
fmt.Print("Enter recovery key: ")
recoveryKeyString, err := reader.ReadString('\n')
if err != nil && err != io.EOF {
return err
}
recoveryKeyString = strings.TrimSpace(recoveryKeyString)

key, err := sbParseRecoveryKey(recoveryKeyString)
if err != nil {
return err
}

ok := sbTestLUKS2ContainerKey(devicePath, key[:])
if !ok {
fmt.Fprintf(stdout, "fail\n")
return fmt.Errorf("This recovery key cannot unlock the device")
}

fmt.Fprintf(stdout, "success\n")
return nil
}

// ListRecoveryKeys lists recovery keys of a device.
func ListRecoveryKeys(devicePath string) error {
fmt.Fprintf(stderr, "Listing recovery keys in '%v'\n", devicePath)
recovery_names, err := sbListLUKS2ContainerRecoveryKeyNames(devicePath)
if err != nil {
return fmt.Errorf("Cannot list recovery keys: %w", err)
}

for _, name := range recovery_names {
fmt.Fprintf(stdout, "%v\n", name)
}
return nil
}

// DeleteRecoveryKey deletes the given recovery key from a device.
func DeleteRecoveryKey(devicePath string, recoveryKeyName string) error {
fmt.Fprintf(stdout, "Deleting recovery key '%v' in '%v'\n", recoveryKeyName, devicePath)
err := sbDeleteLUKS2ContainerKey(devicePath, recoveryKeyName)
if err != nil {
return fmt.Errorf("Cannot delete recovery key: %w", err)
}
return nil
}
Loading
Loading