//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 }