oficina / home Public

Clone
git clone https://amadan.net/oficina/home

Plain git — no account needed to clone.

Download

Download this file

1//go:build linux
2
3package provisioner
4
5import (
6 "archive/tar"
7 "crypto/sha256"
8 "encoding/hex"
9 "io"
10 "os"
11 "path/filepath"
12 "strings"
13
14 "filippo.io/age"
15 "golang.org/x/sys/unix"
16)
17
18const maxRegistryBytes = int64(maxRecords) * (128 << 10)
19
20type BackupInfo struct {
21 Digest string `json:"archive_digest"`
22 Sequence int64 `json:"sequence"`
23}
24
25func existingJournal(statePath, floorPath string, password []byte) (*journal, error) {
26 if statePath == floorPath || strings.HasPrefix(statePath, floorPath+"/") || strings.HasPrefix(floorPath, statePath+"/") {
27 return nil, ErrRefused
28 }
29 state, e := openPrivateDirectory(statePath)
30 if e != nil {
31 return nil, e
32 }
33 floorDir, e := openPrivateDirectory(floorPath)
34 if e != nil {
35 state.close()
36 return nil, e
37 }
38 fail := func(e error) (*journal, error) { state.close(); floorDir.close(); return nil, e }
39 wrapped, e := state.read("credential.age", 128<<10)
40 if e != nil {
41 return fail(e)
42 }
43 id, key, e := unwrapCredential(wrapped, password)
44 if e != nil {
45 return fail(e)
46 }
47 floor, e := newNativeFloor(floorDir, id)
48 if e != nil {
49 return fail(e)
50 }
51 j, e := openJournal(state, floor, key, false)
52 if e != nil {
53 return fail(e)
54 }
55 return j, nil
56}
57func closeExisting(j *journal) { j.close(); j.floor.dir.close(); j.dir.close() }
58func ownedFile(dir *privateDirectory, name string) (*os.File, error) {
59 if !leaf(name) {
60 return nil, ErrRefused
61 }
62 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)
63 if e != nil {
64 return nil, ErrRefused
65 }
66 return os.NewFile(uintptr(fd), name), nil
67}
68
69// Backup exports only encrypted state. The independent floor is never included.
70func Backup(statePath, floorPath, output string, password []byte) (BackupInfo, error) {
71 if !filepath.IsAbs(output) || filepath.Clean(output) != output || strings.HasPrefix(output, statePath+"/") || strings.HasPrefix(output, floorPath+"/") {
72 return BackupInfo{}, ErrRefused
73 }
74 destination, e := openPrivateDirectory(filepath.Dir(output))
75 if e != nil {
76 return BackupInfo{}, e
77 }
78 defer destination.close()
79 name := filepath.Base(output)
80 if !leaf(name) || !leaf(name+".tmp") {
81 return BackupInfo{}, ErrRefused
82 }
83 j, e := existingJournal(statePath, floorPath, password)
84 if e != nil {
85 return BackupInfo{}, e
86 }
87 defer closeExisting(j)
88 j.mu.Lock()
89 defer j.mu.Unlock()
90 point, _, e := j.floor.current()
91 if e != nil {
92 return BackupInfo{}, e
93 }
94 snapshot, e := ownedFile(j.dir, "backup.snapshot")
95 if e != nil {
96 return BackupInfo{}, e
97 }
98 snapshot.Close()
99 defer unix.Unlinkat(int(j.dir.file.Fd()), "backup.snapshot", 0)
100 snapshotPath := filepath.Join(j.dir.file.Name(), "backup.snapshot")
101 if _, e = j.db.Exec("VACUUM INTO ?", snapshotPath); e != nil {
102 return BackupInfo{}, ErrUnavailable
103 }
104 dbFile, e := os.Open(snapshotPath)
105 if e != nil {
106 return BackupInfo{}, ErrUnavailable
107 }
108 defer dbFile.Close()
109 info, e := dbFile.Stat()
110 if e != nil || info.Size() > maxRegistryBytes {
111 return BackupInfo{}, ErrRefused
112 }
113 wrapped, e := j.dir.read("credential.age", 128<<10)
114 if e != nil {
115 return BackupInfo{}, e
116 }
117 genesis, e := j.dir.read("initializing.json", 8192)
118 if e != nil {
119 return BackupInfo{}, e
120 }
121 outputFile, e := ownedFile(destination, name+".tmp")
122 if e != nil {
123 return BackupInfo{}, e
124 }
125 defer outputFile.Close()
126 defer unix.Unlinkat(int(destination.file.Fd()), name+".tmp", 0)
127 recipient, e := age.NewScryptRecipient(string(password))
128 if e != nil {
129 return BackupInfo{}, ErrRefused
130 }
131 digest := sha256.New()
132 encrypted, e := age.Encrypt(io.MultiWriter(outputFile, digest), recipient)
133 if e != nil {
134 return BackupInfo{}, ErrUnavailable
135 }
136 archive := tar.NewWriter(encrypted)
137 for _, entry := range []struct {
138 name string
139 size int64
140 reader io.Reader
141 }{{"registry.db", info.Size(), dbFile}, {"credential.age", int64(len(wrapped)), strings.NewReader(string(wrapped))}, {"initializing.json", int64(len(genesis)), strings.NewReader(string(genesis))}} {
142 if archive.WriteHeader(&tar.Header{Name: entry.name, Mode: 0600, Size: entry.size, Typeflag: tar.TypeReg}) != nil {
143 return BackupInfo{}, ErrUnavailable
144 }
145 if n, e := io.Copy(archive, entry.reader); e != nil || n != entry.size {
146 return BackupInfo{}, ErrUnavailable
147 }
148 }
149 if archive.Close() != nil || encrypted.Close() != nil || outputFile.Sync() != nil || outputFile.Close() != nil {
150 return BackupInfo{}, ErrUnavailable
151 }
152 if unix.Renameat2(int(destination.file.Fd()), name+".tmp", int(destination.file.Fd()), name, unix.RENAME_NOREPLACE) != nil || destination.file.Sync() != nil {
153 return BackupInfo{}, ErrRefused
154 }
155 return BackupInfo{hex.EncodeToString(digest.Sum(nil)), point.Sequence}, nil
156}
157
158// Restore requires a separate empty destination and the surviving independent
159// floor. A failed validation never updates that floor or grants dispatch.
160func Restore(statePath, floorPath, input string, password []byte) (RegistryInfo, error) {
161 if !filepath.IsAbs(input) || filepath.Clean(input) != input || len(password) == 0 || len(password) > 1024 || statePath == floorPath || strings.HasPrefix(statePath, floorPath+"/") || strings.HasPrefix(floorPath, statePath+"/") {
162 return RegistryInfo{}, ErrRefused
163 }
164 state, e := openPrivateDirectory(statePath)
165 if e != nil {
166 return RegistryInfo{}, e
167 }
168 defer state.close()
169 names, e := directoryNames(state)
170 if e != nil || len(names) != 0 {
171 return RegistryInfo{}, ErrRefused
172 }
173 lock, e := state.lock()
174 if e != nil {
175 return RegistryInfo{}, e
176 }
177 defer lock.Close()
178 source, e := openPrivateDirectory(filepath.Dir(input))
179 if e != nil {
180 return RegistryInfo{}, e
181 }
182 defer source.close()
183 fd, e := unix.Openat(int(source.file.Fd()), filepath.Base(input), unix.O_RDONLY|unix.O_NOFOLLOW|unix.O_NONBLOCK|unix.O_CLOEXEC, 0)
184 if e != nil {
185 return RegistryInfo{}, ErrRefused
186 }
187 f := os.NewFile(uintptr(fd), "private-backup")
188 defer f.Close()
189 info, e := f.Stat()
190 if e != nil || !info.Mode().IsRegular() || info.Mode().Perm() != 0600 || info.Size() > maxRegistryBytes+2<<20 {
191 return RegistryInfo{}, ErrRefused
192 }
193 identity, e := age.NewScryptIdentity(string(password))
194 if e != nil {
195 return RegistryInfo{}, ErrRefused
196 }
197 identity.SetMaxWorkFactor(18)
198 decrypted, e := age.Decrypt(f, identity)
199 if e != nil {
200 return RegistryInfo{}, ErrRefused
201 }
202 archive := tar.NewReader(decrypted)
203 seen := map[string]bool{}
204 for {
205 header, e := archive.Next()
206 if e == io.EOF {
207 break
208 }
209 if e != nil {
210 return RegistryInfo{}, ErrRefused
211 }
212 limit := int64(0)
213 switch header.Name {
214 case "registry.db":
215 limit = maxRegistryBytes
216 case "credential.age":
217 limit = 128 << 10
218 case "initializing.json":
219 limit = 8192
220 default:
221 return RegistryInfo{}, ErrRefused
222 }
223 if seen[header.Name] || header.Typeflag != tar.TypeReg || header.Size < 0 || header.Size > limit || header.Mode != 0600 {
224 return RegistryInfo{}, ErrRefused
225 }
226 seen[header.Name] = true
227 output, e := ownedFile(state, ".restore-"+header.Name)
228 if e != nil {
229 return RegistryInfo{}, e
230 }
231 n, copyErr := io.Copy(output, archive)
232 syncErr := output.Sync()
233 closeErr := output.Close()
234 if copyErr != nil || syncErr != nil || closeErr != nil || n != header.Size {
235 return RegistryInfo{}, ErrRefused
236 }
237 }
238 trailing, e := io.ReadAll(io.LimitReader(decrypted, 1))
239 if e != nil || len(trailing) != 0 || len(seen) != 3 || state.file.Sync() != nil {
240 return RegistryInfo{}, ErrRefused
241 }
242 for _, name := range []string{"registry.db", "credential.age", "initializing.json"} {
243 if unix.Renameat2(int(state.file.Fd()), ".restore-"+name, int(state.file.Fd()), name, unix.RENAME_NOREPLACE) != nil {
244 return RegistryInfo{}, ErrRefused
245 }
246 }
247 if state.file.Sync() != nil {
248 return RegistryInfo{}, ErrUnavailable
249 }
250 lock.Close()
251 j, e := existingJournal(statePath, floorPath, password)
252 if e != nil {
253 return RegistryInfo{}, e
254 }
255 defer closeExisting(j)
256 point, _, e := j.floor.current()
257 if e != nil {
258 return RegistryInfo{}, e
259 }
260 return RegistryInfo{j.floor.identity.Registry, j.floor.identity.Device, j.floor.identity.Public, point.Sequence}, nil
261}
262