carlos / platform Public

hostfloor, release: authenticated release identity and the durable floor policy (claim Plan 8b Task 1)

Co-Authored-By: Codex <noreply@openai.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
codex agent, operated by paul@keymail.dev pushed by claude agent, operated by paul@keymail.dev f84cebb1630bdbf6aec9e4731faf2f625a723549 parent ca5a79d
11 files changed, +1017 −30
  • internal/hostfloor/disk.go +193 −0
  • internal/hostfloor/floor.go +131 −0
  • internal/hostfloor/floor_test.go +89 −0
  • internal/hostfloor/image_linux.go +87 −0
  • internal/hostfloor/image_other.go +13 −0
  • internal/hostfloor/remote.go +41 −0
  • internal/hostfloor/report.go +99 −0
  • internal/release/systemidentity.go +111 −0
  • internal/release/systempointer.go +62 −30
  • internal/release/systempublication.go +93 −0
  • internal/release/systempublication_test.go +98 −0
diff --git a/internal/hostfloor/disk.go b/internal/hostfloor/disk.go
new file mode 100644
index 000000000..233286c22
--- /dev/null
+++ b/internal/hostfloor/disk.go
@@ -0,0 +1,193 @@
+package hostfloor
+
+import (
+ "crypto/ed25519"
+ "encoding/json"
+ "errors"
+ "github.com/carlosframework/platform/internal/release"
+ "io"
+ "os"
+ "path/filepath"
+)
+
+type Disk struct {
+ Dir, Bucket string
+ PublicKey ed25519.PublicKey
+}
+
+func (d Disk) Load() (Policy, Verified, error) {
+ raw, err := os.ReadFile(filepath.Join(d.Dir, "policy.json"))
+ if err != nil {
+ return Policy{}, Verified{}, err
+ }
+ var p Policy
+ if json.Unmarshal(raw, &p) != nil {
+ return Policy{}, Verified{}, ErrFloor
+ }
+ f, err := p.Verify(d.PublicKey, d.Bucket)
+ return p, f, err
+}
+func Durable(path string, raw []byte, mode os.FileMode) error {
+ dir := filepath.Dir(path)
+ f, err := os.CreateTemp(dir, ".guard-*")
+ if err != nil {
+ return err
+ }
+ name := f.Name()
+ defer os.Remove(name)
+ if err = f.Chmod(mode); err == nil {
+ _, err = f.Write(raw)
+ }
+ if err == nil {
+ err = f.Sync()
+ }
+ err = errors.Join(err, f.Close())
+ if err != nil {
+ return err
+ }
+ if err = os.Rename(name, path); err != nil {
+ return err
+ }
+ fd, err := os.Open(dir)
+ if err != nil {
+ return err
+ }
+ defer fd.Close()
+ return fd.Sync()
+}
+
+// Persist never creates unpublished state implicitly. Bootstrap creates that
+// explicit root-owned file before installing the guarded unit drop-ins.
+func (d Disk) Persist(next Policy, identities map[string][]byte) error {
+ _, nf, err := func() (Policy, Verified, error) { f, e := next.Verify(d.PublicKey, d.Bucket); return next, f, e }()
+ if err != nil {
+ return err
+ }
+ old, of, err := d.Load()
+ if err != nil {
+ return err
+ }
+ if old.Mode != "unpublished" {
+ if next.Mode == "unpublished" || nf.Digest != of.Digest {
+ return ErrFloor
+ }
+ if old.Mode == "enforced" && next.Mode != "enforced" {
+ return ErrFloor
+ }
+ }
+ for tag, raw := range identities {
+ p, err := release.VerifySystemIdentity(d.PublicKey, raw)
+ if err != nil || p.Current.ReleaseTag != tag {
+ return ErrFloor
+ }
+ if err = Durable(filepath.Join(d.Dir, "identities", tag+".json"), raw, 0644); err != nil {
+ return err
+ }
+ }
+ raw, err := json.Marshal(next)
+ if err != nil {
+ return err
+ }
+ return Durable(filepath.Join(d.Dir, "policy.json"), raw, 0644)
+}
+func (d Disk) VerifyFile(f *os.File, platform string, roles ...string) (string, error) {
+ component := "router"
+ if len(roles) == 1 {
+ component = roles[0]
+ } else if len(roles) > 1 {
+ return "", ErrFloor
+ }
+ p, floor, err := d.Load()
+ if err != nil {
+ return "", err
+ }
+ if p.Mode == "unpublished" {
+ return "", nil
+ }
+ if _, err = f.Seek(0, io.SeekStart); err != nil {
+ return "", err
+ }
+ raw, err := io.ReadAll(io.LimitReader(f, 512<<20))
+ if err != nil {
+ return "", err
+ }
+ hash := Digest(raw)
+ entries, err := os.ReadDir(filepath.Join(d.Dir, "identities"))
+ if err != nil {
+ return "", err
+ }
+ for _, entry := range entries {
+ if entry.IsDir() {
+ continue
+ }
+ wire, err := os.ReadFile(filepath.Join(d.Dir, "identities", entry.Name()))
+ if err != nil {
+ return "", err
+ }
+ identity, err := release.VerifySystemIdentity(d.PublicKey, wire)
+ if err != nil {
+ return "", err
+ }
+ if CheckComponent(d.PublicKey, floor, wire, identity.Current.Version, platform, hash, component) == nil {
+ return hash, nil
+ }
+ }
+ return "", ErrFloor
+}
+func (d Disk) Begin(id, dataDir string) error {
+ if id == "" {
+ return ErrFloor
+ }
+ marker := filepath.Join(d.Dir, "update-begun.json")
+ if _, err := os.Stat(marker); err == nil {
+ return ErrFloor
+ } else if !errors.Is(err, os.ErrNotExist) {
+ return err
+ }
+ raw, _ := json.Marshal(struct {
+ ID string `json:"id"`
+ }{id})
+ if err := Durable(marker, raw, 0644); err != nil {
+ return err
+ }
+ if err := CheckControllersIdle(dataDir); err != nil {
+ removeErr := RemoveDurable(marker)
+ return errors.Join(err, removeErr)
+ }
+ return nil
+}
+
+// CheckControllersIdle refuses while any controller state is present. The
+// directory must also be readable: a missing or unreadable directory cannot
+// prove that no controller is active.
+func CheckControllersIdle(dataDir string) error {
+ entries, err := os.ReadDir(filepath.Join(dataDir, ".hostexec"))
+ if err != nil {
+ return err
+ }
+ if len(entries) != 0 {
+ return ErrFloor
+ }
+ return nil
+}
+func RemoveDurable(path string) error {
+ if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
+ return err
+ }
+ dir, err := os.Open(filepath.Dir(path))
+ if err != nil {
+ return err
+ }
+ defer dir.Close()
+ return dir.Sync()
+}
+func UpdateIdle(guardDir, journal string) error {
+ for _, p := range []string{filepath.Join(guardDir, "update-begun.json"), journal} {
+ if _, err := os.Lstat(p); err == nil {
+ return ErrFloor
+ } else if !errors.Is(err, os.ErrNotExist) {
+ return err
+ }
+ }
+ return nil
+}
diff --git a/internal/hostfloor/floor.go b/internal/hostfloor/floor.go
new file mode 100644
index 000000000..7fa001489
--- /dev/null
+++ b/internal/hostfloor/floor.go
@@ -0,0 +1,131 @@
+package hostfloor
+
+import (
+ "context"
+ "crypto/ed25519"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+const Key = "control/moves-protocol.json"
+const AuthKey = "control/moves-protocol-auth.json"
+
+var ErrFloor = errors.New("hostfloor: authenticated floor required")
+
+type Floor struct {
+ Min int `json:"min"`
+ MinRelease string `json:"min_release"`
+}
+type Authorization struct {
+ Schema int `json:"schema"`
+ Purpose string `json:"purpose"`
+ DeploymentBucket string `json:"deployment_bucket"`
+ Key string `json:"key"`
+ SHA256 string `json:"sha256"`
+}
+type Policy struct {
+ Schema int `json:"schema"`
+ DeploymentBucket string `json:"deployment_bucket"`
+ Mode string `json:"mode"`
+ Floor json.RawMessage `json:"floor"`
+ Authorization json.RawMessage `json:"authorization"`
+}
+type Verified struct {
+ Floor Floor
+ Digest string
+}
+
+func Digest(b []byte) string { sum := sha256.Sum256(b); return hex.EncodeToString(sum[:]) }
+
+func Verify(pub ed25519.PublicKey, bucket string, raw, auth []byte) (Verified, error) {
+ var f Floor
+ var a Authorization
+ if bucket == "" || json.Unmarshal(raw, &f) != nil || f.Min < 1 {
+ return Verified{}, ErrFloor
+ }
+ if _, ok := release.ReleaseTag(f.MinRelease); !ok {
+ return Verified{}, ErrFloor
+ }
+ if release.VerifyRootEnvelope(pub, auth, &a) != nil || a.Schema != 1 || a.Purpose != "moves-protocol" || a.Key != Key || a.DeploymentBucket != bucket || a.SHA256 != Digest(raw) {
+ return Verified{}, ErrFloor
+ }
+ var fields map[string]json.RawMessage
+ if json.Unmarshal(raw, &fields) != nil || len(fields) != 2 {
+ return Verified{}, ErrFloor
+ }
+ return Verified{f, Digest(raw)}, nil
+}
+func Read(ctx context.Context, s release.Store, pub ed25519.PublicKey, bucket string) (Verified, error) {
+ raw, _, err := s.Get(ctx, Key)
+ if err != nil {
+ return Verified{}, err
+ }
+ auth, _, err := s.Get(ctx, AuthKey)
+ if err != nil {
+ return Verified{}, err
+ }
+ return Verify(pub, bucket, raw, auth)
+}
+func Publish(ctx context.Context, s release.Store, sg release.Signer, bucket string, f Floor) error {
+ raw, err := json.Marshal(f)
+ if err != nil {
+ return err
+ }
+ auth, err := release.SignRootEnvelope(sg, Authorization{1, "moves-protocol", bucket, Key, Digest(raw)})
+ if err != nil {
+ return err
+ }
+ if _, err = Verify(sg.RootPublicKey(), bucket, raw, auth); err != nil {
+ return err
+ }
+ if err = release.CreateExact(ctx, s, AuthKey, auth); err != nil {
+ return err
+ }
+ return release.CreateExact(ctx, s, Key, raw)
+}
+func (p Policy) Verify(pub ed25519.PublicKey, bucket string) (Verified, error) {
+ if p.Schema != 1 || p.DeploymentBucket != bucket {
+ return Verified{}, ErrFloor
+ }
+ switch p.Mode {
+ case "unpublished":
+ if (len(p.Floor) != 0 && string(p.Floor) != "null") || (len(p.Authorization) != 0 && string(p.Authorization) != "null") {
+ return Verified{}, ErrFloor
+ }
+ return Verified{}, nil
+ case "armed", "enforced":
+ return Verify(pub, bucket, p.Floor, p.Authorization)
+ default:
+ return Verified{}, ErrFloor
+ }
+}
+func CheckImage(pub ed25519.PublicKey, f Verified, identity []byte, build, platform, hash string) error {
+ return CheckComponent(pub, f, identity, build, platform, hash, "router")
+}
+func CheckComponent(pub ed25519.PublicKey, f Verified, identity []byte, build, platform, hash, component string) error {
+ p, err := release.VerifySystemIdentity(pub, identity)
+ if err != nil {
+ return err
+ }
+ have, ok := release.ReleaseTag(p.Current.ReleaseTag)
+ if !ok {
+ return ErrFloor
+ }
+ want, ok := release.ReleaseTag(f.Floor.MinRelease)
+ if !ok {
+ return ErrFloor
+ }
+ a, ok := p.Current.Artifacts[platform]
+ if component == "console" {
+ a, ok = p.Current.ConsoleArtifacts[platform]
+ } else if component != "router" {
+ return ErrFloor
+ }
+ if !ok || build != p.Current.Version || hash != a.SHA256 || have.Compare(want) < 0 || p.Current.MovesProtocol < f.Floor.Min {
+ return ErrFloor
+ }
+ return nil
+}
diff --git a/internal/hostfloor/floor_test.go b/internal/hostfloor/floor_test.go
new file mode 100644
index 000000000..1e5ec19a7
--- /dev/null
+++ b/internal/hostfloor/floor_test.go
@@ -0,0 +1,89 @@
+package hostfloor
+
+import (
+ "context"
+ "crypto/ed25519"
+ "crypto/rand"
+ "encoding/json"
+ "github.com/carlosframework/platform/internal/release"
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestFloorPairAndPermanentArming(t *testing.T) {
+ pub, key, _ := ed25519.GenerateKey(rand.Reader)
+ sg := release.RootSigner(key)
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ floor := Floor{1, "v1.2.3"}
+ raw, _ := json.Marshal(floor)
+ auth, _ := release.SignRootEnvelope(sg, Authorization{1, "moves-protocol", "bucket", Key, Digest(raw)})
+ if _, err := Verify(pub, "other", raw, auth); err == nil {
+ t.Fatal("wrong deployment")
+ }
+ if _, err := Verify(pub, "bucket", append(raw, ' '), auth); err == nil {
+ t.Fatal("changed bytes")
+ }
+ if _, err := s.Put(ctx, AuthKey, auth, ""); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := Read(ctx, s, pub, "bucket"); err == nil {
+ t.Fatal("incomplete pair accepted")
+ }
+ if err := Publish(ctx, s, sg, "bucket", floor); err != nil {
+ t.Fatal(err)
+ }
+ d := Disk{Dir: t.TempDir(), Bucket: "bucket", PublicKey: pub}
+ _ = os.Mkdir(filepath.Join(d.Dir, "identities"), 0700)
+ initial, _ := json.Marshal(Policy{Schema: 1, DeploymentBucket: "bucket", Mode: "unpublished"})
+ _ = os.WriteFile(filepath.Join(d.Dir, "policy.json"), initial, 0600)
+ armed := Policy{1, "bucket", "armed", raw, auth}
+ if err := d.Persist(armed, nil); err != nil {
+ t.Fatal(err)
+ }
+ if err := d.Persist(Policy{Schema: 1, DeploymentBucket: "bucket", Mode: "unpublished"}, nil); err == nil {
+ t.Fatal("floor lowered")
+ }
+ _ = os.Remove(filepath.Join(d.Dir, "policy.json"))
+ if _, _, err := d.Load(); err == nil {
+ t.Fatal("missing is unpublished")
+ }
+}
+func TestPendingAndLostControllersBlockUpdate(t *testing.T) {
+ for _, state := range []string{"pending", "active", "lost", "broken"} {
+ t.Run(state, func(t *testing.T) {
+ d := Disk{Dir: t.TempDir()}
+ data := t.TempDir()
+ _ = os.Mkdir(filepath.Join(data, ".hostexec"), 0700)
+ _ = os.WriteFile(filepath.Join(data, ".hostexec", "host.json"), []byte(state), 0600)
+ if err := d.Begin("update", data); err == nil {
+ t.Fatal("controller did not block")
+ }
+ if _, err := os.Stat(filepath.Join(d.Dir, "update-begun.json")); !os.IsNotExist(err) {
+ t.Fatal("marker survived refusal", err)
+ }
+ })
+ }
+ d := Disk{Dir: t.TempDir()}
+ data := t.TempDir()
+ _ = os.Mkdir(filepath.Join(data, ".hostexec"), 0700)
+ if err := d.Begin("update", data); err != nil {
+ t.Fatal(err)
+ }
+ if err := UpdateIdle(d.Dir, filepath.Join(data, "journal.json")); err == nil {
+ t.Fatal("participant ignored begun update")
+ }
+}
+func TestCanonicalReleaseTags(t *testing.T) {
+ for _, tag := range []string{"dev", "abc123", "1.2.3", "vv1.2.3", "v01.2.3", "v1.2.3-rc1"} {
+ if _, ok := release.ReleaseTag(tag); ok {
+ t.Fatal(tag)
+ }
+ }
+ a, _ := release.ReleaseTag("v1.10.0")
+ b, _ := release.ReleaseTag("v1.9.9")
+ if a.Compare(b) <= 0 {
+ t.Fatal("lexical ordering")
+ }
+}
diff --git a/internal/hostfloor/image_linux.go b/internal/hostfloor/image_linux.go
new file mode 100644
index 000000000..c5687f37b
--- /dev/null
+++ b/internal/hostfloor/image_linux.go
@@ -0,0 +1,87 @@
+//go:build linux
+
+package hostfloor
+
+import (
+ "context"
+ "fmt"
+ "golang.org/x/sys/unix"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strconv"
+ "syscall"
+)
+
+func OpenProtected(path string) (*os.File, error) {
+ if !filepath.IsAbs(path) || filepath.Clean(path) != path {
+ return nil, ErrFloor
+ }
+ // Every parent is root-owned and cannot be changed by a platform worker.
+ for p := filepath.Dir(path); ; p = filepath.Dir(p) {
+ s, err := os.Lstat(p)
+ if err != nil {
+ return nil, err
+ }
+ stat, ok := s.Sys().(*syscall.Stat_t)
+ if !ok || stat.Uid != 0 || !s.IsDir() || s.Mode().Perm()&0022 != 0 {
+ return nil, ErrFloor
+ }
+ if p == "/" {
+ break
+ }
+ }
+ fd, err := unix.Open(path, unix.O_RDONLY|unix.O_CLOEXEC|unix.O_NOFOLLOW|unix.O_NONBLOCK, 0)
+ if err != nil {
+ return nil, err
+ }
+ f := os.NewFile(uintptr(fd), path)
+ s, err := f.Stat()
+ if err != nil {
+ f.Close()
+ return nil, err
+ }
+ stat, ok := s.Sys().(*syscall.Stat_t)
+ if !ok || stat.Uid != 0 || !s.Mode().IsRegular() || s.Mode().Perm()&0022 != 0 {
+ f.Close()
+ return nil, ErrFloor
+ }
+ return f, nil
+}
+func (d Disk) Exec(path, platform string, args, env []string, roles ...string) error {
+ f, err := OpenProtected(path)
+ if err != nil {
+ return err
+ }
+ defer f.Close()
+ if _, err = d.VerifyFile(f, platform, roles...); err != nil {
+ return err
+ }
+ return syscall.Exec("/proc/self/fd/"+strconv.Itoa(int(f.Fd())), append([]string{path}, args...), env)
+}
+func (d Disk) Run(ctx context.Context, path, platform string, args ...string) (string, error) {
+ f, err := OpenProtected(path)
+ if err != nil {
+ return "", err
+ }
+ defer f.Close()
+ if _, err = d.VerifyFile(f, platform); err != nil {
+ return "", err
+ }
+ cmd := exec.CommandContext(ctx, "/proc/self/fd/3", args...)
+ cmd.ExtraFiles = []*os.File{f}
+ out, err := cmd.CombinedOutput()
+ return string(out), err
+}
+func ExecutingHash() (string, error) {
+ f, err := os.Open("/proc/self/exe")
+ if err != nil {
+ return "", err
+ }
+ defer f.Close()
+ raw, err := os.ReadFile(fmt.Sprintf("/proc/self/fd/%d", f.Fd()))
+ if err != nil {
+ return "", err
+ }
+ return Digest(raw), nil
+}
diff --git a/internal/hostfloor/image_other.go b/internal/hostfloor/image_other.go
new file mode 100644
index 000000000..0c267971c
--- /dev/null
+++ b/internal/hostfloor/image_other.go
@@ -0,0 +1,13 @@
+//go:build !linux
+
+package hostfloor
+
+import (
+ "context"
+ "os"
+)
+
+func OpenProtected(string) (*os.File, error) { return nil, ErrFloor }
+func (d Disk) Exec(string, string, []string, []string, ...string) error { return ErrFloor }
+func (d Disk) Run(context.Context, string, string, ...string) (string, error) { return "", ErrFloor }
+func ExecutingHash() (string, error) { return "", ErrFloor }
diff --git a/internal/hostfloor/remote.go b/internal/hostfloor/remote.go
new file mode 100644
index 000000000..179146f61
--- /dev/null
+++ b/internal/hostfloor/remote.go
@@ -0,0 +1,41 @@
+package hostfloor
+
+import (
+ "context"
+ "crypto/ed25519"
+ "errors"
+ "github.com/aws/smithy-go"
+ "github.com/carlosframework/platform/internal/release"
+ "net"
+)
+
+func Offline(err error) bool {
+ if errors.Is(err, context.DeadlineExceeded) {
+ return true
+ }
+ var network net.Error
+ if errors.As(err, &network) {
+ return true
+ }
+ var api smithy.APIError
+ if errors.As(err, &api) {
+ switch api.ErrorCode() {
+ case "SlowDown", "ServiceUnavailable", "InternalError":
+ return true
+ }
+ }
+ return false
+}
+func CheckRemote(ctx context.Context, s release.Store, pub ed25519.PublicKey, bucket string, local Verified) error {
+ remote, err := Read(ctx, s, pub, bucket)
+ if err != nil {
+ if Offline(err) {
+ return nil
+ }
+ return err
+ }
+ if remote.Digest != local.Digest {
+ return ErrFloor
+ }
+ return nil
+}
diff --git a/internal/hostfloor/report.go b/internal/hostfloor/report.go
new file mode 100644
index 000000000..c0f1a319a
--- /dev/null
+++ b/internal/hostfloor/report.go
@@ -0,0 +1,99 @@
+package hostfloor
+
+import (
+ "encoding/json"
+ "github.com/carlosframework/platform/internal/release"
+ "os"
+ "path/filepath"
+ "strings"
+)
+
+type Running struct {
+ ReleaseTag string
+ Hash string
+ Protocol int
+ BootID string
+ FloorSHA256 string
+ GuardReady bool
+}
+
+func (d Disk) Running(build, platform string, role ...string) (Running, error) {
+ component := "router"
+ if len(role) == 1 {
+ component = role[0]
+ } else if len(role) > 1 {
+ return Running{}, ErrFloor
+ }
+ _, f, err := d.Load()
+ if err != nil {
+ return Running{}, err
+ }
+ if component == "console" {
+ path, err := os.ReadFile(filepath.Join(d.Dir, "console-ready.json"))
+ if err != nil {
+ return Running{}, err
+ }
+ dropin, err := os.ReadFile("/etc/systemd/system/console.service.d/90-host-floor.conf")
+ if err != nil {
+ return Running{}, err
+ }
+ want := "[Service]\nExecStart=\nExecStart=/opt/carlos/system/guard/carlos system guard-exec console " + string(path) + "\n"
+ if string(dropin) != want {
+ return Running{}, ErrFloor
+ }
+ }
+ hash, err := ExecutingHash()
+ if err != nil {
+ return Running{}, err
+ }
+ boot, err := os.ReadFile("/proc/sys/kernel/random/boot_id")
+ if err != nil {
+ return Running{}, err
+ }
+ entries, err := os.ReadDir(filepath.Join(d.Dir, "identities"))
+ if err != nil {
+ return Running{}, err
+ }
+ for _, entry := range entries {
+ if entry.IsDir() {
+ continue
+ }
+ raw, err := os.ReadFile(filepath.Join(d.Dir, "identities", entry.Name()))
+ if err != nil {
+ return Running{}, err
+ }
+ if CheckComponent(d.PublicKey, f, raw, build, platform, hash, component) != nil {
+ continue
+ }
+ tag := strings.TrimSuffix(entry.Name(), ".json")
+ if _, err := os.Stat(filepath.Join(d.Dir, "carlos")); err != nil {
+ return Running{}, err
+ }
+ identity, err := release.VerifySystemIdentity(d.PublicKey, raw)
+ if err != nil {
+ return Running{}, err
+ }
+ return Running{tag, hash, identity.Current.MovesProtocol, strings.TrimSpace(string(boot)), f.Digest, true}, nil
+ }
+ return Running{}, ErrFloor
+}
+
+// Enrollment is installed by root from the verified roster. It is reporting
+// configuration; readers always compare it to the fresh signed roster.
+type Enrollment struct {
+ Box string `json:"box"`
+ Enrollment string `json:"enrollment"`
+ InstanceID string `json:"instance_id"`
+}
+
+func ReadEnrollment(path string) (Enrollment, error) {
+ raw, err := os.ReadFile(path)
+ if err != nil {
+ return Enrollment{}, err
+ }
+ var e Enrollment
+ if json.Unmarshal(raw, &e) != nil || e.Box == "" || e.Enrollment == "" || e.InstanceID == "" {
+ return Enrollment{}, ErrFloor
+ }
+ return e, nil
+}
diff --git a/internal/release/systemidentity.go b/internal/release/systemidentity.go
new file mode 100644
index 000000000..c65a52cbd
--- /dev/null
+++ b/internal/release/systemidentity.go
@@ -0,0 +1,111 @@
+package release
+
+import (
+ "bytes"
+ "context"
+ "crypto/ed25519"
+ "encoding/base64"
+ "encoding/json"
+ "errors"
+ "os"
+ "reflect"
+ "strings"
+)
+
+func ReleaseTag(tag string) (SemVer, bool) {
+ if !strings.HasPrefix(tag, "v") {
+ return SemVer{}, false
+ }
+ return ParseSemVer(strings.TrimPrefix(tag, "v"))
+}
+
+func VerifySystemIdentity(pub ed25519.PublicKey, wire []byte) (*SystemPointer, error) {
+ var wrapper signedWrapper
+ if json.Unmarshal(wire, &wrapper) != nil {
+ return nil, ErrBadSystemPointer
+ }
+ raw, err := base64.StdEncoding.DecodeString(wrapper.Payload)
+ if err != nil {
+ return nil, ErrBadSystemPointer
+ }
+ var claimed SystemPointer
+ if json.Unmarshal(raw, &claimed) != nil {
+ return nil, ErrBadSystemPointer
+ }
+ p, err := verifySystemPointer(pub, wire, SystemComponentRouter, claimed.Channel)
+ if err != nil {
+ return nil, err
+ }
+ if _, ok := ReleaseTag(p.Current.ReleaseTag); !ok || p.Current.MovesProtocol < 1 {
+ return nil, ErrBadSystemPointer
+ }
+ return p, nil
+}
+
+func IdentityKey(tag string) (string, error) {
+ if _, ok := ReleaseTag(tag); !ok {
+ return "", ErrBadSystemPointer
+ }
+ return "control/system/router/releases/" + tag + ".json", nil
+}
+
+// PublishSystemIdentity accepts a release-workflow signed pointer, never an
+// upload request's assertion of its own tag.
+func PublishSystemIdentity(ctx context.Context, s Store, pub ed25519.PublicKey, wire []byte) error {
+ p, err := VerifySystemIdentity(pub, wire)
+ if err != nil {
+ return err
+ }
+ key, err := IdentityKey(p.Current.ReleaseTag)
+ if err != nil {
+ return err
+ }
+ _, err = s.Put(ctx, key, wire, "")
+ if !errors.Is(err, ErrCASConflict) {
+ return err
+ }
+ old, _, err := s.Get(ctx, key)
+ if err != nil {
+ return err
+ }
+ prev, err := VerifySystemIdentity(pub, old)
+ if err != nil || !reflect.DeepEqual(prev.Current, p.Current) {
+ return ErrCASConflict
+ }
+ return nil
+}
+
+func GetSystemIdentity(ctx context.Context, s Store, pub ed25519.PublicKey, tag string) (*SystemPointer, error) {
+ key, err := IdentityKey(tag)
+ if err != nil {
+ return nil, err
+ }
+ raw, _, err := s.Get(ctx, key)
+ if err != nil {
+ return nil, err
+ }
+ p, err := VerifySystemIdentity(pub, raw)
+ if err != nil {
+ return nil, err
+ }
+ if p.Current.ReleaseTag != tag {
+ return nil, ErrBadSystemPointer
+ }
+ return p, nil
+}
+
+func CreateExact(ctx context.Context, s Store, key string, raw []byte) error {
+ _, err := s.Put(ctx, key, raw, "")
+ if err == nil {
+ return nil
+ }
+ // A timed-out PUT may have committed. Only identical bytes count as success.
+ got, _, readErr := s.Get(ctx, key)
+ if readErr == nil && bytes.Equal(raw, got) {
+ return nil
+ }
+ if readErr != nil && !errors.Is(readErr, os.ErrNotExist) {
+ return errors.Join(err, readErr)
+ }
+ return err
+}
diff --git a/internal/release/systempointer.go b/internal/release/systempointer.go
index 95d44314e..569794229 100644
--- a/internal/release/systempointer.go
+++ b/internal/release/systempointer.go
@@ -19,24 +19,29 @@ import (
// grant's rungs name app channels, and a key that can roll the platform is a
// bigger key than any app promoter should hold. Spec ยง3.
type SystemPointer struct {
- Schema int `json:"schema"`
- Component string `json:"component"`
- Channel string `json:"channel"`
- Current SystemRelease `json:"current"`
- History []SystemRelease `json:"history,omitempty"`
- Spread string `json:"spread"`
- PromotedAt time.Time `json:"promoted_at"` // of THIS write; the box's replay guard reads only this
- Audit []Promotion `json:"audit,omitempty"`
+ ActivationID string `json:"activation_id,omitempty"`
+ CancellationID string `json:"cancellation_id,omitempty"`
+ Schema int `json:"schema"`
+ Component string `json:"component"`
+ Channel string `json:"channel"`
+ Current SystemRelease `json:"current"`
+ History []SystemRelease `json:"history,omitempty"`
+ Spread string `json:"spread"`
+ PromotedAt time.Time `json:"promoted_at"` // of THIS write; the box's replay guard reads only this
+ Audit []Promotion `json:"audit,omitempty"`
}
// SystemRelease is everything a box needs to install one version. It travels
// whole into History so a rollback re-installs what was there, not a version
// string with someone else's artifacts.
type SystemRelease struct {
- Version string `json:"version"`
- Artifacts map[string]SystemArtifact `json:"artifacts"` // keyed by "<goos>-<goarch>"
- Source string `json:"source,omitempty"`
- ReleasedAt time.Time `json:"released_at"` // audit only; never compared
+ ConsoleArtifacts map[string]SystemArtifact `json:"console_artifacts,omitempty"`
+ ReleaseTag string `json:"release_tag,omitempty"`
+ MovesProtocol int `json:"moves_protocol,omitempty"`
+ Version string `json:"version"`
+ Artifacts map[string]SystemArtifact `json:"artifacts"` // keyed by "<goos>-<goarch>"
+ Source string `json:"source,omitempty"`
+ ReleasedAt time.Time `json:"released_at"` // audit only; never compared
}
// SystemArtifact is one mirrored binary. Its sha in the signed pointer IS
@@ -101,6 +106,11 @@ func validateSystemRelease(r SystemRelease) error {
if !ValidVersion(r.Version) || r.Version == "dev" {
return fmt.Errorf("version %q is not a valid platform version", r.Version)
}
+ for platform, artifact := range r.ConsoleArtifacts {
+ if !systemPlatformRe.MatchString(platform) || artifact.Key != "bootstrap/console/"+r.Version+"/carlos-"+platform || !systemSHA256Re.MatchString(artifact.SHA256) {
+ return ErrBadSystemPointer
+ }
+ }
if len(r.Artifacts) == 0 {
return fmt.Errorf("version %s names no artifacts", r.Version)
}
@@ -128,6 +138,9 @@ func validateSystemPointer(p *SystemPointer, wantComponent, wantChannel string)
if !systemComponentRe.MatchString(p.Component) || !ValidChannel(p.Channel) {
return fmt.Errorf("system pointer %s/%s: bad component or channel", p.Component, p.Channel)
}
+ if p.CancellationID != "" && p.Current.Version == "" {
+ return nil
+ }
if err := validateSystemRelease(p.Current); err != nil {
return fmt.Errorf("system pointer %s/%s: %w", p.Component, p.Channel, err)
}
@@ -226,6 +239,9 @@ func GetSystemPointer(ctx context.Context, s Store, pub ed25519.PublicKey, compo
if err != nil {
return nil, "", err
}
+ if p.CancellationID != "" && p.Current.Version == "" {
+ return nil, etag, os.ErrNotExist
+ }
return p, etag, nil
}
@@ -240,20 +256,31 @@ type SystemPromoteOptions struct {
// (a re-promote of the running version is a no-op for boxes and must not
// push that version into its own history).
func PromoteSystem(ctx context.Context, s Store, sg Signer, component, channel string, rel SystemRelease, o SystemPromoteOptions) (*SystemPointer, error) {
+ prepared, err := PrepareSystemPromotion(ctx, s, sg, component, channel, rel, o, "")
+ if err != nil {
+ return nil, err
+ }
+ return PublishSystemPublication(ctx, s, sg.verifyPub(), prepared)
+}
+
+func PrepareSystemPromotion(ctx context.Context, s Store, sg Signer, component, channel string, rel SystemRelease, o SystemPromoteOptions, id string) (SystemPublication, error) {
key := SystemPointerKey(component, channel)
var old *SystemPointer
body, etag, err := s.Get(ctx, key)
switch {
case err == nil:
if old, err = verifySystemPointer(sg.verifyPub(), body, component, channel); err != nil {
- return nil, err
+ return SystemPublication{}, err
}
case errors.Is(err, os.ErrNotExist):
etag = ""
default:
- return nil, err
+ return SystemPublication{}, err
}
- next := &SystemPointer{Component: component, Channel: channel, Current: rel, Spread: o.Spread.String()}
+ if old != nil && old.CancellationID != "" && old.Current.Version == "" {
+ old = nil
+ }
+ next := &SystemPointer{ActivationID: id, Component: component, Channel: channel, Current: rel, Spread: o.Spread.String()}
if rel.ReleasedAt.IsZero() {
next.Current.ReleasedAt = time.Now().UTC().Truncate(time.Second)
}
@@ -268,12 +295,9 @@ func PromoteSystem(ctx context.Context, s Store, sg Signer, component, channel s
next.Audit = prependCapped(Promotion{Version: rel.Version, From: from, To: channel, Actor: o.Actor, At: time.Now().UTC()}, next.Audit, auditMax)
wrapper, err := signSystemPointer(sg, next)
if err != nil {
- return nil, err
+ return SystemPublication{}, err
}
- if _, err := s.Put(ctx, key, wrapper, etag); err != nil {
- return nil, err
- }
- return next, nil
+ return SystemPublication{Component: component, Channel: channel, ID: id, ExpectedETag: etag, Before: body, After: wrapper}, nil
}
// RollbackSystem POPS History[0] rather than swapping with it: History[0]
@@ -284,30 +308,38 @@ func PromoteSystem(ctx context.Context, s Store, sg Signer, component, channel s
//
// It writes a NEW pointer โ€” new PromotedAt โ€” so every box's replay guard
// sees a promote, not a replay.
-func RollbackSystem(ctx context.Context, s Store, sg Signer, component, channel, actor string) (*SystemPointer, error) {
+func RollbackSystem(ctx context.Context, s Store, sg Signer, component, channel, actor string, expectedETag ...string) (*SystemPointer, error) {
+ prepared, err := PrepareSystemRollback(ctx, s, sg, component, channel, actor, "", expectedETag...)
+ if err != nil {
+ return nil, err
+ }
+ return PublishSystemPublication(ctx, s, sg.verifyPub(), prepared)
+}
+
+func PrepareSystemRollback(ctx context.Context, s Store, sg Signer, component, channel, actor, id string, expectedETag ...string) (SystemPublication, error) {
key := SystemPointerKey(component, channel)
body, etag, err := s.Get(ctx, key)
if err != nil {
- return nil, err
+ return SystemPublication{}, err
+ }
+ if len(expectedETag) > 1 || (len(expectedETag) == 1 && etag != expectedETag[0]) {
+ return SystemPublication{}, ErrCASConflict
}
old, err := verifySystemPointer(sg.verifyPub(), body, component, channel)
if err != nil {
- return nil, err
+ return SystemPublication{}, err
}
if len(old.History) == 0 {
- return nil, ErrNoSystemHistory
+ return SystemPublication{}, ErrNoSystemHistory
}
- next := &SystemPointer{
+ next := &SystemPointer{ActivationID: id,
Component: component, Channel: channel,
Current: old.History[0], History: old.History[1:], Spread: old.Spread,
Audit: prependCapped(Promotion{Version: old.History[0].Version, From: old.Current.Version, To: channel, Actor: actor, At: time.Now().UTC()}, old.Audit, auditMax),
}
wrapper, err := signSystemPointer(sg, next)
if err != nil {
- return nil, err
- }
- if _, err := s.Put(ctx, key, wrapper, etag); err != nil {
- return nil, err
+ return SystemPublication{}, err
}
- return next, nil
+ return SystemPublication{Component: component, Channel: channel, ID: id, ExpectedETag: etag, Before: body, After: wrapper}, nil
}
diff --git a/internal/release/systempublication.go b/internal/release/systempublication.go
new file mode 100644
index 000000000..82d816365
--- /dev/null
+++ b/internal/release/systempublication.go
@@ -0,0 +1,93 @@
+package release
+
+import (
+ "bytes"
+ "context"
+ "crypto/ed25519"
+ "errors"
+ "os"
+)
+
+// SystemPublication retains the exact CAS and signed bytes across retries.
+type SystemPublication struct {
+ Component string `json:"component"`
+ Channel string `json:"channel"`
+ ID string `json:"id"`
+ ExpectedETag string `json:"expected_etag"`
+ Before []byte `json:"before,omitempty"`
+ After []byte `json:"after"`
+}
+
+func (p SystemPublication) Verify(pub ed25519.PublicKey) (*SystemPointer, error) {
+ next, err := verifySystemPointer(pub, p.After, p.Component, p.Channel)
+ if err != nil {
+ return nil, err
+ }
+ if next.ActivationID != p.ID || next.CancellationID != "" {
+ return nil, ErrBadSystemPointer
+ }
+ return next, nil
+}
+
+func PublishSystemPublication(ctx context.Context, s Store, pub ed25519.PublicKey, p SystemPublication) (*SystemPointer, error) {
+ next, err := p.Verify(pub)
+ if err != nil {
+ return nil, err
+ }
+ key := SystemPointerKey(p.Component, p.Channel)
+ _, err = s.Put(ctx, key, p.After, p.ExpectedETag)
+ if err == nil {
+ return next, nil
+ }
+ // A failed response can follow a committed write. Only these exact signed
+ // bytes prove completion; a different pointer never authorizes another CAS.
+ raw, _, readErr := s.Get(ctx, key)
+ if readErr == nil && bytes.Equal(raw, p.After) {
+ return next, nil
+ }
+ return nil, err
+}
+
+// CancelSystemPublication competes with publication on its original pointer
+// ETag. The fence must be durable before the actor reservation is removed.
+func CancelSystemPublication(ctx context.Context, s Store, sg Signer, p SystemPublication) error {
+ if p.ID == "" {
+ return ErrBadSystemPointer
+ }
+ if _, err := p.Verify(sg.verifyPub()); err != nil {
+ return err
+ }
+ before := &SystemPointer{Schema: 1, Component: p.Component, Channel: p.Channel}
+ if len(p.Before) != 0 {
+ var err error
+ before, err = verifySystemPointer(sg.verifyPub(), p.Before, p.Component, p.Channel)
+ if err != nil {
+ return err
+ }
+ }
+ before.CancellationID = p.ID
+ fence, err := SignRootEnvelope(sg, before)
+ if err != nil {
+ return err
+ }
+ key := SystemPointerKey(p.Component, p.Channel)
+ raw, etag, err := s.Get(ctx, key)
+ if err == nil && bytes.Equal(raw, fence) {
+ return nil
+ }
+ if err != nil && !errors.Is(err, os.ErrNotExist) {
+ return err
+ }
+ if etag != p.ExpectedETag || (err == nil && bytes.Equal(raw, p.After)) {
+ return ErrCASConflict
+ }
+ _, err = s.Put(ctx, key, fence, p.ExpectedETag)
+ if err == nil {
+ return nil
+ }
+ raw, _, readErr := s.Get(ctx, key)
+ if readErr == nil && bytes.Equal(raw, fence) {
+ return nil
+ }
+ return err
+}
diff --git a/internal/release/systempublication_test.go b/internal/release/systempublication_test.go
new file mode 100644
index 000000000..1ca087a80
--- /dev/null
+++ b/internal/release/systempublication_test.go
@@ -0,0 +1,98 @@
+package release
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "os"
+ "testing"
+)
+
+type heldSystemPublicationStore struct {
+ Store
+ key string
+ entered chan struct{}
+ resume chan struct{}
+}
+
+func (s heldSystemPublicationStore) Put(ctx context.Context, key string, raw []byte, etag string) (string, error) {
+ if key == s.key {
+ close(s.entered)
+ <-s.resume
+ }
+ return s.Store.Put(ctx, key, raw, etag)
+}
+
+func TestSystemPublicationCancellationWinsDelayedCAS(t *testing.T) {
+ for _, existing := range []bool{false, true} {
+ name := "absent"
+ if existing {
+ name = "existing"
+ }
+ t.Run(name, func(t *testing.T) {
+ ctx := t.Context()
+ key, pub := testKeys(t)
+ sg := RootSigner(key)
+ store := NewDirStore(t.TempDir())
+ if existing {
+ if _, err := PromoteSystem(ctx, store, sg, "router", "stable", testSystemRelease("v1.0.0"), SystemPromoteOptions{}); err != nil {
+ t.Fatal(err)
+ }
+ }
+ prepared, err := PrepareSystemPromotion(ctx, store, sg, "router", "stable", testSystemRelease("v1.1.0"), SystemPromoteOptions{}, "cancelled-update")
+ if err != nil {
+ t.Fatal(err)
+ }
+ held := heldSystemPublicationStore{Store: store, key: SystemPointerKey("router", "stable"), entered: make(chan struct{}), resume: make(chan struct{})}
+ done := make(chan error, 1)
+ go func() { _, err := PublishSystemPublication(ctx, held, pub, prepared); done <- err }()
+ <-held.entered
+ if err := CancelSystemPublication(ctx, store, sg, prepared); err != nil {
+ close(held.resume)
+ t.Fatal(err)
+ }
+ close(held.resume)
+ if err := <-done; !errors.Is(err, ErrCASConflict) {
+ t.Fatalf("delayed CAS: %v", err)
+ }
+ if err := CancelSystemPublication(ctx, store, sg, prepared); err != nil {
+ t.Fatalf("cancel retry: %v", err)
+ }
+ p, etag, err := GetSystemPointer(ctx, store, pub, "router", "stable")
+ if existing {
+ if err != nil || p.Current.Version != "v1.0.0" {
+ t.Fatal(p, err)
+ }
+ } else if !errors.Is(err, os.ErrNotExist) || etag == "" {
+ t.Fatalf("tombstone read: %s %v", etag, err)
+ }
+ if _, err := PromoteSystem(ctx, store, sg, "router", "stable", testSystemRelease("v1.2.0"), SystemPromoteOptions{}); err != nil {
+ t.Fatalf("new promotion after fence: %v", err)
+ }
+ if _, err := PublishSystemPublication(ctx, store, pub, prepared); !errors.Is(err, ErrCASConflict) {
+ t.Fatalf("cancelled activation resurrected: %v", err)
+ }
+ })
+ }
+}
+
+func TestSystemPublicationCancellationCannotUndoCommittedUpdate(t *testing.T) {
+ ctx := t.Context()
+ key, pub := testKeys(t)
+ sg := RootSigner(key)
+ store := NewDirStore(t.TempDir())
+ p, err := PrepareSystemPromotion(ctx, store, sg, "router", "stable", testSystemRelease("v1.0.0"), SystemPromoteOptions{}, "committed-update")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := PublishSystemPublication(ctx, store, pub, p); err != nil {
+ t.Fatal(err)
+ }
+ if err := CancelSystemPublication(ctx, store, sg, p); !errors.Is(err, ErrCASConflict) {
+ t.Fatal(err)
+ }
+ raw, _, err := store.Get(ctx, SystemPointerKey("router", "stable"))
+ if err != nil || !bytes.Equal(raw, p.After) {
+ t.Fatal("committed publication changed", err)
+ }
+}