Initial commit: DSH Web Manager

This commit is contained in:
2026-10-13 09:34:47 +08:00
commit 85332bbc56
135 changed files with 19712 additions and 0 deletions
+515
View File
@@ -0,0 +1,515 @@
// Package backup handles configuration archives. Callers must stop containers,
// exclude concurrent filesystem writers, and manage rollback across files.
package backup
import (
"archive/tar"
"bytes"
"compress/gzip"
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path"
"path/filepath"
"sort"
"strings"
"time"
)
const (
MaxTotalBytes = 32 << 20
MaxFiles = 4096
maxArchiveBytes = 4*((MaxTotalBytes+2)/3) + 4096
magic = "DSHBACKUP\x01"
magicV2 = "DSHBACKUP\x02"
SchemaVersion = 2
maxManifestBytes = 4096
maxTarBytes = MaxTotalBytes + MaxFiles*4096 + maxManifestBytes
)
var allowed = [...]string{".credentials.yaml", "cordis.patch.yml", "profiles/web/package.json", "profiles/web/cordis.patch.yml", "profiles/web/pnpm-lock.yaml", "dsh-ssh.json"}
var resources = [...]string{"skins", "pets", "agent-presets", ".agent-presets"}
type Metadata struct {
DSHVersion string `json:"dsh_version"`
CreatedAt time.Time `json:"created_at"`
}
type manifest struct {
SchemaVersion int `json:"schema_version"`
Metadata
}
func allowedPath(name string, recursive bool) bool {
if name == "." || len(name) > 1024 || path.Clean(name) != name || strings.ContainsAny(name, "\\\x00:") || path.IsAbs(name) {
return false
}
for _, valid := range allowed {
if name == valid {
return true
}
}
if recursive {
for _, dir := range resources {
if strings.HasPrefix(name, dir+"/") {
return true
}
}
}
return false
}
type payload struct {
Version int `json:"version"`
Files map[string][]byte `json:"files"`
}
func newGCM(key []byte) (cipher.AEAD, error) {
b, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
return cipher.NewGCM(b)
}
func openHome(home string) (*os.Root, error) {
info, err := os.Lstat(home)
if err != nil {
return nil, err
}
if info.Mode()&os.ModeSymlink != 0 {
return nil, errors.New("home must not be a symlink")
}
return os.OpenRoot(home)
}
// os.Root permits in-root symlinks, so reject them explicitly in each component.
func checkPath(root *os.Root, name string) error {
parts := strings.Split(name, "/")
for i := range parts {
info, err := root.Lstat(strings.Join(parts[:i+1], "/"))
if errors.Is(err, os.ErrNotExist) {
return nil
}
if err != nil {
return err
}
if info.Mode()&os.ModeSymlink != 0 {
return fmt.Errorf("symlink rejected: %s", name)
}
if i < len(parts)-1 && !info.IsDir() {
return fmt.Errorf("not a directory: %s", name)
}
if i == len(parts)-1 && !info.Mode().IsRegular() {
return fmt.Errorf("not a regular file: %s", name)
}
}
return nil
}
func readBounded(f *os.File, limit int64) ([]byte, error) {
info, err := f.Stat()
if err != nil {
return nil, err
}
if !info.Mode().IsRegular() {
return nil, errors.New("not a regular file")
}
if info.Size() > limit {
return nil, errors.New("backup size limit exceeded")
}
b, err := io.ReadAll(io.LimitReader(f, limit+1))
if err != nil {
return nil, err
}
if int64(len(b)) > limit {
return nil, errors.New("backup size limit exceeded")
}
return b, nil
}
// Create writes a 0600 archive using a raw 16, 24, or 32 byte AES key.
// Missing allowlisted files are omitted; destination's parent must exist.
func Create(home, destination string, key []byte) error {
return CreateWithMetadata(home, destination, key, Metadata{})
}
// CreateWithMetadata writes an AES-GCM encrypted tar.gz with an authenticated
// manifest. Empty resource directories are omitted; callers must exclude writers.
func CreateWithMetadata(home, destination string, key []byte, metadata Metadata) error {
gcm, err := newGCM(key)
if err != nil {
return err
}
root, err := openHome(home)
if err != nil {
return err
}
defer root.Close()
p := payload{Version: SchemaVersion, Files: make(map[string][]byte)}
remaining := int64(MaxTotalBytes)
addFile := func(name string) error {
if !allowedPath(name, true) {
return fmt.Errorf("path not allowed: %q", name)
}
if err := checkPath(root, name); err != nil {
return err
}
f, err := root.Open(name)
if errors.Is(err, os.ErrNotExist) {
return nil
}
if err != nil {
return err
}
data, readErr := readBounded(f, remaining)
closeErr := f.Close()
if readErr != nil {
return fmt.Errorf("%s: %w", name, readErr)
}
if closeErr != nil {
return closeErr
}
remaining -= int64(len(data))
p.Files[name] = data
if len(p.Files) > MaxFiles {
return errors.New("backup file count limit exceeded")
}
return nil
}
for _, name := range allowed {
if err := addFile(name); err != nil {
return err
}
}
visited := 0
var walk func(string) error
walk = func(name string) error {
visited++
if visited > MaxFiles*2 || len(name) > 1024 {
return errors.New("resource traversal limit exceeded")
}
info, err := root.Lstat(name)
if err != nil {
return err
}
if !info.IsDir() {
return addFile(name)
}
dir, err := root.Open(name)
if err != nil {
return err
}
defer dir.Close()
for {
entries, err := dir.ReadDir(1)
if err == io.EOF {
return nil
}
if err != nil {
return err
}
for _, entry := range entries {
if err := walk(name + "/" + entry.Name()); err != nil {
return err
}
}
}
}
for _, name := range resources {
info, err := root.Lstat(name)
if errors.Is(err, os.ErrNotExist) {
continue
}
if err != nil {
return err
}
if !info.IsDir() {
return fmt.Errorf("resource root is not a directory: %s", name)
}
if err := walk(name); err != nil {
return err
}
}
plain, err := encodeV2(p.Files, metadata)
if err != nil {
return err
}
nonce := make([]byte, gcm.NonceSize())
if _, err := rand.Read(nonce); err != nil {
return err
}
archive := append([]byte(magicV2), nonce...)
archive = gcm.Seal(archive, nonce, plain, []byte(magicV2))
if len(archive) > maxArchiveBytes {
return errors.New("backup archive size limit exceeded")
}
destRoot, err := os.OpenRoot(filepath.Dir(destination))
if err != nil {
return err
}
defer destRoot.Close()
return replace(destRoot, filepath.Base(destination), archive)
}
// Restore authenticates and validates everything before changing home. Files
// absent from the archive remain untouched. Replacement is atomic per file only.
func Restore(archive, home string, key []byte) error {
gcm, err := newGCM(key)
if err != nil {
return err
}
aroot, err := os.OpenRoot(filepath.Dir(archive))
if err != nil {
return err
}
defer aroot.Close()
if err := checkPath(aroot, filepath.Base(archive)); err != nil {
return err
}
f, err := aroot.Open(filepath.Base(archive))
if err != nil {
return err
}
data, readErr := readBounded(f, maxArchiveBytes)
closeErr := f.Close()
if readErr != nil {
return readErr
}
if closeErr != nil {
return closeErr
}
if len(data) < len(magic)+gcm.NonceSize()+gcm.Overhead() || (!bytes.HasPrefix(data, []byte(magic)) && !bytes.HasPrefix(data, []byte(magicV2))) {
return errors.New("invalid backup format")
}
nonce := data[len(magic) : len(magic)+gcm.NonceSize()]
header := string(data[:len(magic)])
plain, err := gcm.Open(nil, nonce, data[len(magic)+gcm.NonceSize():], []byte(header))
if err != nil {
return fmt.Errorf("authenticate backup: %w", err)
}
var p payload
if header == magicV2 {
p.Files, err = decodeV2(plain)
if err != nil {
return err
}
} else {
if err := decodeJSON(plain, &p); err != nil {
return err
}
if p.Version != 1 || p.Files == nil {
return errors.New("unsupported backup payload")
}
}
if len(p.Files) > MaxFiles {
return errors.New("backup file count limit exceeded")
}
total := 0
for name, data := range p.Files {
if !allowedPath(name, header == magicV2) {
return fmt.Errorf("path not allowed: %q", name)
}
for parent := path.Dir(name); parent != "."; parent = path.Dir(parent) {
if _, exists := p.Files[parent]; exists {
return errors.New("conflicting backup paths")
}
}
total += len(data)
if total > MaxTotalBytes {
return errors.New("backup size limit exceeded")
}
}
root, err := openHome(home)
if err != nil {
return err
}
defer root.Close()
for name := range p.Files {
if err := checkPath(root, name); err != nil {
return err
}
}
for name, data := range p.Files {
if err := root.MkdirAll(filepath.Dir(name), 0700); err != nil {
return err
}
if err := replace(root, name, data); err != nil {
return err
}
}
return nil
}
func decodeJSON(data []byte, value any) error {
d := json.NewDecoder(bytes.NewReader(data))
d.DisallowUnknownFields()
if err := d.Decode(value); err != nil {
return err
}
if err := d.Decode(new(any)); err != io.EOF {
return errors.New("trailing backup data")
}
return nil
}
func encodeV2(files map[string][]byte, metadata Metadata) ([]byte, error) {
if metadata.CreatedAt.IsZero() {
metadata.CreatedAt = time.Now().UTC()
}
if metadata.DSHVersion == "" {
metadata.DSHVersion = "unknown"
}
data, err := json.Marshal(manifest{SchemaVersion, metadata})
if err != nil {
return nil, err
}
if len(data) > maxManifestBytes {
return nil, errors.New("manifest size limit exceeded")
}
var out bytes.Buffer
gz := gzip.NewWriter(&out)
tw := tar.NewWriter(gz)
write := func(name string, data []byte) error {
if err := tw.WriteHeader(&tar.Header{Name: name, Mode: 0600, Size: int64(len(data)), Typeflag: tar.TypeReg, Format: tar.FormatPAX}); err != nil {
return err
}
_, err := tw.Write(data)
return err
}
if err := write("manifest.json", data); err != nil {
return nil, err
}
names := make([]string, 0, len(files))
for name := range files {
names = append(names, name)
}
sort.Strings(names)
for _, name := range names {
if err := write(name, files[name]); err != nil {
return nil, err
}
}
if err := tw.Close(); err != nil {
return nil, err
}
if err := gz.Close(); err != nil {
return nil, err
}
return out.Bytes(), nil
}
func decodeV2(plain []byte) (map[string][]byte, error) {
source := bytes.NewReader(plain)
gz, err := gzip.NewReader(source)
if err != nil {
return nil, err
}
defer gz.Close()
gz.Multistream(false)
unpacked, err := io.ReadAll(io.LimitReader(gz, maxTarBytes+1))
if err != nil {
return nil, err
}
if len(unpacked) > maxTarBytes {
return nil, errors.New("expanded archive size limit exceeded")
}
if source.Len() != 0 {
return nil, errors.New("trailing gzip data")
}
input := bytes.NewReader(unpacked)
tr := tar.NewReader(input)
files := make(map[string][]byte)
seenManifest := false
remaining := int64(MaxTotalBytes)
for {
h, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
return nil, err
}
if h.Typeflag != tar.TypeReg || h.Linkname != "" {
return nil, errors.New("non-regular archive entry")
}
limit := remaining
if h.Name == "manifest.json" {
if seenManifest {
return nil, errors.New("duplicate manifest")
}
limit = maxManifestBytes
} else {
if !allowedPath(h.Name, true) {
return nil, fmt.Errorf("path not allowed: %q", h.Name)
}
if _, exists := files[h.Name]; exists {
return nil, errors.New("duplicate archive entry")
}
if len(files) >= MaxFiles {
return nil, errors.New("backup file count limit exceeded")
}
}
if h.Size < 0 || h.Size > limit {
return nil, errors.New("backup size limit exceeded")
}
data, err := io.ReadAll(tr)
if err != nil {
return nil, err
}
if h.Name == "manifest.json" {
var m manifest
if err := decodeJSON(data, &m); err != nil {
return nil, err
}
if m.SchemaVersion != SchemaVersion || m.CreatedAt.IsZero() || m.DSHVersion == "" {
return nil, errors.New("invalid manifest")
}
seenManifest = true
} else {
files[h.Name] = data
remaining -= int64(len(data))
}
}
if !seenManifest || input.Len() != 0 {
return nil, errors.New("missing manifest or trailing tar data")
}
return files, nil
}
func replace(root *os.Root, name string, data []byte) error {
if err := checkPath(root, name); err != nil {
return err
}
var random [16]byte
if _, err := rand.Read(random[:]); err != nil {
return err
}
tmp := filepath.Join(filepath.Dir(name), ".backup-"+hex.EncodeToString(random[:]))
f, err := root.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600)
if err != nil {
return err
}
defer root.Remove(tmp)
_, writeErr := f.Write(data)
if writeErr == nil {
writeErr = f.Sync()
}
closeErr := f.Close()
if writeErr != nil {
return writeErr
}
if closeErr != nil {
return closeErr
}
if err := checkPath(root, name); err != nil {
return err
}
return root.Rename(tmp, name)
}
+521
View File
@@ -0,0 +1,521 @@
package backup
import (
"archive/tar"
"bytes"
"compress/gzip"
"encoding/json"
"fmt"
"io"
"net"
"os"
"path/filepath"
"testing"
"time"
)
var testKey = bytes.Repeat([]byte{42}, 32)
func put(t *testing.T, name string, data []byte) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(name), 0700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(name, data, 0600); err != nil {
t.Fatal(err)
}
}
func sealed(t *testing.T, p payload) string {
t.Helper()
plain, err := json.Marshal(p)
if err != nil {
t.Fatal(err)
}
return sealPlain(t, magic, plain)
}
func sealPlain(t *testing.T, header string, plain []byte) string {
t.Helper()
gcm, err := newGCM(testKey)
if err != nil {
t.Fatal(err)
}
nonce := make([]byte, gcm.NonceSize())
data := append([]byte(header), nonce...)
data = gcm.Seal(data, nonce, plain, []byte(header))
name := filepath.Join(t.TempDir(), "archive")
put(t, name, data)
return name
}
func TestResourcesAndManifest(t *testing.T) {
home, dest := t.TempDir(), t.TempDir()
for _, dir := range resources {
put(t, filepath.Join(home, dir, "nested/资源.bin"), []byte{0, 1, 255})
}
for _, name := range []string{"node_modules/pkg", "worktrees/x", "sessions/x", "logs/x", "manifest.json"} {
put(t, filepath.Join(home, name), []byte("excluded"))
}
metadata := Metadata{DSHVersion: "1.2.3", CreatedAt: time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)}
archive := filepath.Join(t.TempDir(), "archive")
if err := CreateWithMetadata(home, archive, testKey, metadata); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(archive)
if err != nil {
t.Fatal(err)
}
if !bytes.HasPrefix(data, []byte(magicV2)) {
t.Fatal("not v2")
}
gcm, _ := newGCM(testKey)
nonce := data[len(magicV2) : len(magicV2)+gcm.NonceSize()]
plain, err := gcm.Open(nil, nonce, data[len(magicV2)+gcm.NonceSize():], []byte(magicV2))
if err != nil {
t.Fatal(err)
}
gz, err := gzip.NewReader(bytes.NewReader(plain))
if err != nil {
t.Fatal(err)
}
defer gz.Close()
tr := tar.NewReader(gz)
h, err := tr.Next()
if err != nil || h.Name != "manifest.json" {
t.Fatal("missing manifest", err)
}
b, err := io.ReadAll(tr)
if err != nil {
t.Fatal(err)
}
var m manifest
if err := json.Unmarshal(b, &m); err != nil {
t.Fatal(err)
}
if m.SchemaVersion != SchemaVersion || m.Metadata != metadata {
t.Fatalf("manifest: %+v", m)
}
if err := Restore(archive, dest, testKey); err != nil {
t.Fatal(err)
}
for _, dir := range resources {
b, err := os.ReadFile(filepath.Join(dest, dir, "nested/资源.bin"))
if err != nil || !bytes.Equal(b, []byte{0, 1, 255}) {
t.Fatal(dir, err, b)
}
}
if _, err := os.Stat(filepath.Join(dest, "manifest.json")); !os.IsNotExist(err) {
t.Fatal("manifest restored into home")
}
}
func TestLegacyRestore(t *testing.T) {
home := t.TempDir()
if err := Restore(sealed(t, payload{1, map[string][]byte{allowed[0]: []byte("legacy")}}), home, testKey); err != nil {
t.Fatal(err)
}
b, err := os.ReadFile(filepath.Join(home, allowed[0]))
if err != nil || string(b) != "legacy" {
t.Fatal(err, string(b))
}
}
type tarEntry struct {
name string
kind byte
data []byte
}
func makeTar(t *testing.T, entries []tarEntry) []byte {
t.Helper()
var b bytes.Buffer
gz := gzip.NewWriter(&b)
tw := tar.NewWriter(gz)
for _, e := range entries {
h := &tar.Header{Name: e.name, Typeflag: e.kind, Mode: 0600, Size: int64(len(e.data))}
if e.kind == tar.TypeSymlink || e.kind == tar.TypeLink {
h.Linkname = "target"
}
if err := tw.WriteHeader(h); err != nil {
t.Fatal(err)
}
if _, err := tw.Write(e.data); err != nil {
t.Fatal(err)
}
}
if err := tw.Close(); err != nil {
t.Fatal(err)
}
if err := gz.Close(); err != nil {
t.Fatal(err)
}
return b.Bytes()
}
func TestV2RejectsInvalidArchivesBeforeWrites(t *testing.T) {
m, _ := json.Marshal(manifest{SchemaVersion, Metadata{"test", time.Now().UTC()}})
base := []tarEntry{{"manifest.json", tar.TypeReg, m}, {allowed[0], tar.TypeReg, []byte("new")}}
cases := map[string][]tarEntry{
"missing manifest": base[1:],
"duplicate manifest": append(append([]tarEntry{}, base...), base[0]),
"duplicate file": append(append([]tarEntry{}, base...), base[1]),
"invalid manifest": {{"manifest.json", tar.TypeReg, []byte(`{"schema_version":3}`)}},
"conflict": append(append([]tarEntry{}, base...), tarEntry{"pets/a", tar.TypeReg, nil}, tarEntry{"pets/a/b", tar.TypeReg, nil}),
}
for _, name := range []string{"pets/../escape", "pets//a", "pets/./a", "/pets/a", "pets/a/../../escape", "pets/a\\b", "pets/C:bad", "pets", "other/a"} {
cases[name] = append(append([]tarEntry{}, base...), tarEntry{name, tar.TypeReg, nil})
}
for _, kind := range []byte{tar.TypeSymlink, tar.TypeLink, tar.TypeFifo, tar.TypeChar, tar.TypeBlock, tar.TypeDir} {
cases[fmt.Sprint(kind)] = append(append([]tarEntry{}, base...), tarEntry{"pets/unsafe", kind, nil})
}
for name, entries := range cases {
t.Run(name, func(t *testing.T) {
home := t.TempDir()
put(t, filepath.Join(home, allowed[0]), []byte("old"))
if err := Restore(sealPlain(t, magicV2, makeTar(t, entries)), home, testKey); err == nil {
t.Fatal("accepted invalid archive")
}
b, _ := os.ReadFile(filepath.Join(home, allowed[0]))
if string(b) != "old" {
t.Fatal("partial write")
}
})
}
}
func TestResourceFilesystemRejections(t *testing.T) {
for _, name := range []string{"pets", "pets/nested", "pets/nested/file"} {
t.Run(name, func(t *testing.T) {
home := t.TempDir()
if err := os.MkdirAll(filepath.Dir(filepath.Join(home, name)), 0700); err != nil {
t.Fatal(err)
}
if err := os.Symlink(t.TempDir(), filepath.Join(home, name)); err != nil {
t.Fatal(err)
}
if err := Create(home, filepath.Join(t.TempDir(), "archive"), testKey); err == nil {
t.Fatal("followed resource symlink")
}
p, err := encodeV2(map[string][]byte{"pets/nested/file": []byte("bad")}, Metadata{})
if err != nil {
t.Fatal(err)
}
if err := Restore(sealPlain(t, magicV2, p), home, testKey); err == nil {
t.Fatal("restore followed symlink")
}
})
}
home := t.TempDir()
if err := os.Mkdir(filepath.Join(home, "pets"), 0700); err != nil {
t.Fatal(err)
}
l, err := net.Listen("unix", filepath.Join(home, "pets/socket"))
if err != nil {
t.Fatal(err)
}
defer l.Close()
if err := Create(home, filepath.Join(t.TempDir(), "archive"), testKey); err == nil {
t.Fatal("special file accepted")
}
}
func TestV2LimitsAndCorruption(t *testing.T) {
files := make(map[string][]byte)
home := t.TempDir()
for i := 0; i < MaxFiles; i++ {
name := fmt.Sprintf("pets/%d", i)
files[name] = nil
put(t, filepath.Join(home, name), nil)
}
archive := filepath.Join(t.TempDir(), "archive")
if err := Create(home, archive, testKey); err != nil {
t.Fatal(err)
}
if err := Restore(archive, t.TempDir(), testKey); err != nil {
t.Fatal(err)
}
put(t, filepath.Join(home, "pets/extra"), nil)
if err := Create(home, archive, testKey); err == nil {
t.Fatal("file count accepted")
}
files["pets/extra"] = nil
p, err := encodeV2(files, Metadata{})
if err != nil {
t.Fatal(err)
}
if _, err := decodeV2(p); err == nil {
t.Fatal("archive file count accepted")
}
p, err = encodeV2(map[string][]byte{"pets/large": make([]byte, MaxTotalBytes+1)}, Metadata{})
if err != nil {
t.Fatal(err)
}
if _, err := decodeV2(p); err == nil {
t.Fatal("expanded file limit accepted")
}
p, err = encodeV2(nil, Metadata{})
if err != nil {
t.Fatal(err)
}
badChecksum := bytes.Clone(p)
badChecksum[len(badChecksum)-8] ^= 1
for _, bad := range [][]byte{p[:len(p)-1], append(bytes.Clone(p), 0), append(bytes.Clone(p), p...), badChecksum} {
if _, err := decodeV2(bad); err == nil {
t.Fatal("corrupt gzip accepted")
}
}
if _, err := encodeV2(nil, Metadata{DSHVersion: string(make([]byte, maxManifestBytes))}); err == nil {
t.Fatal("oversized manifest accepted")
}
}
func TestRoundTrip(t *testing.T) {
home, dest := t.TempDir(), t.TempDir()
for _, name := range allowed {
put(t, filepath.Join(home, name), []byte("secret:"+name))
}
put(t, filepath.Join(home, "not-allowed"), []byte("ignore"))
archive := filepath.Join(t.TempDir(), "archive")
if err := Create(home, archive, testKey); err != nil {
t.Fatal(err)
}
first, err := os.ReadFile(archive)
if err != nil {
t.Fatal(err)
}
if bytes.Contains(first, []byte("secret:")) {
t.Fatal("plaintext leaked")
}
if err := Create(home, archive, testKey); err != nil {
t.Fatal(err)
}
second, err := os.ReadFile(archive)
if err != nil {
t.Fatal(err)
}
if bytes.Equal(first, second) {
t.Fatal("nonce reused")
}
put(t, filepath.Join(dest, allowed[0]), []byte("old"))
if err := os.Chmod(filepath.Join(dest, allowed[0]), 0644); err != nil {
t.Fatal(err)
}
if err := Restore(archive, dest, testKey); err != nil {
t.Fatal(err)
}
for _, name := range allowed {
data, err := os.ReadFile(filepath.Join(dest, name))
if err != nil {
t.Fatal(err)
}
if string(data) != "secret:"+name {
t.Fatalf("wrong data: %s", name)
}
info, err := os.Stat(filepath.Join(dest, name))
if err != nil {
t.Fatal(err)
}
if info.Mode().Perm() != 0600 {
t.Fatalf("mode: %v", info.Mode())
}
}
if _, err := os.Stat(filepath.Join(dest, "not-allowed")); !os.IsNotExist(err) {
t.Fatal("unlisted file restored")
}
info, err := os.Stat(archive)
if err != nil {
t.Fatal(err)
}
if info.Mode().Perm() != 0600 {
t.Fatal("archive permissions")
}
}
func TestAuthentication(t *testing.T) {
archive := sealed(t, payload{1, map[string][]byte{allowed[0]: []byte("new")}})
original, err := os.ReadFile(archive)
if err != nil {
t.Fatal(err)
}
for _, offset := range []int{0, len(magic), len(original) - 1} {
t.Run(fmt.Sprint(offset), func(t *testing.T) {
data := bytes.Clone(original)
data[offset] ^= 1
put(t, archive, data)
home := t.TempDir()
put(t, filepath.Join(home, allowed[0]), []byte("old"))
if err := Restore(archive, home, testKey); err == nil {
t.Fatal("tampering accepted")
}
got, _ := os.ReadFile(filepath.Join(home, allowed[0]))
if string(got) != "old" {
t.Fatal("modified on failure")
}
})
}
put(t, archive, original)
if err := Restore(archive, t.TempDir(), bytes.Repeat([]byte{1}, 32)); err == nil {
t.Fatal("wrong key accepted")
}
put(t, archive, original[:len(original)-1])
if err := Restore(archive, t.TempDir(), testKey); err == nil {
t.Fatal("truncation accepted")
}
}
func TestInvalidPathsAndVersion(t *testing.T) {
for _, name := range []string{"../escape", "/tmp/escape", "profiles/web/../../../escape", "profiles//web/package.json", "other", "profiles\\web\\package.json"} {
t.Run(name, func(t *testing.T) {
home := t.TempDir()
put(t, filepath.Join(home, allowed[0]), []byte("old"))
archive := sealed(t, payload{1, map[string][]byte{allowed[0]: []byte("new"), name: []byte("bad")}})
if err := Restore(archive, home, testKey); err == nil {
t.Fatal("invalid path accepted")
}
got, _ := os.ReadFile(filepath.Join(home, allowed[0]))
if string(got) != "old" {
t.Fatal("partial restore before validation")
}
})
}
if err := Restore(sealed(t, payload{2, map[string][]byte{}}), t.TempDir(), testKey); err == nil {
t.Fatal("version accepted")
}
files := map[string][]byte{}
for i := 0; i < MaxFiles+1; i++ {
files[fmt.Sprint(i)] = nil
}
if err := Restore(sealed(t, payload{1, files}), t.TempDir(), testKey); err == nil {
t.Fatal("file count accepted")
}
}
func TestSymlinks(t *testing.T) {
for _, outside := range []bool{false, true} {
for _, parent := range []bool{false, true} {
t.Run(fmt.Sprintf("outside=%v/parent=%v", outside, parent), func(t *testing.T) {
home := t.TempDir()
target := filepath.Join(home, "target")
linkTarget := "target"
if outside {
target = filepath.Join(t.TempDir(), "target")
linkTarget = target
}
name := allowed[0]
link := filepath.Join(home, name)
if parent {
name = "profiles/web/package.json"
link = filepath.Join(home, "profiles")
put(t, filepath.Join(target, "web/package.json"), []byte("safe"))
} else {
put(t, target, []byte("safe"))
}
if err := os.Symlink(linkTarget, link); err != nil {
t.Fatal(err)
}
if err := Create(home, filepath.Join(t.TempDir(), "archive"), testKey); err == nil {
t.Fatal("create followed symlink")
}
archive := sealed(t, payload{1, map[string][]byte{name: []byte("bad")}})
if err := Restore(archive, home, testKey); err == nil {
t.Fatal("restore followed symlink")
}
})
}
}
}
func TestSymlinkPreflightAndArchive(t *testing.T) {
home := t.TempDir()
put(t, filepath.Join(home, allowed[0]), []byte("old"))
target := filepath.Join(t.TempDir(), "target")
put(t, target, []byte("safe"))
if err := os.Symlink(target, filepath.Join(home, "dsh-ssh.json")); err != nil {
t.Fatal(err)
}
archive := sealed(t, payload{1, map[string][]byte{allowed[0]: []byte("new"), "dsh-ssh.json": []byte("bad")}})
if err := Restore(archive, home, testKey); err == nil {
t.Fatal("symlink accepted")
}
got, err := os.ReadFile(filepath.Join(home, allowed[0]))
if err != nil {
t.Fatal(err)
}
if string(got) != "old" {
t.Fatal("preflight modified earlier file")
}
got, err = os.ReadFile(target)
if err != nil {
t.Fatal(err)
}
if string(got) != "safe" {
t.Fatal("outside target modified")
}
link := filepath.Join(t.TempDir(), "archive-link")
if err := os.Symlink(archive, link); err != nil {
t.Fatal(err)
}
if err := Restore(link, t.TempDir(), testKey); err == nil {
t.Fatal("archive symlink accepted")
}
if err := Create(t.TempDir(), link, testKey); err == nil {
t.Fatal("destination symlink accepted")
}
}
func TestSizeLimits(t *testing.T) {
home := t.TempDir()
name := filepath.Join(home, allowed[0])
put(t, name, nil)
if err := os.Truncate(name, MaxTotalBytes+1); err != nil {
t.Fatal(err)
}
archive := filepath.Join(t.TempDir(), "archive")
if err := Create(home, archive, testKey); err == nil {
t.Fatal("oversized file accepted")
}
if err := os.Truncate(name, MaxTotalBytes); err != nil {
t.Fatal(err)
}
if err := Create(home, archive, testKey); err != nil {
t.Fatal("exact limit:", err)
}
if err := Restore(archive, t.TempDir(), testKey); err != nil {
t.Fatal("exact limit restore:", err)
}
put(t, filepath.Join(home, allowed[1]), []byte{1})
if err := Create(home, archive, testKey); err == nil {
t.Fatal("aggregate limit accepted")
}
archive = sealed(t, payload{1, map[string][]byte{allowed[0]: make([]byte, MaxTotalBytes), allowed[1]: {1}}})
if err := Restore(archive, t.TempDir(), testKey); err == nil {
t.Fatal("restore aggregate limit accepted")
}
put(t, archive, nil)
if err := os.Truncate(archive, maxArchiveBytes+1); err != nil {
t.Fatal(err)
}
if err := Restore(archive, t.TempDir(), testKey); err == nil {
t.Fatal("oversized archive accepted")
}
}
func TestMissingFilesAndInvalidKey(t *testing.T) {
archive := filepath.Join(t.TempDir(), "archive")
if err := Create(t.TempDir(), archive, nil); err == nil {
t.Fatal("invalid key accepted")
}
if err := Create(t.TempDir(), archive, testKey); err != nil {
t.Fatal(err)
}
home := t.TempDir()
put(t, filepath.Join(home, allowed[0]), []byte("keep"))
if err := Restore(archive, home, testKey); err != nil {
t.Fatal(err)
}
got, _ := os.ReadFile(filepath.Join(home, allowed[0]))
if string(got) != "keep" {
t.Fatal("missing file removed")
}
}
+102
View File
@@ -0,0 +1,102 @@
package backup
import (
"bytes"
"crypto/rand"
"errors"
"os"
"path/filepath"
"syscall"
"golang.org/x/crypto/argon2"
)
const passwordMagic = "DSHPASS\x01"
const MaxEncryptedBytes int64 = int64(maxArchiveBytes + len(passwordMagic) + 16)
// The format fixes KDF costs; untrusted archives cannot request arbitrary memory.
func passwordKey(password string, salt []byte) ([]byte, error) {
if len(password) == 0 || len(password) > 1024 {
return nil, errors.New("backup password required (maximum 1024 bytes)")
}
return argon2.IDKey([]byte(password), salt, 3, 64*1024, 2, 32), nil
}
func CreateWithPassword(home, destination, password string, metadata Metadata) error {
salt := make([]byte, 16)
if _, err := rand.Read(salt); err != nil {
return err
}
key, err := passwordKey(password, salt)
if err != nil {
return err
}
defer clear(key)
dir, err := os.MkdirTemp(filepath.Dir(destination), ".password-backup-")
if err != nil {
return err
}
defer os.RemoveAll(dir)
inner := filepath.Join(dir, "archive")
if err = CreateWithMetadata(home, inner, key, metadata); err != nil {
return err
}
data, err := os.ReadFile(inner)
if err != nil {
return err
}
data = append(append([]byte(passwordMagic), salt...), data...)
root, err := os.OpenRoot(filepath.Dir(destination))
if err != nil {
return err
}
defer root.Close()
return replace(root, filepath.Base(destination), data)
}
// Prepare authenticates and validates into a private staging home before the caller
// stops a container or snapshots its live home. Only ciphertext is retained.
func Prepare(archive, password string, legacyKey []byte) (restore func(string) error, cleanup func(), err error) {
f, err := os.OpenFile(archive, os.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0)
if err != nil {
return nil, nil, err
}
data, err := readBounded(f, MaxEncryptedBytes)
f.Close()
if err != nil {
return nil, nil, err
}
key := append([]byte(nil), legacyKey...)
if bytes.HasPrefix(data, []byte(passwordMagic)) {
if len(data) < len(passwordMagic)+16 {
return nil, nil, errors.New("truncated backup")
}
key, err = passwordKey(password, data[len(passwordMagic):len(passwordMagic)+16])
if err != nil {
return nil, nil, err
}
data = data[len(passwordMagic)+16:]
}
dir, err := os.MkdirTemp("", "dsh-backup-validate-")
if err != nil {
clear(key)
return nil, nil, err
}
cleanup = func() { clear(key); os.RemoveAll(dir) }
inner := filepath.Join(dir, "archive")
if err = os.WriteFile(inner, data, 0600); err == nil {
err = os.Mkdir(filepath.Join(dir, "home"), 0700)
}
if err == nil {
err = Restore(inner, filepath.Join(dir, "home"), key)
}
if err != nil {
cleanup()
return nil, nil, err
}
if err = os.RemoveAll(filepath.Join(dir, "home")); err != nil {
cleanup()
return nil, nil, err
}
return func(home string) error { return Restore(inner, home, key) }, cleanup, nil
}
+77
View File
@@ -0,0 +1,77 @@
package backup
import (
"bytes"
"os"
"path/filepath"
"testing"
)
func TestPasswordArchive(t *testing.T) {
home := t.TempDir()
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(home, ".credentials.yaml"), []byte("secret"), 0600); err != nil {
t.Fatal(err)
}
first, second := filepath.Join(dir, "first"), filepath.Join(dir, "second")
for _, path := range []string{first, second} {
if err := CreateWithPassword(home, path, "old-password", Metadata{DSHVersion: "test"}); err != nil {
t.Fatal(err)
}
}
a, _ := os.ReadFile(first)
b, _ := os.ReadFile(second)
if bytes.Equal(a[:len(passwordMagic)+16], b[:len(passwordMagic)+16]) {
t.Fatal("reused salt")
}
if bytes.Contains(a, []byte("secret")) {
t.Fatal("plaintext")
}
if _, _, err := Prepare(first, "wrong-password", nil); err == nil {
t.Fatal("accepted wrong password")
}
restore, cleanup, err := Prepare(first, "old-password", nil)
if err != nil {
t.Fatal(err)
}
defer cleanup()
destination := t.TempDir()
if err := restore(destination); err != nil {
t.Fatal(err)
}
restored, _ := os.ReadFile(filepath.Join(destination, ".credentials.yaml"))
if string(restored) != "secret" {
t.Fatal(string(restored))
}
a[len(passwordMagic)] ^= 1
os.WriteFile(second, a, 0600)
if _, _, err := Prepare(second, "old-password", nil); err == nil {
t.Fatal("accepted altered salt")
}
legacy := filepath.Join(dir, "legacy")
key := bytes.Repeat([]byte{9}, 32)
if err := Create(home, legacy, key); err != nil {
t.Fatal(err)
}
restore, legacyCleanup, err := Prepare(legacy, "", key)
if err != nil {
t.Fatal(err)
}
defer legacyCleanup()
if err := restore(t.TempDir()); err != nil {
t.Fatal(err)
}
os.WriteFile(second, []byte(passwordMagic), 0600)
if _, _, err := Prepare(second, "old-password", nil); err == nil {
t.Fatal("accepted truncated header")
}
f, err := os.Create(second)
if err != nil {
t.Fatal(err)
}
f.Truncate(MaxEncryptedBytes + 1)
f.Close()
if _, _, err := Prepare(second, "old-password", nil); err == nil {
t.Fatal("accepted oversized file")
}
}