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)
+ }
+ }
+}