core/internal/localstorage: init

This implements localstorage and localstorage/declarative, a small
library for better typed filesystem access. Further down the road this
will replace //core/internal/storage, but we're trying to commit this
early.

This is not used anywhere, and instead comes with a basic test to show
its workings.

Test Plan: covered by unit tests

X-Origin-Diff: phab/D578
GitOrigin-RevId: 9a225bc105cc331ce139eb6c195e9af216c8633e
diff --git a/core/internal/localstorage/BUILD.bazel b/core/internal/localstorage/BUILD.bazel
new file mode 100644
index 0000000..a3a5b0c
--- /dev/null
+++ b/core/internal/localstorage/BUILD.bazel
@@ -0,0 +1,26 @@
+load("@io_bazel_rules_go//go:def.bzl", "go_library", "go_test")
+
+go_library(
+    name = "go_default_library",
+    srcs = [
+        "directory_data.go",
+        "directory_pki.go",
+        "directory_root.go",
+        "storage.go",
+    ],
+    importpath = "git.monogon.dev/source/nexantic.git/core/internal/localstorage",
+    visibility = ["//core:__subpackages__"],
+    deps = [
+        "//core/internal/localstorage/crypt:go_default_library",
+        "//core/internal/localstorage/declarative:go_default_library",
+        "//core/pkg/tpm:go_default_library",
+        "@org_golang_x_sys//unix:go_default_library",
+    ],
+)
+
+go_test(
+    name = "go_default_test",
+    srcs = ["storage_test.go"],
+    embed = [":go_default_library"],
+    deps = ["//core/internal/localstorage/declarative:go_default_library"],
+)
diff --git a/core/internal/localstorage/crypt/BUILD.bazel b/core/internal/localstorage/crypt/BUILD.bazel
new file mode 100644
index 0000000..27968f3
--- /dev/null
+++ b/core/internal/localstorage/crypt/BUILD.bazel
@@ -0,0 +1,17 @@
+load("@io_bazel_rules_go//go:def.bzl", "go_library")
+
+go_library(
+    name = "go_default_library",
+    srcs = [
+        "blockdev.go",
+        "crypt.go",
+    ],
+    importpath = "git.monogon.dev/source/nexantic.git/core/internal/localstorage/crypt",
+    visibility = ["//core:__subpackages__"],
+    deps = [
+        "//core/pkg/devicemapper:go_default_library",
+        "//core/pkg/sysfs:go_default_library",
+        "@com_github_rekby_gpt//:go_default_library",
+        "@org_golang_x_sys//unix:go_default_library",
+    ],
+)
diff --git a/core/internal/localstorage/crypt/blockdev.go b/core/internal/localstorage/crypt/blockdev.go
new file mode 100644
index 0000000..b1886dc
--- /dev/null
+++ b/core/internal/localstorage/crypt/blockdev.go
@@ -0,0 +1,95 @@
+// Copyright 2020 The Monogon Project Authors.
+//
+// SPDX-License-Identifier: Apache-2.0
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package crypt
+
+import (
+	"context"
+	"fmt"
+	"io/ioutil"
+	"os"
+	"path/filepath"
+	"strconv"
+
+	"github.com/rekby/gpt"
+	"golang.org/x/sys/unix"
+
+	"git.monogon.dev/source/nexantic.git/core/pkg/sysfs"
+)
+
+var (
+	// EFIPartitionType is the standardized partition type value for the EFI ESP partition. The human readable GUID is C12A7328-F81F-11D2-BA4B-00A0C93EC93B.
+	EFIPartitionType = gpt.PartType{0x28, 0x73, 0x2a, 0xc1, 0x1f, 0xf8, 0xd2, 0x11, 0xba, 0x4b, 0x00, 0xa0, 0xc9, 0x3e, 0xc9, 0x3b}
+
+	// SmalltownDataPartitionType is the partition type value for a Smalltown data partition. The human-readable GUID is 9eeec464-6885-414a-b278-4305c51f7966.
+	SmalltownDataPartitionType = gpt.PartType{0x64, 0xc4, 0xee, 0x9e, 0x85, 0x68, 0x4a, 0x41, 0xb2, 0x78, 0x43, 0x05, 0xc5, 0x1f, 0x79, 0x66}
+)
+
+const (
+	ESPDevicePath       = "/dev/esp"
+	SmalltownDataCryptPath = "/dev/data-crypt"
+)
+
+// MakeBlockDevices looks for the ESP and the Smalltown data partition and maps them to ESPDevicePath and
+// SmalltownDataCryptPath respectively. This doesn't fail if it doesn't find the partitions, only if
+// something goes catastrophically wrong.
+func MakeBlockDevices(ctx context.Context) error {
+	blockdevNames, err := ioutil.ReadDir("/sys/class/block")
+	if err != nil {
+		return fmt.Errorf("failed to read sysfs block class: %w", err)
+	}
+	for _, blockdevName := range blockdevNames {
+		ueventData, err := sysfs.ReadUevents(filepath.Join("/sys/class/block", blockdevName.Name(), "uevent"))
+		if err != nil {
+			return fmt.Errorf("failed to read uevent for block device %v: %w", blockdevName.Name(), err)
+		}
+		if ueventData["DEVTYPE"] == "disk" {
+			majorDev, err := strconv.Atoi(ueventData["MAJOR"])
+			if err != nil {
+				return fmt.Errorf("failed to convert uevent: %w", err)
+			}
+			devNodeName := fmt.Sprintf("/dev/%v", ueventData["DEVNAME"])
+			blkdev, err := os.Open(devNodeName)
+			if err != nil {
+				return fmt.Errorf("failed to open block device %v: %w", devNodeName, err)
+			}
+			defer blkdev.Close()
+			blockSize, err := unix.IoctlGetUint32(int(blkdev.Fd()), unix.BLKSSZGET)
+			if err != nil {
+				continue // This is not a regular block device
+			}
+			blkdev.Seek(int64(blockSize), 0)
+			table, err := gpt.ReadTable(blkdev, uint64(blockSize))
+			if err != nil {
+				// Probably just not a GPT-partitioned disk
+				continue
+			}
+			for partNumber, part := range table.Partitions {
+				if part.Type == EFIPartitionType {
+					if err := unix.Mknod(ESPDevicePath, 0600|unix.S_IFBLK, int(unix.Mkdev(uint32(majorDev), uint32(partNumber+1)))); err != nil {
+						return fmt.Errorf("failed to create device node for ESP partition: %w", err)
+					}
+				}
+				if part.Type == SmalltownDataPartitionType {
+					if err := unix.Mknod(SmalltownDataCryptPath, 0600|unix.S_IFBLK, int(unix.Mkdev(uint32(majorDev), uint32(partNumber+1)))); err != nil {
+						return fmt.Errorf("failed to create device node for Smalltown encrypted data partition: %w", err)
+					}
+				}
+			}
+		}
+	}
+	return nil
+}
diff --git a/core/internal/localstorage/crypt/crypt.go b/core/internal/localstorage/crypt/crypt.go
new file mode 100644
index 0000000..ba2e8d2
--- /dev/null
+++ b/core/internal/localstorage/crypt/crypt.go
@@ -0,0 +1,149 @@
+// Copyright 2020 The Monogon Project Authors.
+//
+// SPDX-License-Identifier: Apache-2.0
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package crypt
+
+import (
+	"encoding/binary"
+	"encoding/hex"
+	"fmt"
+	"os"
+	"syscall"
+
+	"git.monogon.dev/source/nexantic.git/core/pkg/devicemapper"
+
+	"golang.org/x/sys/unix"
+)
+
+func readDataSectors(path string) (uint64, error) {
+	integrityPartition, err := os.Open(path)
+	if err != nil {
+		return 0, err
+	}
+	defer integrityPartition.Close()
+	// Based on structure defined in
+	// https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/drivers/md/dm-integrity.c#n59
+	if _, err := integrityPartition.Seek(16, 0); err != nil {
+		return 0, err
+	}
+	var providedDataSectors uint64
+	if err := binary.Read(integrityPartition, binary.LittleEndian, &providedDataSectors); err != nil {
+		return 0, err
+	}
+	return providedDataSectors, nil
+}
+
+// cryptMap maps an encrypted device (node) at baseName to a
+// decrypted device at /dev/$name using the given encryptionKey
+func CryptMap(name string, baseName string, encryptionKey []byte) error {
+	integritySectors, err := readDataSectors(baseName)
+	if err != nil {
+		return fmt.Errorf("failed to read the number of usable sectors on the integrity device: %w", err)
+	}
+
+	integrityDevName := fmt.Sprintf("/dev/%v-integrity", name)
+	integrityDMName := fmt.Sprintf("%v-integrity", name)
+	integrityDev, err := devicemapper.CreateActiveDevice(integrityDMName, []devicemapper.Target{
+		devicemapper.Target{
+			Length:     integritySectors,
+			Type:       "integrity",
+			Parameters: fmt.Sprintf("%v 0 28 J 1 journal_sectors:1024", baseName),
+		},
+	})
+	if err != nil {
+		return fmt.Errorf("failed to create Integrity device: %w", err)
+	}
+	if err := unix.Mknod(integrityDevName, 0600|unix.S_IFBLK, int(integrityDev)); err != nil {
+		unix.Unlink(integrityDevName)
+		devicemapper.RemoveDevice(integrityDMName)
+		return fmt.Errorf("failed to create integrity device node: %w", err)
+	}
+
+	cryptDevName := fmt.Sprintf("/dev/%v", name)
+	cryptDev, err := devicemapper.CreateActiveDevice(name, []devicemapper.Target{
+		devicemapper.Target{
+			Length:     integritySectors,
+			Type:       "crypt",
+			Parameters: fmt.Sprintf("capi:gcm(aes)-random %v 0 %v 0 1 integrity:28:aead", hex.EncodeToString(encryptionKey), integrityDevName),
+		},
+	})
+	if err != nil {
+		unix.Unlink(integrityDevName)
+		devicemapper.RemoveDevice(integrityDMName)
+		return fmt.Errorf("failed to create crypt device: %w", err)
+	}
+	if err := unix.Mknod(cryptDevName, 0600|unix.S_IFBLK, int(cryptDev)); err != nil {
+		unix.Unlink(cryptDevName)
+		devicemapper.RemoveDevice(name)
+
+		unix.Unlink(integrityDevName)
+		devicemapper.RemoveDevice(integrityDMName)
+		return fmt.Errorf("failed to create crypt device node: %w", err)
+	}
+	return nil
+}
+
+// cryptInit initializes a new encrypted block device. This can take a long
+// time since all bytes on the mapped block device need to be zeroed.
+func CryptInit(name, baseName string, encryptionKey []byte) error {
+	integrityPartition, err := os.OpenFile(baseName, os.O_WRONLY, 0)
+	if err != nil {
+		return err
+	}
+	defer integrityPartition.Close()
+	zeroed512BBuf := make([]byte, 4096)
+	if _, err := integrityPartition.Write(zeroed512BBuf); err != nil {
+		return fmt.Errorf("failed to wipe header: %w", err)
+	}
+	integrityPartition.Close()
+
+	integrityDMName := fmt.Sprintf("%v-integrity", name)
+	_, err = devicemapper.CreateActiveDevice(integrityDMName, []devicemapper.Target{
+		{
+			Length:     1,
+			Type:       "integrity",
+			Parameters: fmt.Sprintf("%v 0 28 J 1 journal_sectors:1024", baseName),
+		},
+	})
+	if err != nil {
+		return fmt.Errorf("failed to create discovery integrity device: %w", err)
+	}
+	if err := devicemapper.RemoveDevice(integrityDMName); err != nil {
+		return fmt.Errorf("failed to remove discovery integrity device: %w", err)
+	}
+
+	if err := CryptMap(name, baseName, encryptionKey); err != nil {
+		return err
+	}
+
+	blkdev, err := os.OpenFile(fmt.Sprintf("/dev/%v", name), unix.O_DIRECT|os.O_WRONLY, 0000)
+	if err != nil {
+		return fmt.Errorf("failed to open new encrypted device for zeroing: %w", err)
+	}
+	defer blkdev.Close()
+	blockSize, err := unix.IoctlGetUint32(int(blkdev.Fd()), unix.BLKSSZGET)
+	zeroedBuf := make([]byte, blockSize*100) // Make it faster
+	for {
+		_, err := blkdev.Write(zeroedBuf)
+		if e, ok := err.(*os.PathError); ok && e.Err == syscall.ENOSPC {
+			break
+		}
+		if err != nil {
+			return fmt.Errorf("failed to zero-initalize new encrypted device: %w", err)
+		}
+	}
+	return nil
+}
diff --git a/core/internal/localstorage/declarative/BUILD.bazel b/core/internal/localstorage/declarative/BUILD.bazel
new file mode 100644
index 0000000..7c84d9d
--- /dev/null
+++ b/core/internal/localstorage/declarative/BUILD.bazel
@@ -0,0 +1,12 @@
+load("@io_bazel_rules_go//go:def.bzl", "go_library")
+
+go_library(
+    name = "go_default_library",
+    srcs = [
+        "declarative.go",
+        "placement.go",
+        "placement_local.go",
+    ],
+    importpath = "git.monogon.dev/source/nexantic.git/core/internal/localstorage/declarative",
+    visibility = ["//core:__subpackages__"],
+)
diff --git a/core/internal/localstorage/declarative/declarative.go b/core/internal/localstorage/declarative/declarative.go
new file mode 100644
index 0000000..b6b1220
--- /dev/null
+++ b/core/internal/localstorage/declarative/declarative.go
@@ -0,0 +1,199 @@
+// Copyright 2020 The Monogon Project Authors.
+//
+// SPDX-License-Identifier: Apache-2.0
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package declarative
+
+import (
+	"fmt"
+	"reflect"
+	"strings"
+)
+
+// Directory represents the intent of existence of a directory in a hierarchical filesystem (simplified to a tree).
+// This structure can be embedded and still be interpreted as a Directory for purposes of use within this library. Any
+// inner fields of such an embedding structure that are in turn (embedded) Directories or Files will be treated as
+// children in the intent expressed by this Directory. All contained directory fields must have a `dir:"name"` struct
+// tag that names them, and all contained file fields must have a `file:"name"` struct tag.
+//
+// Creation and management of the directory at runtime is left to the implementing code. However, the DirectoryPlacement
+// implementation (set as the directory is placed onto a backing store) facilitates this management (by exposing methods
+// that mutate the backing store).
+type Directory struct {
+	DirectoryPlacement
+}
+
+// File represents the intent of existence of a file. Files are usually child structures in types that embed Directory.
+// File can also be embedded in another structure, and this embedding type will still be interpreted as a File for
+// purposes of use within this library.
+//
+// As with Directory, the runtime management of a File in a backing store is left to the implementing code, and the
+// embedded FilePlacement interface facilitates access to the backing store.
+type File struct {
+	FilePlacement
+}
+
+// unpackDirectory takes a pointer to Directory or a pointer to a structure embedding Directory, and returns a
+// reflection Value that refers to the passed structure itself (not its pointer) and a plain Go pointer to the
+// (embedded) Directory.
+func unpackDirectory(d interface{}) (*reflect.Value, *Directory, error) {
+	td := reflect.TypeOf(d)
+	if td.Kind() != reflect.Ptr {
+		return nil, nil, fmt.Errorf("wanted a pointer, got %v", td.Kind())
+	}
+
+	var dir *Directory
+	id := reflect.ValueOf(d).Elem()
+	tid := id.Type()
+	switch {
+	case tid.Name() == reflect.TypeOf(Directory{}).Name():
+		dir = id.Addr().Interface().(*Directory)
+	case id.FieldByName("Directory").IsValid():
+		dir = id.FieldByName("Directory").Addr().Interface().(*Directory)
+	default:
+		return nil, nil, fmt.Errorf("not a Directory or embedding Directory (%v)", id.Type().String())
+	}
+	return &id, dir, nil
+}
+
+// unpackFile takes a pointer to a File or a pointer to a structure embedding File, and returns a reflection Value that
+// refers to the passed structure itself (not its pointer) and a plain Go pointer to the (embedded) File.
+func unpackFile(f interface{}) (*reflect.Value, *File, error) {
+	tf := reflect.TypeOf(f)
+	if tf.Kind() != reflect.Ptr {
+		return nil, nil, fmt.Errorf("wanted a pointer, got %v", tf.Kind())
+	}
+
+	var fil *File
+	id := reflect.ValueOf(f).Elem()
+	tid := id.Type()
+	switch {
+	case tid.Name() == reflect.TypeOf(File{}).Name():
+		fil = id.Addr().Interface().(*File)
+	case id.FieldByName("File").IsValid():
+		fil = id.FieldByName("File").Addr().Interface().(*File)
+	default:
+		return nil, nil, fmt.Errorf("not a File or embedding File (%v)", tid.String())
+	}
+	return &id, fil, nil
+
+}
+
+// subdirs takes a pointer to a Directory or pointer to a structure embedding Directory, and returns a pair of pointers
+// to Directory-like structures contained within that directory with corresponding names (based on struct tags).
+func subdirs(d interface{}) ([]namedDirectory, error) {
+	s, _, err := unpackDirectory(d)
+	if err != nil {
+		return nil, fmt.Errorf("argument could not be parsed as *Directory: %w", err)
+	}
+
+	var res []namedDirectory
+	for i := 0; i < s.NumField(); i++ {
+		tf := s.Type().Field(i)
+		dirTag := tf.Tag.Get("dir")
+		if dirTag == "" {
+			continue
+		}
+		sf := s.Field(i)
+		res = append(res, namedDirectory{dirTag, sf.Addr().Interface()})
+	}
+	return res, nil
+}
+
+type namedDirectory struct {
+	name      string
+	directory interface{}
+}
+
+// files takes a pointer to a File or pointer to a structure embedding File, and returns a pair of pointers
+// to Directory-like structures contained within that directory with corresponding names (based on struct tags).
+func files(d interface{}) ([]namedFile, error) {
+	s, _, err := unpackDirectory(d)
+	if err != nil {
+		return nil, fmt.Errorf("argument could not be parsed as *Directory: %w", err)
+	}
+
+	var res []namedFile
+	for i := 0; i < s.NumField(); i++ {
+		tf := s.Type().Field(i)
+		fileTag := tf.Tag.Get("file")
+		if fileTag == "" {
+			continue
+		}
+		_, f, err := unpackFile(s.Field(i).Addr().Interface())
+		if err != nil {
+			return nil, fmt.Errorf("file %q could not be parsed as *File: %w", tf.Name, err)
+		}
+		res = append(res, namedFile{fileTag, f})
+	}
+	return res, nil
+}
+
+type namedFile struct {
+	name string
+	file *File
+}
+
+// Validate checks that a given pointer to a Directory or pointer to a structure containing Directory does not contain
+// any programmer errors in its definition:
+//  - all subdirectories/files must be named
+//  - all subdirectory/file names within a directory must be unique
+//  - all subdirectory/file names within a directory must not contain the '/' character (as it is a common path
+//    delimiter)
+func Validate(d interface{}) error {
+	names := make(map[string]bool)
+
+	subs, err := subdirs(d)
+	if err != nil {
+		return fmt.Errorf("could not get subdirectories: %w", err)
+	}
+
+	for _, nd := range subs {
+		if nd.name == "" {
+			return fmt.Errorf("subdirectory with empty name")
+		}
+		if strings.Contains(nd.name, "/") {
+			return fmt.Errorf("subdirectory with invalid path: %q", nd.name)
+		}
+		if names[nd.name] {
+			return fmt.Errorf("subdirectory with duplicate name: %q", nd.name)
+		}
+		names[nd.name] = true
+
+		err := Validate(nd.directory)
+		if err != nil {
+			return fmt.Errorf("%s: %w", nd.name, err)
+		}
+	}
+
+	filelist, err := files(d)
+	if err != nil {
+		return fmt.Errorf("could not get files: %w", err)
+	}
+
+	for _, nf := range filelist {
+		if nf.name == "" {
+			return fmt.Errorf("file with empty name")
+		}
+		if strings.Contains(nf.name, "/") {
+			return fmt.Errorf("file with invalid path: %q", nf.name)
+		}
+		if names[nf.name] {
+			return fmt.Errorf("file with duplicate name: %q", nf.name)
+		}
+		names[nf.name] = true
+	}
+	return nil
+}
diff --git a/core/internal/localstorage/declarative/placement.go b/core/internal/localstorage/declarative/placement.go
new file mode 100644
index 0000000..252dbdf
--- /dev/null
+++ b/core/internal/localstorage/declarative/placement.go
@@ -0,0 +1,92 @@
+// Copyright 2020 The Monogon Project Authors.
+//
+// SPDX-License-Identifier: Apache-2.0
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package declarative
+
+import (
+	"fmt"
+	"os"
+)
+
+// A declarative Directory/File tree is an abstract definition until it's 'placed' on a backing file system.
+// By convention, all abstract definitions of hierarchies are stored as copiable structs, and only turned to pointers
+// when placed (ie., implementations like PlaceFS takes a *Directory, but Root as a declarative definition is defined as
+// non-pointer).
+
+// Placement is an interface available on Placed Files and Directories. All *Placement interfaces on Files/Directories
+// are only available on placed trees - eg., after a PlaceFS call. This is unfortunately not typesafe, callers need to
+// either be sure about placement, or check the interface for null.
+type Placement interface {
+	FullPath() string
+	RootRef() interface{}
+}
+
+// FilePlacement is an interface available on Placed Files. It is implemented by different placement backends, and
+// set on all files during placement by a given backend.
+type FilePlacement interface {
+	Placement
+	Exists() (bool, error)
+	Read() ([]byte, error)
+	Write([]byte, os.FileMode) error
+}
+
+// DirectoryPlacement is an interface available on Placed Directories. It is implemented by different placement
+// backends, and set on all directories during placement by a given backend.
+type DirectoryPlacement interface {
+	Placement
+}
+
+// DirectoryPlacer is a placement backend-defined function that, given the path returned by the parent of a directory,
+// and the path to a directory, returns a DirectoryPlacement implementation for this directory. The new placement's
+// path (via .FullPath()) will be used for placement of directories/files within the new directory.
+type DirectoryPlacer func(parent, this string) DirectoryPlacement
+
+// FilePlacer is analogous to DirectoryPlacer, but for files.
+type FilePlacer func(parent, this string) FilePlacement
+
+// place recursively places a pointer to a Directory or pointer to a structure embedding Directory into a given backend,
+// by calling DirectoryPlacer and FilePlacer where appropriate. This is done recursively across a declarative tree until
+// all children are placed.
+func place(d interface{}, parent, this string, dpl DirectoryPlacer, fpl FilePlacer) error {
+	_, dir, err := unpackDirectory(d)
+	if err != nil {
+		return err
+	}
+
+	if dir.DirectoryPlacement != nil {
+		return fmt.Errorf("already placed")
+	}
+	dir.DirectoryPlacement = dpl(parent, this)
+
+	dirlist, err := subdirs(d)
+	if err != nil {
+		return fmt.Errorf("could not list subdirectories: %w", err)
+	}
+	for _, nd := range dirlist {
+		err := place(nd.directory, dir.FullPath(), nd.name, dpl, fpl)
+		if err != nil {
+			return fmt.Errorf("%v: %w", nd.name, err)
+		}
+	}
+	filelist, err := files(d)
+	if err != nil {
+		return fmt.Errorf("could not list files: %w", err)
+	}
+	for _, nf := range filelist {
+		nf.file.FilePlacement = fpl(dir.FullPath(), nf.name)
+	}
+	return nil
+}
diff --git a/core/internal/localstorage/declarative/placement_local.go b/core/internal/localstorage/declarative/placement_local.go
new file mode 100644
index 0000000..38fe98d
--- /dev/null
+++ b/core/internal/localstorage/declarative/placement_local.go
@@ -0,0 +1,92 @@
+// Copyright 2020 The Monogon Project Authors.
+//
+// SPDX-License-Identifier: Apache-2.0
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package declarative
+
+import (
+	"fmt"
+	"io/ioutil"
+	"os"
+)
+
+// FSRoot is a root of a storage backend that resides on the local filesystem.
+type FSRoot struct {
+	// The local path at which the declarative directory structure is located (eg. "/").
+	root string
+}
+
+type FSPlacement struct {
+	root *FSRoot
+	path string
+}
+
+func (f *FSPlacement) FullPath() string {
+	return f.path
+}
+
+func (f *FSPlacement) RootRef() interface{} {
+	return f.root
+}
+
+func (f *FSPlacement) Exists() (bool, error) {
+	_, err := os.Stat(f.FullPath())
+	if err == nil {
+		return true, nil
+	}
+	if os.IsNotExist(err) {
+		return false, nil
+	}
+	return false, err
+}
+
+func (f *FSPlacement) Read() ([]byte, error) {
+	return ioutil.ReadFile(f.FullPath())
+}
+
+func (f *FSPlacement) Write(d []byte, mode os.FileMode) error {
+	return ioutil.WriteFile(f.FullPath(), d, mode)
+}
+
+// PlaceFS takes a pointer to a Directory or a pointer to a structure embedding Directory and places it at a given
+// filesystem root. From this point on the given structure pointer has valid Placement interfaces.
+func PlaceFS(dd interface{}, root string) error {
+	r := &FSRoot{root}
+	pathFor := func(parent, this string) string {
+		var np string
+		switch {
+		case parent == "" && this == "":
+			np = "/"
+		case parent == "/":
+			np = "/" + this
+		default:
+			np = fmt.Sprintf("%s/%s", parent, this)
+		}
+		return np
+	}
+	dp := func(parent, this string) DirectoryPlacement {
+		np := pathFor(parent, this)
+		return &FSPlacement{path: np, root: r}
+	}
+	fp := func(parent, this string) FilePlacement {
+		np := pathFor(parent, this)
+		return &FSPlacement{path: np, root: r}
+	}
+	err := place(dd, "", "", dp, fp)
+	if err != nil {
+		return fmt.Errorf("could not place: %w", err)
+	}
+	return nil
+}
diff --git a/core/internal/localstorage/directory_data.go b/core/internal/localstorage/directory_data.go
new file mode 100644
index 0000000..ae842d9
--- /dev/null
+++ b/core/internal/localstorage/directory_data.go
@@ -0,0 +1,133 @@
+// Copyright 2020 The Monogon Project Authors.
+//
+// SPDX-License-Identifier: Apache-2.0
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package localstorage
+
+import (
+	"fmt"
+	"os"
+	"os/exec"
+
+	"golang.org/x/sys/unix"
+
+	"git.monogon.dev/source/nexantic.git/core/internal/localstorage/crypt"
+	"git.monogon.dev/source/nexantic.git/core/pkg/tpm"
+)
+
+var keySize uint16 = 256 / 8
+
+// MountData mounts the Smalltown data partition with the given global unlock key. It automatically
+// unseals the local unlock key from the TPM.
+func (d *DataDirectory) MountExisting(unlock *ESPLocalUnlockFile, globalUnlockKey []byte) error {
+	d.flagLock.Lock()
+	defer d.flagLock.Unlock()
+
+	if !d.canMount {
+		return fmt.Errorf("cannot mount yet (root not ready?)")
+	}
+	if d.mounted {
+		return fmt.Errorf("already mounted")
+	}
+	d.mounted = true
+
+	localUnlockBlob, err := unlock.Read()
+	if err != nil {
+		return fmt.Errorf("reading local unlock file from ESP: %w", err)
+	}
+	localUnlockKey, err := tpm.Unseal(localUnlockBlob)
+	if err != nil {
+		return fmt.Errorf("unsealing local unlock key: %w", err)
+	}
+
+	key := make([]byte, keySize)
+	for i := uint16(0); i < keySize; i++ {
+		key[i] = localUnlockKey[i] ^ globalUnlockKey[i]
+	}
+
+	if err := crypt.CryptMap("data", crypt.SmalltownDataCryptPath, key); err != nil {
+		return err
+	}
+	if err := d.mount(); err != nil {
+		return err
+	}
+	return nil
+}
+
+// InitializeData initializes the Smalltown data partition and returns the global unlock key. It seals
+// the local portion into the TPM and stores the blob on the ESP. This is a potentially slow
+// operation since it touches the whole partition.
+func (d *DataDirectory) MountNew(unlock *ESPLocalUnlockFile) ([]byte, error) {
+	d.flagLock.Lock()
+	defer d.flagLock.Unlock()
+	if !d.canMount {
+		return nil, fmt.Errorf("cannot mount yet (root not ready?)")
+	}
+	if d.mounted {
+		return nil, fmt.Errorf("already mounted")
+	}
+	d.mounted = true
+
+	localUnlockKey, err := tpm.GenerateSafeKey(keySize)
+	if err != nil {
+		return nil, fmt.Errorf("generating local unlock key: %w", err)
+	}
+	globalUnlockKey, err := tpm.GenerateSafeKey(keySize)
+	if err != nil {
+		return nil, fmt.Errorf("generating global unlock key: %w", err)
+	}
+
+	localUnlockBlob, err := tpm.Seal(localUnlockKey, tpm.SecureBootPCRs)
+	if err != nil {
+		return nil, fmt.Errorf("sealing lock unlock key: %w", err)
+	}
+
+	// The actual key is generated by XORing together the localUnlockKey and the globalUnlockKey
+	// This provides us with a mathematical guarantee that the resulting key cannot be recovered
+	// whithout knowledge of both parts.
+	key := make([]byte, keySize)
+	for i := uint16(0); i < keySize; i++ {
+		key[i] = localUnlockKey[i] ^ globalUnlockKey[i]
+	}
+
+	if err := crypt.CryptInit("data", crypt.SmalltownDataCryptPath, key); err != nil {
+		return nil, fmt.Errorf("initializing encrypted block device: %w", err)
+	}
+	mkfsCmd := exec.Command("/bin/mkfs.xfs", "-qf", "/dev/data")
+	if _, err := mkfsCmd.Output(); err != nil {
+		return nil, fmt.Errorf("formatting encrypted block device: %w", err)
+	}
+
+	if err := d.mount(); err != nil {
+		return nil, fmt.Errorf("mounting: %w", err)
+	}
+
+	if err := unlock.Write(localUnlockBlob, 0600); err != nil {
+		return nil, fmt.Errorf("writing unlock blob: %w", err)
+	}
+
+	return globalUnlockKey, nil
+}
+
+func (d *DataDirectory) mount() error {
+	if err := os.Mkdir(d.FullPath(), 0755); err != nil {
+		return fmt.Errorf("making data directory: %w", err)
+	}
+
+	if err := unix.Mount("/dev/data", d.FullPath(), "xfs", unix.MS_NOEXEC|unix.MS_NODEV, "pquota"); err != nil {
+		return fmt.Errorf("mounting data directory: %w", err)
+	}
+	return nil
+}
diff --git a/core/internal/localstorage/directory_pki.go b/core/internal/localstorage/directory_pki.go
new file mode 100644
index 0000000..a2d4424
--- /dev/null
+++ b/core/internal/localstorage/directory_pki.go
@@ -0,0 +1,137 @@
+// Copyright 2020 The Monogon Project Authors.
+//
+// SPDX-License-Identifier: Apache-2.0
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package localstorage
+
+import (
+	"crypto/ed25519"
+	"crypto/rand"
+	"crypto/tls"
+	"crypto/x509"
+	"crypto/x509/pkix"
+	"encoding/hex"
+	"fmt"
+	"math/big"
+	"time"
+
+	"git.monogon.dev/source/nexantic.git/core/internal/localstorage/declarative"
+)
+
+var (
+	// From RFC 5280 Section 4.1.2.5
+	unknownNotAfter = time.Unix(253402300799, 0)
+)
+
+type CertificateTemplateNamer func(pubkey []byte) x509.Certificate
+
+func CertificateForNode(pubkey []byte) x509.Certificate {
+	name := "smalltown-" + hex.EncodeToString([]byte(pubkey[:16]))
+
+	// This has no SANs because it authenticates by public key, not by name
+	return x509.Certificate{
+		Subject: pkix.Name{
+			// We identify nodes by their ID public keys (not hashed since a strong hash is longer and serves no benefit)
+			CommonName: name,
+		},
+		IsCA:                  false,
+		BasicConstraintsValid: true,
+		NotBefore:             time.Now(),
+		NotAfter:              unknownNotAfter,
+		// Certificate is used both as server & client
+		ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth, x509.ExtKeyUsageServerAuth},
+	}
+}
+
+func (p *PKIDirectory) EnsureSelfSigned(namer CertificateTemplateNamer) (*tls.Certificate, error) {
+	create := false
+	for _, f := range []*declarative.File{&p.Certificate, &p.Key} {
+		exists, err := f.Exists()
+		if err != nil {
+			return nil, fmt.Errorf("could not check existence of file %q: %w", f.FullPath(), err)
+		}
+		if !exists {
+			create = true
+			break
+		}
+	}
+
+	if !create {
+		certRaw, err := p.Certificate.Read()
+		if err != nil {
+			return nil, fmt.Errorf("could not read certificate: %w", err)
+		}
+		privKeyRaw, err := p.Key.Read()
+		if err != nil {
+			return nil, fmt.Errorf("could not read key: %w", err)
+		}
+		cert, err := x509.ParseCertificate(certRaw)
+		if err != nil {
+			return nil, fmt.Errorf("could not parse certificate: %w", err)
+		}
+		privKey, err := x509.ParsePKCS8PrivateKey(privKeyRaw)
+		if err != nil {
+			return nil, fmt.Errorf("could not parse key: %w", err)
+		}
+		return &tls.Certificate{
+			Certificate: [][]byte{certRaw},
+			PrivateKey:  privKey,
+			Leaf:        cert,
+		}, nil
+	}
+
+	pubKey, privKeyRaw, err := ed25519.GenerateKey(rand.Reader)
+	if err != nil {
+		return nil, fmt.Errorf("failed to generate key: %w", err)
+	}
+
+	privKey, err := x509.MarshalPKCS8PrivateKey(privKeyRaw)
+	if err != nil {
+		return nil, fmt.Errorf("failed to marshal key: %w", err)
+	}
+
+	if err := p.Key.Write(privKey, 0600); err != nil {
+		return nil, fmt.Errorf("failed to write new private key: %w", err)
+	}
+
+	serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 127)
+	serialNumber, err := rand.Int(rand.Reader, serialNumberLimit)
+	if err != nil {
+		return nil, fmt.Errorf("failed to generate serial number: %w", err)
+	}
+
+	template := namer(pubKey)
+	template.SerialNumber = serialNumber
+
+	certRaw, err := x509.CreateCertificate(rand.Reader, &template, &template, pubKey, privKeyRaw)
+	if err != nil {
+		return nil, fmt.Errorf("could not sign certificate: %w", err)
+	}
+
+	cert, err := x509.ParseCertificate(certRaw)
+	if err != nil {
+		return nil, fmt.Errorf("could not parse newly created certificate: %w", err)
+	}
+
+	if err := p.Certificate.Write(certRaw, 0600); err != nil {
+		return nil, fmt.Errorf("failed to write new certificate: %w", err)
+	}
+
+	return &tls.Certificate{
+		Certificate: [][]byte{certRaw},
+		PrivateKey:  privKey,
+		Leaf:        cert,
+	}, nil
+}
diff --git a/core/internal/localstorage/directory_root.go b/core/internal/localstorage/directory_root.go
new file mode 100644
index 0000000..324350a
--- /dev/null
+++ b/core/internal/localstorage/directory_root.go
@@ -0,0 +1,52 @@
+// Copyright 2020 The Monogon Project Authors.
+//
+// SPDX-License-Identifier: Apache-2.0
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package localstorage
+
+import (
+	"context"
+	"fmt"
+	"os"
+
+	"golang.org/x/sys/unix"
+
+	"git.monogon.dev/source/nexantic.git/core/internal/localstorage/crypt"
+)
+
+func (r *Root) Start(ctx context.Context) error {
+	r.Data.flagLock.Lock()
+	defer r.Data.flagLock.Unlock()
+	if r.Data.canMount {
+		return fmt.Errorf("cannot re-start root storage")
+	}
+	// TODO(q3k): turn this into an Ensure call
+	err := crypt.MakeBlockDevices(ctx)
+	if err != nil {
+		return fmt.Errorf("MakeBlockDevices: %w", err)
+	}
+
+	if err := os.Mkdir(r.ESP.FullPath(), 0755); err != nil {
+		return fmt.Errorf("making ESP directory: %w", err)
+	}
+
+	if err := unix.Mount(crypt.ESPDevicePath, r.ESP.FullPath(), "vfat", unix.MS_NOEXEC|unix.MS_NODEV|unix.MS_SYNC, ""); err != nil {
+		return fmt.Errorf("mounting ESP partition: %w", err)
+	}
+
+	r.Data.canMount = true
+
+	return nil
+}
diff --git a/core/internal/localstorage/storage.go b/core/internal/localstorage/storage.go
new file mode 100644
index 0000000..1aab262
--- /dev/null
+++ b/core/internal/localstorage/storage.go
@@ -0,0 +1,92 @@
+// Copyright 2020 The Monogon Project Authors.
+//
+// SPDX-License-Identifier: Apache-2.0
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package localstorage
+
+// Localstorage is a replacement for the old 'storage' internal library. It is currently unused, but will become
+// so as the node code gets rewritten.
+
+// The library is centered around the idea of a declarative filesystem tree defined as mutually recursive Go structs.
+// This structure is then Placed onto an abstract real filesystem (eg. a local POSIX filesystem at /), and a handle
+// to that placed filesystem is then used by the consumers of this library to refer to subsets of the tree (that now
+// correspond to locations on a filesystem).
+//
+// Every member of the storage hierarchy must either be, or inherit from Directory or File. In order to be placed
+// correctly, Directory embedding structures must use `dir:` or `file:` tags for child Directories and Files
+// respectively. The content of the tag specifies the path part that this element will be placed at.
+//
+// Full placement path(available via FullPath()) format is placement implementation-specific. However, they're always
+// strings.
+
+import (
+	"sync"
+
+	"git.monogon.dev/source/nexantic.git/core/internal/localstorage/declarative"
+)
+
+type Root struct {
+	declarative.Directory
+	ESP  ESPDirectory  `dir:"esp"`
+	Data DataDirectory `dir:"data"`
+	Etc  EtcDirectory  `dif:"etc"`
+}
+
+type PKIDirectory struct {
+	declarative.Directory
+	CACertificate declarative.File `file:"ca.pem"`
+	Certificate   declarative.File `file:"cert.pem"`
+	Key           declarative.File `file:"cert-key.pem"`
+}
+
+// ESPDirectory is the EFI System Partition.
+type ESPDirectory struct {
+	declarative.Directory
+	LocalUnlock ESPLocalUnlockFile `file:"local_unlock.bin"`
+	// Enrolment is the configuration/provisioning file for this node, containing information required to begin
+	// joining the cluster.
+	Enrolment declarative.File `file:"enrolment.pb"`
+}
+
+// ESPLocalUnlockFile is the localUnlock file, encrypted by the TPM of this node. After decrypting by the TPM it is used
+// in conjunction with the globalUnlock key (retrieved from the existing cluster) to decrypt the local data partition.
+type ESPLocalUnlockFile struct {
+	declarative.File
+}
+
+// DataDirectory is an xfs partition mounted via cryptsetup/LUKS, with a key derived from {global,local}Unlock keys.
+type DataDirectory struct {
+	declarative.Directory
+
+	// flagLock locks canMount and mounted.
+	flagLock sync.Mutex
+	// canMount is set by Root when it is initialized. It is required to be set for mounting the data directory.
+	canMount bool
+	// mounted is set by DataDirectory when it is mounted. It ensures it's only mounted once.
+	mounted bool
+
+	Etcd struct {
+		declarative.Directory
+		MemberPKI PKIDirectory `dir:"member_pki"`
+	} `dir:"etcd"`
+	Node    PKIDirectory          `dir:"node_pki"`
+	Volumes declarative.Directory `dir:"volumes"`
+}
+
+type EtcDirectory struct {
+	declarative.Directory
+	Hosts     declarative.File `file:"hosts"`
+	MachineID declarative.File `file:"machine_id"`
+}
diff --git a/core/internal/localstorage/storage_test.go b/core/internal/localstorage/storage_test.go
new file mode 100644
index 0000000..d676cb5
--- /dev/null
+++ b/core/internal/localstorage/storage_test.go
@@ -0,0 +1,58 @@
+// Copyright 2020 The Monogon Project Authors.
+//
+// SPDX-License-Identifier: Apache-2.0
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package localstorage
+
+import (
+	"testing"
+
+	"git.monogon.dev/source/nexantic.git/core/internal/localstorage/declarative"
+)
+
+func TestValidateAll(t *testing.T) {
+	r := Root{}
+	if err := declarative.Validate(&r); err != nil {
+		t.Errorf("Validation failed: %v", err)
+	}
+}
+
+func TestPlaceFS(t *testing.T) {
+	rr := Root{}
+	err := declarative.PlaceFS(&rr, "")
+	if err != nil {
+		t.Errorf("Placement failed: %v", err)
+	}
+
+	// Re-placing should fail.
+	err = declarative.PlaceFS(&rr, "/foo")
+	if err == nil {
+		t.Errorf("Re-placement didn't fail")
+	}
+
+	// Check some absolute paths.
+	for i, te := range []struct {
+		pl   declarative.Placement
+		want string
+	}{
+		{rr.ESP, "/esp"},
+		{rr.Data.Etcd, "/data/etcd"},
+		{rr.Data.Node.Certificate, "/data/node_pki/cert.pem"},
+	} {
+		if got, want := te.pl.FullPath(), te.want; got != want {
+			t.Errorf("test %d: wanted path %q, got %q", i, want, got)
+		}
+	}
+}