oficina / home Public

Export and restore encrypted registries against surviving floors

Paul Campbell pushed by paul@keymail.dev e24577add04d4c9478df57ca4e79c584722486b9 parent 84cb58f
3 files changed, +401 −2
  • internal/provisioner/backup_linux.go +261 −0
  • internal/provisioner/backup_linux_test.go +119 −0
  • internal/provisioner/cli_linux.go +21 −2

CI failed — run details

diff --git a/internal/provisioner/backup_linux.go b/internal/provisioner/backup_linux.go
new file mode 100644
index 0000000..4c071d7
--- /dev/null
+++ b/internal/provisioner/backup_linux.go
@@ -0,0 +1,261 @@
+//go:build linux
+
+package provisioner
+
+import (
+ "archive/tar"
+ "crypto/sha256"
+ "encoding/hex"
+ "io"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "filippo.io/age"
+ "golang.org/x/sys/unix"
+)
+
+const maxRegistryBytes = int64(maxRecords) * (128 << 10)
+
+type BackupInfo struct {
+ Digest string `json:"archive_digest"`
+ Sequence int64 `json:"sequence"`
+}
+
+func existingJournal(statePath, floorPath string, password []byte) (*journal, error) {
+ if statePath == floorPath || strings.HasPrefix(statePath, floorPath+"/") || strings.HasPrefix(floorPath, statePath+"/") {
+ return nil, ErrRefused
+ }
+ state, e := openPrivateDirectory(statePath)
+ if e != nil {
+ return nil, e
+ }
+ floorDir, e := openPrivateDirectory(floorPath)
+ if e != nil {
+ state.close()
+ return nil, e
+ }
+ fail := func(e error) (*journal, error) { state.close(); floorDir.close(); return nil, e }
+ wrapped, e := state.read("credential.age", 128<<10)
+ if e != nil {
+ return fail(e)
+ }
+ id, key, e := unwrapCredential(wrapped, password)
+ if e != nil {
+ return fail(e)
+ }
+ floor, e := newNativeFloor(floorDir, id)
+ if e != nil {
+ return fail(e)
+ }
+ j, e := openJournal(state, floor, key, false)
+ if e != nil {
+ return fail(e)
+ }
+ return j, nil
+}
+func closeExisting(j *journal) { j.close(); j.floor.dir.close(); j.dir.close() }
+func ownedFile(dir *privateDirectory, name string) (*os.File, error) {
+ if !leaf(name) {
+ return nil, ErrRefused
+ }
+ fd, e := unix.Openat(int(dir.file.Fd()), name, unix.O_WRONLY|unix.O_CREAT|unix.O_EXCL|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0600)
+ if e != nil {
+ return nil, ErrRefused
+ }
+ return os.NewFile(uintptr(fd), name), nil
+}
+
+// Backup exports only encrypted state. The independent floor is never included.
+func Backup(statePath, floorPath, output string, password []byte) (BackupInfo, error) {
+ if !filepath.IsAbs(output) || filepath.Clean(output) != output || strings.HasPrefix(output, statePath+"/") || strings.HasPrefix(output, floorPath+"/") {
+ return BackupInfo{}, ErrRefused
+ }
+ destination, e := openPrivateDirectory(filepath.Dir(output))
+ if e != nil {
+ return BackupInfo{}, e
+ }
+ defer destination.close()
+ name := filepath.Base(output)
+ if !leaf(name) || !leaf(name+".tmp") {
+ return BackupInfo{}, ErrRefused
+ }
+ j, e := existingJournal(statePath, floorPath, password)
+ if e != nil {
+ return BackupInfo{}, e
+ }
+ defer closeExisting(j)
+ j.mu.Lock()
+ defer j.mu.Unlock()
+ point, _, e := j.floor.current()
+ if e != nil {
+ return BackupInfo{}, e
+ }
+ snapshot, e := ownedFile(j.dir, "backup.snapshot")
+ if e != nil {
+ return BackupInfo{}, e
+ }
+ snapshot.Close()
+ defer unix.Unlinkat(int(j.dir.file.Fd()), "backup.snapshot", 0)
+ snapshotPath := filepath.Join(j.dir.file.Name(), "backup.snapshot")
+ if _, e = j.db.Exec("VACUUM INTO ?", snapshotPath); e != nil {
+ return BackupInfo{}, ErrUnavailable
+ }
+ dbFile, e := os.Open(snapshotPath)
+ if e != nil {
+ return BackupInfo{}, ErrUnavailable
+ }
+ defer dbFile.Close()
+ info, e := dbFile.Stat()
+ if e != nil || info.Size() > maxRegistryBytes {
+ return BackupInfo{}, ErrRefused
+ }
+ wrapped, e := j.dir.read("credential.age", 128<<10)
+ if e != nil {
+ return BackupInfo{}, e
+ }
+ genesis, e := j.dir.read("initializing.json", 8192)
+ if e != nil {
+ return BackupInfo{}, e
+ }
+ outputFile, e := ownedFile(destination, name+".tmp")
+ if e != nil {
+ return BackupInfo{}, e
+ }
+ defer outputFile.Close()
+ defer unix.Unlinkat(int(destination.file.Fd()), name+".tmp", 0)
+ recipient, e := age.NewScryptRecipient(string(password))
+ if e != nil {
+ return BackupInfo{}, ErrRefused
+ }
+ digest := sha256.New()
+ encrypted, e := age.Encrypt(io.MultiWriter(outputFile, digest), recipient)
+ if e != nil {
+ return BackupInfo{}, ErrUnavailable
+ }
+ archive := tar.NewWriter(encrypted)
+ for _, entry := range []struct {
+ name string
+ size int64
+ reader io.Reader
+ }{{"registry.db", info.Size(), dbFile}, {"credential.age", int64(len(wrapped)), strings.NewReader(string(wrapped))}, {"initializing.json", int64(len(genesis)), strings.NewReader(string(genesis))}} {
+ if archive.WriteHeader(&tar.Header{Name: entry.name, Mode: 0600, Size: entry.size, Typeflag: tar.TypeReg}) != nil {
+ return BackupInfo{}, ErrUnavailable
+ }
+ if n, e := io.Copy(archive, entry.reader); e != nil || n != entry.size {
+ return BackupInfo{}, ErrUnavailable
+ }
+ }
+ if archive.Close() != nil || encrypted.Close() != nil || outputFile.Sync() != nil || outputFile.Close() != nil {
+ return BackupInfo{}, ErrUnavailable
+ }
+ if unix.Renameat2(int(destination.file.Fd()), name+".tmp", int(destination.file.Fd()), name, unix.RENAME_NOREPLACE) != nil || destination.file.Sync() != nil {
+ return BackupInfo{}, ErrRefused
+ }
+ return BackupInfo{hex.EncodeToString(digest.Sum(nil)), point.Sequence}, nil
+}
+
+// Restore requires a separate empty destination and the surviving independent
+// floor. A failed validation never updates that floor or grants dispatch.
+func Restore(statePath, floorPath, input string, password []byte) (RegistryInfo, error) {
+ if !filepath.IsAbs(input) || filepath.Clean(input) != input || len(password) == 0 || len(password) > 1024 || statePath == floorPath || strings.HasPrefix(statePath, floorPath+"/") || strings.HasPrefix(floorPath, statePath+"/") {
+ return RegistryInfo{}, ErrRefused
+ }
+ state, e := openPrivateDirectory(statePath)
+ if e != nil {
+ return RegistryInfo{}, e
+ }
+ defer state.close()
+ names, e := directoryNames(state)
+ if e != nil || len(names) != 0 {
+ return RegistryInfo{}, ErrRefused
+ }
+ lock, e := state.lock()
+ if e != nil {
+ return RegistryInfo{}, e
+ }
+ defer lock.Close()
+ source, e := openPrivateDirectory(filepath.Dir(input))
+ if e != nil {
+ return RegistryInfo{}, e
+ }
+ defer source.close()
+ fd, e := unix.Openat(int(source.file.Fd()), filepath.Base(input), unix.O_RDONLY|unix.O_NOFOLLOW|unix.O_NONBLOCK|unix.O_CLOEXEC, 0)
+ if e != nil {
+ return RegistryInfo{}, ErrRefused
+ }
+ f := os.NewFile(uintptr(fd), "private-backup")
+ defer f.Close()
+ info, e := f.Stat()
+ if e != nil || !info.Mode().IsRegular() || info.Mode().Perm() != 0600 || info.Size() > maxRegistryBytes+2<<20 {
+ return RegistryInfo{}, ErrRefused
+ }
+ identity, e := age.NewScryptIdentity(string(password))
+ if e != nil {
+ return RegistryInfo{}, ErrRefused
+ }
+ identity.SetMaxWorkFactor(18)
+ decrypted, e := age.Decrypt(f, identity)
+ if e != nil {
+ return RegistryInfo{}, ErrRefused
+ }
+ archive := tar.NewReader(decrypted)
+ seen := map[string]bool{}
+ for {
+ header, e := archive.Next()
+ if e == io.EOF {
+ break
+ }
+ if e != nil {
+ return RegistryInfo{}, ErrRefused
+ }
+ limit := int64(0)
+ switch header.Name {
+ case "registry.db":
+ limit = maxRegistryBytes
+ case "credential.age":
+ limit = 128 << 10
+ case "initializing.json":
+ limit = 8192
+ default:
+ return RegistryInfo{}, ErrRefused
+ }
+ if seen[header.Name] || header.Typeflag != tar.TypeReg || header.Size < 0 || header.Size > limit || header.Mode != 0600 {
+ return RegistryInfo{}, ErrRefused
+ }
+ seen[header.Name] = true
+ output, e := ownedFile(state, ".restore-"+header.Name)
+ if e != nil {
+ return RegistryInfo{}, e
+ }
+ n, copyErr := io.Copy(output, archive)
+ syncErr := output.Sync()
+ closeErr := output.Close()
+ if copyErr != nil || syncErr != nil || closeErr != nil || n != header.Size {
+ return RegistryInfo{}, ErrRefused
+ }
+ }
+ trailing, e := io.ReadAll(io.LimitReader(decrypted, 1))
+ if e != nil || len(trailing) != 0 || len(seen) != 3 || state.file.Sync() != nil {
+ return RegistryInfo{}, ErrRefused
+ }
+ for _, name := range []string{"registry.db", "credential.age", "initializing.json"} {
+ if unix.Renameat2(int(state.file.Fd()), ".restore-"+name, int(state.file.Fd()), name, unix.RENAME_NOREPLACE) != nil {
+ return RegistryInfo{}, ErrRefused
+ }
+ }
+ if state.file.Sync() != nil {
+ return RegistryInfo{}, ErrUnavailable
+ }
+ lock.Close()
+ j, e := existingJournal(statePath, floorPath, password)
+ if e != nil {
+ return RegistryInfo{}, e
+ }
+ defer closeExisting(j)
+ point, _, e := j.floor.current()
+ if e != nil {
+ return RegistryInfo{}, e
+ }
+ return RegistryInfo{j.floor.identity.Registry, j.floor.identity.Device, j.floor.identity.Public, point.Sequence}, nil
+}
diff --git a/internal/provisioner/backup_linux_test.go b/internal/provisioner/backup_linux_test.go
new file mode 100644
index 0000000..977c3bc
--- /dev/null
+++ b/internal/provisioner/backup_linux_test.go
@@ -0,0 +1,119 @@
+//go:build linux
+
+package provisioner
+
+import (
+ "archive/tar"
+ "bytes"
+ "context"
+ "filippo.io/age"
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestEncryptedBackupRestoreAndSurvivingFloor(t *testing.T) {
+ state, floor, backups := persistentDirectory(t), persistentDirectory(t), persistentDirectory(t)
+ password := []byte("synthetic backup password")
+ initial, e := Initialize(state, floor, password)
+ if e != nil {
+ t.Fatal(e)
+ }
+ j, e := existingJournal(state, floor, password)
+ if e != nil {
+ t.Fatal(e)
+ }
+ payload := []byte("PRIVATE-BACKUP-OWNER@example.invalid")
+ if _, e = j.append(context.Background(), testToken(5), testToken(6), "directory.seed-team", "empty", payload); e != nil {
+ t.Fatal(e)
+ }
+ closeExisting(j)
+ path := filepath.Join(backups, "current.age")
+ info, e := Backup(state, floor, path, password)
+ if e != nil || info.Sequence != 1 || len(info.Digest) != 64 {
+ t.Fatal("backup", e)
+ }
+ data, e := os.ReadFile(path)
+ if e != nil {
+ t.Fatal(e)
+ }
+ if bytes.Contains(data, payload) || bytes.Contains(data, []byte(initial.Registry)) {
+ t.Fatal("plaintext archive")
+ }
+ restored := persistentDirectory(t)
+ got, e := Restore(restored, floor, path, password)
+ if e != nil || got.Registry != initial.Registry || got.Sequence != 1 {
+ t.Fatal("restore", e)
+ }
+ j, e = existingJournal(restored, floor, password)
+ if e != nil {
+ t.Fatal(e)
+ }
+ if _, e = j.append(context.Background(), testToken(5), testToken(6), "directory.seed-team", "empty", payload); e != nil {
+ t.Fatal("restored private replay", e)
+ }
+ if _, e = j.append(context.Background(), testToken(5), testToken(7), "directory.activate-consumer", "pending", []byte("next private record")); e != nil {
+ t.Fatal(e)
+ }
+ closeExisting(j)
+ stale := persistentDirectory(t)
+ if _, e = Restore(stale, floor, path, password); e != ErrRefused {
+ t.Fatal("old archive accepted", e)
+ }
+ if _, e = Restore(persistentDirectory(t), floor, path, []byte("wrong")); e != ErrRefused {
+ t.Fatal("wrong archive password", e)
+ }
+ if _, e = Backup(restored, floor, path, password); e != ErrRefused {
+ t.Fatal("exclusive archive replaced", e)
+ }
+}
+
+func TestRestoreRefusesUnauthenticatedCompletionAndLinks(t *testing.T) {
+ state, floor, backups := persistentDirectory(t), persistentDirectory(t), persistentDirectory(t)
+ password := []byte("synthetic malformed archive")
+ if _, e := Initialize(state, floor, password); e != nil {
+ t.Fatal(e)
+ }
+ path := filepath.Join(backups, "good.age")
+ if _, e := Backup(state, floor, path, password); e != nil {
+ t.Fatal(e)
+ }
+ data, e := os.ReadFile(path)
+ if e != nil {
+ t.Fatal(e)
+ }
+ damaged := filepath.Join(backups, "truncated.age")
+ if e = os.WriteFile(damaged, data[:len(data)-10], 0600); e != nil {
+ t.Fatal(e)
+ }
+ target := persistentDirectory(t)
+ if _, e = Restore(target, floor, damaged, password); e != ErrRefused {
+ t.Fatal("truncated encrypted stream accepted", e)
+ }
+ if _, e = os.Stat(filepath.Join(target, "credential.age")); !os.IsNotExist(e) {
+ t.Fatal("unauthenticated archive published credentials")
+ }
+ recipient, e := age.NewScryptRecipient(string(password))
+ if e != nil {
+ t.Fatal(e)
+ }
+ var ciphertext bytes.Buffer
+ encrypted, e := age.Encrypt(&ciphertext, recipient)
+ if e != nil {
+ t.Fatal(e)
+ }
+ archive := tar.NewWriter(encrypted)
+ if e = archive.WriteHeader(&tar.Header{Name: "registry.db", Mode: 0600, Typeflag: tar.TypeSymlink, Linkname: "outside"}); e != nil {
+ t.Fatal(e)
+ }
+ if archive.Close() != nil || encrypted.Close() != nil {
+ t.Fatal("synthetic archive")
+ }
+ link := filepath.Join(backups, "link.age")
+ if e = os.WriteFile(link, ciphertext.Bytes(), 0600); e != nil {
+ t.Fatal(e)
+ }
+ if _, e = Restore(persistentDirectory(t), floor, link, password); e != ErrRefused {
+ t.Fatal("archive link accepted", e)
+ }
+}
diff --git a/internal/provisioner/cli_linux.go b/internal/provisioner/cli_linux.go
index d87c1af..af8895f 100644
--- a/internal/provisioner/cli_linux.go
+++ b/internal/provisioner/cli_linux.go
@@ -11,13 +11,15 @@ import (
)
func Run(args []string, out io.Writer) error {
- if len(args) < 2 || args[0] != "provision" || args[1] != "init" || out == nil {
+ if len(args) < 2 || args[0] != "provision" || (args[1] != "init" && args[1] != "backup" && args[1] != "restore") || out == nil {
return ErrRefused
}
flags := flag.NewFlagSet("oficina provision init", flag.ContinueOnError)
flags.SetOutput(io.Discard)
state := flags.String("state", "", "")
floor := flags.String("floor-dir", "", "")
+ output := flags.String("output", "", "")
+ input := flags.String("input", "", "")
passwordFD := flags.Int("password-fd", -1, "")
if flags.Parse(args[2:]) != nil || flags.NArg() != 0 || *state == "" || *floor == "" {
return ErrRefused
@@ -27,7 +29,24 @@ func Run(args []string, out io.Writer) error {
return e
}
defer clear(password)
- info, e := Initialize(*state, *floor, password)
+ var info any
+ switch args[1] {
+ case "init":
+ if *output != "" || *input != "" {
+ return ErrRefused
+ }
+ info, e = Initialize(*state, *floor, password)
+ case "backup":
+ if *output == "" || *input != "" {
+ return ErrRefused
+ }
+ info, e = Backup(*state, *floor, *output, password)
+ case "restore":
+ if *input == "" || *output != "" {
+ return ErrRefused
+ }
+ info, e = Restore(*state, *floor, *input, password)
+ }
if e != nil {
return e
}