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>
11 files changed,
+1017
−30
internal/hostfloor/disk.go+193 −0internal/hostfloor/floor.go+131 −0internal/hostfloor/floor_test.go+89 −0internal/hostfloor/image_linux.go+87 −0internal/hostfloor/image_other.go+13 −0internal/hostfloor/remote.go+41 −0internal/hostfloor/report.go+99 −0internal/release/systemidentity.go+111 −0internal/release/systempointer.go+62 −30internal/release/systempublication.go+93 −0internal/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) |
| + } |
| +} |