Add native durable storage and signed floor history primitives
4 files changed,
+586
−0
internal/provisioner/floor_linux.go+211 −0internal/provisioner/floor_linux_test.go+113 −0internal/provisioner/storage_linux.go+121 −0internal/provisioner/storage_linux_test.go+141 −0
CI failed — run details
diff --git a/internal/provisioner/floor_linux.go b/internal/provisioner/floor_linux.go| new file mode 100644 |
| index 0000000..e4a39b9 |
| --- /dev/null |
| +++ b/internal/provisioner/floor_linux.go |
| @@ -0,0 +1,211 @@ |
| +//go:build linux |
| + |
| +package provisioner |
| + |
| +import ( |
| + "bytes" |
| + "crypto/elliptic" |
| + "crypto/sha256" |
| + "encoding/base64" |
| + "fmt" |
| + "io" |
| + "os" |
| + "sort" |
| + |
| + family "amadan.net/rastrillo/rastrillo/crypto" |
| + "github.com/go-json-experiment/json" |
| + "github.com/go-json-experiment/json/jsontext" |
| +) |
| + |
| +const floorContext = "oficina/provisioner/floor/v1" |
| +const maxRecords = 10000 |
| + |
| +type floorIdentity struct { |
| + Registry string `json:"registry"` |
| + Device string `json:"device"` |
| + Public string `json:"public_key"` |
| +} |
| +type floorPoint struct { |
| + Sequence int64 `json:"sequence"` |
| + Head string `json:"head"` |
| +} |
| +type floorRecord struct { |
| + Version int `json:"version"` |
| + Identity floorIdentity `json:"identity"` |
| + Point floorPoint `json:"point"` |
| + Previous string `json:"previous"` |
| +} |
| +type floorEnvelope struct { |
| + Record floorRecord `json:"record"` |
| + Signature string `json:"signature"` |
| +} |
| +type nativeFloor struct { |
| + dir *privateDirectory |
| + identity floorIdentity |
| + public []byte |
| +} |
| + |
| +func tokenBytes(s string, n int) ([]byte, bool) { |
| + b, e := base64.RawURLEncoding.DecodeString(s) |
| + return b, e == nil && len(b) == n && base64.RawURLEncoding.EncodeToString(b) == s |
| +} |
| +func canonicalJSON(v any) ([]byte, error) { |
| + b, e := json.Marshal(v) |
| + if e != nil { |
| + return nil, ErrRefused |
| + } |
| + j := jsontext.Value(b) |
| + if j.Canonicalize() != nil { |
| + return nil, ErrRefused |
| + } |
| + return j, nil |
| +} |
| +func closedJSON(b []byte, v any) error { |
| + if json.Unmarshal(b, v, json.RejectUnknownMembers(true)) != nil { |
| + return ErrRefused |
| + } |
| + canonical, e := canonicalJSON(v) |
| + if e != nil || !bytes.Equal(canonical, b) { |
| + return ErrRefused |
| + } |
| + return nil |
| +} |
| +func hash(b []byte) string { h := sha256.Sum256(b); return base64.RawURLEncoding.EncodeToString(h[:]) } |
| +func floorName(sequence int64) string { return fmt.Sprintf("%020d.json", sequence) } |
| +func newNativeFloor(dir *privateDirectory, id floorIdentity) (*nativeFloor, error) { |
| + _, a := tokenBytes(id.Registry, 32) |
| + _, b := tokenBytes(id.Device, 32) |
| + pub, c := tokenBytes(id.Public, 65) |
| + x, y := elliptic.Unmarshal(elliptic.P256(), pub) |
| + if dir == nil || !a || !b || !c || x == nil || y == nil { |
| + return nil, ErrRefused |
| + } |
| + return &nativeFloor{dir, id, pub}, nil |
| +} |
| +func (f *nativeFloor) signed(k *family.Keypair, p floorPoint, previous string) ([]byte, error) { |
| + if k == nil || k.SignPriv == nil || !bytes.Equal(k.SignPub(), f.public) { |
| + return nil, ErrRefused |
| + } |
| + r := floorRecord{1, f.identity, p, previous} |
| + body, e := canonicalJSON(r) |
| + if e != nil { |
| + return nil, e |
| + } |
| + sig, e := family.Sign(k, floorContext, body) |
| + if e != nil { |
| + return nil, ErrUnavailable |
| + } |
| + return canonicalJSON(floorEnvelope{r, base64.RawURLEncoding.EncodeToString(sig)}) |
| +} |
| +func (f *nativeFloor) initialize(k *family.Keypair) error { |
| + files, e := f.names() |
| + if e != nil { |
| + return e |
| + } |
| + if len(files) != 0 { |
| + return ErrRefused |
| + } |
| + raw, e := f.signed(k, floorPoint{}, "") |
| + if e != nil { |
| + return e |
| + } |
| + return f.dir.create(floorName(0), raw) |
| +} |
| +func (f *nativeFloor) names() ([]string, error) { |
| + // Open a fresh directory description: a retained Readdir offset must never |
| + // hide files appended since a previous verification. |
| + fd, e := openPrivateDirectory(f.dir.file.Name()) |
| + if e != nil { |
| + return nil, e |
| + } |
| + defer fd.close() |
| + oldInfo, e := f.dir.file.Stat() |
| + if e != nil { |
| + return nil, ErrUnavailable |
| + } |
| + newInfo, e := fd.file.Stat() |
| + if e != nil || !os.SameFile(oldInfo, newInfo) { |
| + return nil, ErrRefused |
| + } |
| + entries, e := fd.file.Readdirnames(maxRecords + 3) |
| + if e != nil && e != io.EOF { |
| + return nil, ErrUnavailable |
| + } |
| + if len(entries) > maxRecords+2 { |
| + return nil, ErrRefused |
| + } |
| + names := make([]string, 0, len(entries)) |
| + for _, name := range entries { |
| + if name == "lock" { |
| + continue |
| + } |
| + if len(name) != 25 || name[20:] != ".json" { |
| + return nil, ErrRefused |
| + } |
| + names = append(names, name) |
| + } |
| + sort.Strings(names) |
| + return names, nil |
| +} |
| +func (f *nativeFloor) current() (floorPoint, string, error) { |
| + names, e := f.names() |
| + if e != nil { |
| + return floorPoint{}, "", e |
| + } |
| + if len(names) == 0 || len(names) > maxRecords+1 { |
| + return floorPoint{}, "", ErrRefused |
| + } |
| + var last floorPoint |
| + var previous string |
| + for i, name := range names { |
| + if name != floorName(int64(i)) { |
| + return floorPoint{}, "", ErrRefused |
| + } |
| + raw, e := f.dir.read(name, 8192) |
| + if e != nil { |
| + return floorPoint{}, "", e |
| + } |
| + var env floorEnvelope |
| + if closedJSON(raw, &env) != nil || env.Record.Version != 1 || env.Record.Identity != f.identity || env.Record.Point.Sequence != int64(i) || env.Record.Previous != previous { |
| + return floorPoint{}, "", ErrRefused |
| + } |
| + r := env.Record |
| + if i == 0 { |
| + if r.Point.Head != "" { |
| + return floorPoint{}, "", ErrRefused |
| + } |
| + } else { |
| + if _, ok := tokenBytes(r.Point.Head, 32); !ok { |
| + return floorPoint{}, "", ErrRefused |
| + } |
| + } |
| + sig, ok := tokenBytes(env.Signature, 64) |
| + body, e := canonicalJSON(r) |
| + if !ok || e != nil || !family.Verify(f.public, floorContext, body, sig) { |
| + return floorPoint{}, "", ErrRefused |
| + } |
| + previous = hash(raw) |
| + last = r.Point |
| + } |
| + return last, previous, nil |
| +} |
| + |
| +// advance requires the caller to hold the device process lock. It checks every |
| +// immutable floor record again, then exclusively creates the adjacent record. |
| +func (f *nativeFloor) advance(k *family.Keypair, expected, next floorPoint) error { |
| + current, previous, e := f.current() |
| + if e != nil { |
| + return e |
| + } |
| + if current != expected || next.Sequence != expected.Sequence+1 || next.Sequence > maxRecords { |
| + return ErrRefused |
| + } |
| + if _, ok := tokenBytes(next.Head, 32); !ok { |
| + return ErrRefused |
| + } |
| + raw, e := f.signed(k, next, previous) |
| + if e != nil { |
| + return e |
| + } |
| + return f.dir.create(floorName(next.Sequence), raw) |
| +} |
diff --git a/internal/provisioner/floor_linux_test.go b/internal/provisioner/floor_linux_test.go| new file mode 100644 |
| index 0000000..9ad72a0 |
| --- /dev/null |
| +++ b/internal/provisioner/floor_linux_test.go |
| @@ -0,0 +1,113 @@ |
| +//go:build linux |
| + |
| +package provisioner |
| + |
| +import ( |
| + "encoding/base64" |
| + "os" |
| + "path/filepath" |
| + "testing" |
| + |
| + family "amadan.net/rastrillo/rastrillo/crypto" |
| +) |
| + |
| +func testToken(n byte) string { |
| + b := make([]byte, 32) |
| + for i := range b { |
| + b[i] = n |
| + } |
| + return base64.RawURLEncoding.EncodeToString(b) |
| +} |
| +func floorFixture(t *testing.T) (*nativeFloor, *family.Keypair) { |
| + t.Helper() |
| + path := persistentDirectory(t) |
| + dir, e := openPrivateDirectory(path) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + t.Cleanup(func() { dir.close() }) |
| + key, e := family.Generate() |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + floor, e := newNativeFloor(dir, floorIdentity{testToken(1), testToken(2), base64.RawURLEncoding.EncodeToString(key.SignPub())}) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + return floor, key |
| +} |
| +func TestFloorDurableCompareAndSwap(t *testing.T) { |
| + f, k := floorFixture(t) |
| + if _, _, e := f.current(); e != ErrRefused { |
| + t.Fatal("absent floor accepted", e) |
| + } |
| + if e := f.initialize(k); e != nil { |
| + t.Fatal(e) |
| + } |
| + if e := f.initialize(k); e != ErrRefused { |
| + t.Fatal("floor reinitialized", e) |
| + } |
| + zero, _, e := f.current() |
| + if e != nil || zero != (floorPoint{}) { |
| + t.Fatal(e) |
| + } |
| + one := floorPoint{1, testToken(3)} |
| + if e = f.advance(k, zero, one); e != nil { |
| + t.Fatal(e) |
| + } |
| + if e = f.advance(k, zero, floorPoint{1, testToken(4)}); e != ErrRefused { |
| + t.Fatal("stale registry rolled floor back", e) |
| + } |
| + again, _, e := f.current() |
| + if e != nil || again != one { |
| + t.Fatal("floor failed durable scan", e) |
| + } |
| + // Reopening with a competing device or key never creates a fresh genesis. |
| + wrong, e := newNativeFloor(f.dir, floorIdentity{f.identity.Registry, testToken(9), f.identity.Public}) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + if _, _, e = wrong.current(); e != ErrRefused { |
| + t.Fatal("competing device accepted", e) |
| + } |
| +} |
| +func TestFloorRefusesPartialTamperedAndMissingHistory(t *testing.T) { |
| + for _, which := range []string{"partial", "tamper", "gap"} { |
| + t.Run(which, func(t *testing.T) { |
| + f, k := floorFixture(t) |
| + if e := f.initialize(k); e != nil { |
| + t.Fatal(e) |
| + } |
| + one := floorPoint{1, testToken(3)} |
| + if e := f.advance(k, floorPoint{}, one); e != nil { |
| + t.Fatal(e) |
| + } |
| + switch which { |
| + case "partial": |
| + if e := f.dir.create(floorName(2), []byte("{")); e != nil { |
| + t.Fatal(e) |
| + } |
| + case "tamper": |
| + path := filepath.Join(f.dir.file.Name(), floorName(1)) |
| + raw, e := os.ReadFile(path) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + raw[len(raw)-5] ^= 1 |
| + if e = os.WriteFile(path, raw, 0600); e != nil { |
| + t.Fatal(e) |
| + } |
| + case "gap": |
| + if e := f.advance(k, one, floorPoint{2, testToken(4)}); e != nil { |
| + t.Fatal(e) |
| + } |
| + if e := os.Remove(filepath.Join(f.dir.file.Name(), floorName(1))); e != nil { |
| + t.Fatal(e) |
| + } |
| + } |
| + if _, _, e := f.current(); e != ErrRefused { |
| + t.Fatal("damaged history accepted", e) |
| + } |
| + }) |
| + } |
| +} |
diff --git a/internal/provisioner/storage_linux.go b/internal/provisioner/storage_linux.go| new file mode 100644 |
| index 0000000..4d544e1 |
| --- /dev/null |
| +++ b/internal/provisioner/storage_linux.go |
| @@ -0,0 +1,121 @@ |
| +//go:build linux |
| + |
| +package provisioner |
| + |
| +import ( |
| + "errors" |
| + "io" |
| + "os" |
| + "path/filepath" |
| + "strings" |
| + |
| + "golang.org/x/sys/unix" |
| +) |
| + |
| +var ErrRefused = errors.New("provisioner: refused") |
| +var ErrUnavailable = errors.New("provisioner: unavailable") |
| + |
| +// privateDirectory retains an opened directory so later operations cannot follow |
| +// a replacement path or a symlink supplied in a file name. |
| +type privateDirectory struct{ file *os.File } |
| + |
| +func openPrivateDirectory(path string) (*privateDirectory, error) { |
| + if !filepath.IsAbs(path) || filepath.Clean(path) != path || path == "/" { |
| + return nil, ErrRefused |
| + } |
| + fd, err := unix.Open("/", unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC, 0) |
| + if err != nil { |
| + return nil, ErrUnavailable |
| + } |
| + for _, part := range strings.Split(strings.TrimPrefix(path, "/"), "/") { |
| + next, e := unix.Openat(fd, part, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0) |
| + unix.Close(fd) |
| + if e != nil { |
| + return nil, ErrRefused |
| + } |
| + fd = next |
| + } |
| + var stat unix.Stat_t |
| + var fs unix.Statfs_t |
| + if unix.Fstat(fd, &stat) != nil || unix.Fstatfs(fd, &fs) != nil { |
| + unix.Close(fd) |
| + return nil, ErrUnavailable |
| + } |
| + if stat.Uid != uint32(os.Getuid()) || stat.Mode&0777 != 0700 || (fs.Type != unix.EXT4_SUPER_MAGIC && fs.Type != unix.XFS_SUPER_MAGIC) { |
| + unix.Close(fd) |
| + return nil, ErrRefused |
| + } |
| + return &privateDirectory{os.NewFile(uintptr(fd), path)}, nil |
| +} |
| +func (d *privateDirectory) close() error { return d.file.Close() } |
| +func leaf(name string) bool { |
| + return name != "" && name != "." && name != ".." && len(name) <= 128 && !strings.ContainsAny(name, "/\\\x00") |
| +} |
| +func (d *privateDirectory) create(name string, body []byte) error { |
| + if !leaf(name) || len(body) > 128<<10 { |
| + return ErrRefused |
| + } |
| + fd, e := unix.Openat(int(d.file.Fd()), name, unix.O_WRONLY|unix.O_CREAT|unix.O_EXCL|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0600) |
| + if e != nil { |
| + return ErrRefused |
| + } |
| + f := os.NewFile(uintptr(fd), name) |
| + // A failed exclusive write remains present: recovery must validate or refuse |
| + // the partial record instead of replacing evidence of an interrupted write. |
| + _, e = f.Write(body) |
| + if e == nil { |
| + e = f.Sync() |
| + } |
| + closeErr := f.Close() |
| + if e != nil || closeErr != nil || d.file.Sync() != nil { |
| + return ErrUnavailable |
| + } |
| + return nil |
| +} |
| +func (d *privateDirectory) read(name string, limit int) ([]byte, error) { |
| + if !leaf(name) || limit <= 0 || limit > 128<<10 { |
| + return nil, ErrRefused |
| + } |
| + fd, e := unix.Openat(int(d.file.Fd()), name, unix.O_RDONLY|unix.O_NOFOLLOW|unix.O_NONBLOCK|unix.O_CLOEXEC, 0) |
| + if e != nil { |
| + return nil, ErrRefused |
| + } |
| + f := os.NewFile(uintptr(fd), name) |
| + defer f.Close() |
| + var s unix.Stat_t |
| + if unix.Fstat(fd, &s) != nil { |
| + return nil, ErrUnavailable |
| + } |
| + if s.Mode&unix.S_IFMT != unix.S_IFREG || s.Mode&0777 != 0600 || s.Uid != uint32(os.Getuid()) || s.Nlink != 1 || s.Size > int64(limit) { |
| + return nil, ErrRefused |
| + } |
| + body, e := io.ReadAll(io.LimitReader(f, int64(limit)+1)) |
| + if e != nil { |
| + return nil, ErrUnavailable |
| + } |
| + if len(body) > limit { |
| + return nil, ErrRefused |
| + } |
| + return body, nil |
| +} |
| +func (d *privateDirectory) lock() (*os.File, error) { |
| + fd, e := unix.Openat(int(d.file.Fd()), "lock", unix.O_RDWR|unix.O_CREAT|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0600) |
| + if e != nil { |
| + return nil, ErrRefused |
| + } |
| + f := os.NewFile(uintptr(fd), "lock") |
| + var s unix.Stat_t |
| + if unix.Fstat(fd, &s) != nil || s.Mode&unix.S_IFMT != unix.S_IFREG || s.Mode&0777 != 0600 || s.Uid != uint32(os.Getuid()) || s.Nlink != 1 { |
| + f.Close() |
| + return nil, ErrRefused |
| + } |
| + if unix.Flock(fd, unix.LOCK_EX|unix.LOCK_NB) != nil { |
| + f.Close() |
| + return nil, ErrRefused |
| + } |
| + if f.Sync() != nil || d.file.Sync() != nil { |
| + f.Close() |
| + return nil, ErrUnavailable |
| + } |
| + return f, nil |
| +} |
diff --git a/internal/provisioner/storage_linux_test.go b/internal/provisioner/storage_linux_test.go| new file mode 100644 |
| index 0000000..a8bb52c |
| --- /dev/null |
| +++ b/internal/provisioner/storage_linux_test.go |
| @@ -0,0 +1,141 @@ |
| +//go:build linux |
| + |
| +package provisioner |
| + |
| +import ( |
| + "bytes" |
| + "os" |
| + "os/exec" |
| + "path/filepath" |
| + "testing" |
| +) |
| + |
| +func persistentDirectory(t *testing.T) string { |
| + t.Helper() |
| + // The native backend deliberately rejects /tmp when it is tmpfs. Exercise |
| + // durable operations on this checkout's persistent filesystem instead. |
| + path, err := os.MkdirTemp(".", ".provisioner-test-") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + path, err = filepath.Abs(path) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { os.RemoveAll(path) }) |
| + if err = os.Chmod(path, 0700); err != nil { |
| + t.Fatal(err) |
| + } |
| + return path |
| +} |
| +func TestNativeStorageExclusiveDurableRecords(t *testing.T) { |
| + path := persistentDirectory(t) |
| + d, err := openPrivateDirectory(path) |
| + if err != nil { |
| + t.Fatal("native filesystem required", err) |
| + } |
| + body := []byte("synthetic signed record") |
| + if err = d.create("00000001", body); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err = d.create("00000001", []byte("replacement")); err != ErrRefused { |
| + t.Fatal("immutable record replaced", err) |
| + } |
| + d.close() |
| + d, err = openPrivateDirectory(path) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer d.close() |
| + got, err := d.read("00000001", 128) |
| + if err != nil || !bytes.Equal(got, body) { |
| + t.Fatal("durable reopen", err) |
| + } |
| + if _, err = d.read("00000001", 2); err != ErrRefused { |
| + t.Fatal("unbounded read", err) |
| + } |
| + if err = os.Link(filepath.Join(path, "00000001"), filepath.Join(path, "alias")); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err = d.read("00000001", 128); err != ErrRefused { |
| + t.Fatal("hardlink accepted", err) |
| + } |
| +} |
| +func TestNativeStorageRefusesSymlinksAndPermissions(t *testing.T) { |
| + path := persistentDirectory(t) |
| + real := filepath.Join(path, "real") |
| + if err := os.Mkdir(real, 0700); err != nil { |
| + t.Fatal(err) |
| + } |
| + alias := filepath.Join(path, "alias") |
| + if err := os.Symlink(real, alias); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := openPrivateDirectory(alias); err != ErrRefused { |
| + t.Fatal("symlink directory", err) |
| + } |
| + if err := os.Mkdir(filepath.Join(real, "nested"), 0700); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := openPrivateDirectory(filepath.Join(alias, "nested")); err != ErrRefused { |
| + t.Fatal("symlink ancestor", err) |
| + } |
| + d, err := openPrivateDirectory(real) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer d.close() |
| + if err = os.Symlink("target", filepath.Join(real, "record")); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err = d.create("record", []byte("x")); err != ErrRefused { |
| + t.Fatal("followed record symlink", err) |
| + } |
| + if _, err = d.read("record", 128); err != ErrRefused { |
| + t.Fatal("read record symlink", err) |
| + } |
| + if err = os.Chmod(real, 0755); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err = openPrivateDirectory(real); err != ErrRefused { |
| + t.Fatal("public directory", err) |
| + } |
| +} |
| +func TestNativeStorageProcessLock(t *testing.T) { |
| + if path := os.Getenv("OFICINA_TEST_LOCK_PATH"); path != "" { |
| + d, err := openPrivateDirectory(path) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer d.close() |
| + lock, err := d.lock() |
| + if lock != nil { |
| + lock.Close() |
| + } |
| + if err != ErrRefused { |
| + t.Fatal("second process acquired lock", err) |
| + } |
| + return |
| + } |
| + path := persistentDirectory(t) |
| + d, err := openPrivateDirectory(path) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer d.close() |
| + lock, err := d.lock() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + cmd := exec.Command(os.Args[0], "-test.run=^TestNativeStorageProcessLock$") |
| + cmd.Env = append(os.Environ(), "OFICINA_TEST_LOCK_PATH="+path) |
| + if output, err := cmd.CombinedOutput(); err != nil { |
| + t.Fatalf("child: %s: %v", output, err) |
| + } |
| + lock.Close() |
| + lock, err = d.lock() |
| + if err != nil { |
| + t.Fatal("lock did not release", err) |
| + } |
| + lock.Close() |
| +} |