carlos / platform Public

instancekey: one rule for the instance key's parse, effective layer and adoption record (L21)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev c568c13b8179c9ee1d25bfb7f624539d8a4c20a1 parent 2c39819
4 files changed, +369 −12
  • internal/activator/admintoken.go +2 −12
  • internal/activator/backend_exec.go +11 −0
  • internal/instancekey/instancekey.go +232 −0
  • internal/instancekey/instancekey_test.go +124 −0
diff --git a/internal/activator/admintoken.go b/internal/activator/admintoken.go
index c92e23dbd..6b4960122 100644
--- a/internal/activator/admintoken.go
+++ b/internal/activator/admintoken.go
@@ -28,11 +28,11 @@ import (
"log"
"os"
"path/filepath"
- "strings"
"sync"
"time"
"github.com/carlosframework/platform/internal/instancefs"
+ "github.com/carlosframework/platform/internal/instancekey"
)
// adminTokenMu serializes minting across the process. Two callers can find
@@ -243,15 +243,5 @@ func ensureAdminToken(dir, name string, beneath bool) error {
// set" and "does the file end in a newline" from ONE read of the file — two
// reads could disagree, and the second one decides where the token lands.
func parseEnvBytes(raw []byte) map[string]string {
- m := map[string]string{}
- for _, line := range strings.Split(string(raw), "\n") {
- line = strings.TrimSpace(line)
- if line == "" || strings.HasPrefix(line, "#") {
- continue
- }
- if k, v, ok := strings.Cut(line, "="); ok {
- m[strings.TrimSpace(k)] = strings.TrimSpace(v)
- }
- }
- return m
+ return instancekey.ParseEnv(raw)
}
diff --git a/internal/activator/backend_exec.go b/internal/activator/backend_exec.go
index 96e66227d..39491ef4b 100644
--- a/internal/activator/backend_exec.go
+++ b/internal/activator/backend_exec.go
@@ -24,6 +24,7 @@ import (
"github.com/carlosframework/platform/internal/cgroup"
"github.com/carlosframework/platform/internal/edge"
"github.com/carlosframework/platform/internal/instancectl"
+ "github.com/carlosframework/platform/internal/instancekey"
"github.com/carlosframework/platform/internal/lease"
"github.com/carlosframework/platform/internal/moves"
"github.com/carlosframework/platform/internal/registry"
@@ -1884,6 +1885,9 @@ func (b *ExecBackend) LaunchEnvironment(rt registry.Route) (map[string]string, [
var shadowed []string
for k := range ownEnv {
if _, rendered := cfgEnv[k]; rendered {
+ if k == instancekey.Var && ownEnv[k] == "" {
+ continue // an empty own key shadows nothing (mergeEnv)
+ }
shadowed = append(shadowed, k)
}
}
@@ -1985,6 +1989,13 @@ func mergeEnv(base, overlay map[string]string) map[string]string {
for k, v := range overlay {
out[k] = v
}
+ // An empty CARLOS_INSTANCE_KEY in the instance's own file supplies no
+ // key, so it never shadows a configured one: the child is never handed
+ // an empty key while a non-empty one exists (instance-key plan ruling
+ // 13). instancekey.Effective is the rule the key's mint decides by.
+ if key, _ := instancekey.Effective(base, overlay); key != "" {
+ out[instancekey.Var] = key
+ }
return out
}
diff --git a/internal/instancekey/instancekey.go b/internal/instancekey/instancekey.go
new file mode 100644
index 000000000..57788d5e1
--- /dev/null
+++ b/internal/instancekey/instancekey.go
@@ -0,0 +1,232 @@
+// Package instancekey is the one rule for the platform's per-instance key
+// (region HA L21, rastrillo #66): how an env file is parsed, which file's
+// CARLOS_INSTANCE_KEY a launched app receives, and the adoption record, a
+// writer-bound assertion from the app's helper that it seals with that
+// key. The activator mints and
+// delivers the key by these rules, the control socket records adoption by
+// them, and the relocation pinner checks its evidence by them, so the three
+// can never disagree about what the app was given.
+//
+// It imports only the standard library, so the pinner can use it without
+// importing the activator.
+package instancekey
+
+import (
+ "bytes"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "strings"
+)
+
+// Var is the variable the key is delivered as.
+const Var = "CARLOS_INSTANCE_KEY"
+
+// AdoptionSuffix names the adoption record beside an instance's own env
+// file: <host>.env's sibling <host>.instance-key-adoption.json. It is an
+// installation artifact: moves retire it with <host>.env (O7), and nested
+// it lives in the instance directory, retired with it.
+const AdoptionSuffix = ".instance-key-adoption.json"
+
+// AdoptionItem is the adoption record's name among a files manifest's
+// platform items, beside hostfiles' EnvItem ("host.env").
+const AdoptionItem = "instance-key-adoption.json"
+
+// ParseEnv parses an EnvironmentFile: KEY=VALUE lines, blank lines and #
+// comments skipped, the key and the value trimmed of surrounding
+// whitespace, the last of two lines for one key winning. It is exactly
+// what the activator hands a child, so a reader that wants to know what an
+// app received must parse with it. "export KEY=..." is the key "export
+// KEY", and a quoted value keeps its quotes.
+func ParseEnv(raw []byte) map[string]string {
+ m := map[string]string{}
+ for _, line := range strings.Split(string(raw), "\n") {
+ line = strings.TrimSpace(line)
+ if line == "" || strings.HasPrefix(line, "#") {
+ continue
+ }
+ if k, v, ok := strings.Cut(line, "="); ok {
+ m[strings.TrimSpace(k)] = strings.TrimSpace(v)
+ }
+ }
+ return m
+}
+
+// Layer is which env file supplied the effective key.
+type Layer int
+
+const (
+ // LayerNone: neither file supplies a non-empty key.
+ LayerNone Layer = iota
+ // LayerOwn: the instance's own <host>.env, where the platform mints.
+ LayerOwn
+ // LayerConfig: the rendered <host>.config.env, the app's configuration.
+ LayerConfig
+)
+
+// Effective is the key a launched app receives from its two env files, and
+// which file supplied it. The instance's own file wins, as in every other
+// variable (the activator's mergeEnv), but an empty value supplies no key:
+// an empty CARLOS_INSTANCE_KEY= in the own file never shadows a non-empty
+// configured key, so an app is never handed an empty key while a non-empty
+// one exists.
+func Effective(config, own map[string]string) (string, Layer) {
+ if k := own[Var]; k != "" {
+ return k, LayerOwn
+ }
+ if k := config[Var]; k != "" {
+ return k, LayerConfig
+ }
+ return "", LayerNone
+}
+
+// Digest is the lowercase hex SHA-256 of key: what an app's helper sends
+// to say which key it resolved, and what the adoption record holds. The
+// record is published beside the env file that holds a platform key
+// itself, so the digest gives that file's reader nothing new.
+func Digest(key string) string {
+ sum := sha256.Sum256([]byte(key))
+ return hex.EncodeToString(sum[:])
+}
+
+// The two sources rastrillo's step 2 can have resolved. Both arrive as
+// $CARLOS_INSTANCE_KEY once the env files are merged.
+const (
+ SourcePlatform = "platform" // the instance's own <host>.env, where the platform mints
+ SourceConfig = "config" // the rendered <host>.config.env, the app's configuration
+)
+
+// SourceOf names the source of a layer, "" for LayerNone.
+func SourceOf(l Layer) string {
+ switch l {
+ case LayerOwn:
+ return SourcePlatform
+ case LayerConfig:
+ return SourceConfig
+ }
+ return ""
+}
+
+// Binding is the writer an adoption belongs to: the host, its installation
+// (the claim holder's incarnation), and the writer session and generation
+// the process was admitted as. The agent takes all four from its own
+// launch state for the process, never from the request.
+type Binding struct {
+ Host string `json:"host"`
+ Incarnation string `json:"incarnation"`
+ Session uint64 `json:"session"`
+ Generation string `json:"generation"`
+}
+
+func (b Binding) valid() bool {
+ return b.Host != "" && b.Incarnation != "" && b.Session != 0 && b.Generation != ""
+}
+
+// Adoption is the record the agent writes when a process reports that its
+// helper resolved the key at step 2: which writer, which source, which
+// key (by digest). It is a trusted-helper assertion, not independent
+// evidence: it stops accidental loss, and a tenant who forges one can lose
+// only its own data (instance-key plan ruling 19).
+type Adoption struct {
+ V int `json:"v"`
+ Var string `json:"var"`
+ Binding
+ Source string `json:"source"`
+ SHA256 string `json:"sha256"`
+}
+
+// AdoptionRecord is the canonical bytes of the record for writer b, which
+// was handed the key with digest sha from source.
+func AdoptionRecord(b Binding, source, sha string) []byte {
+ raw, _ := json.Marshal(Adoption{V: 2, Var: Var, Binding: b, Source: source, SHA256: sha})
+ return append(raw, '\n')
+}
+
+// ParseAdoption reads a record strictly: version 2, this variable, a
+// complete binding, a known source and a digest. Anything else is an
+// error, never a record.
+func ParseAdoption(record []byte) (Adoption, error) {
+ var a Adoption
+ dec := json.NewDecoder(bytes.NewReader(record))
+ dec.DisallowUnknownFields()
+ if err := dec.Decode(&a); err != nil {
+ return Adoption{}, fmt.Errorf("instancekey: adoption record: %w", err)
+ }
+ if dec.More() || a.V != 2 || a.Var != Var || !a.Binding.valid() || (a.Source != SourcePlatform && a.Source != SourceConfig) || !ValidDigest(a.SHA256) {
+ return Adoption{}, errors.New("instancekey: adoption record: not a version 2 record for " + Var)
+ }
+ return a, nil
+}
+
+// Binds reports whether the record belongs to exactly writer w. A record
+// from another installation, another session or another generation (an
+// earlier release, a rollback, a reinstall) never vouches for w.
+func (a Adoption) Binds(w Binding) bool {
+ return w.valid() && a.Binding == w
+}
+
+// VerifyDelivered checks that the env files a relocation target is about
+// to launch with deliver the key pinned by digest (O3'): the effective key
+// of the rendered configuration and the restored own env file, by
+// Effective's rule. A configuration changed since the pin, a key removed,
+// or a platform key where a configured one was pinned, is an error; the
+// install refuses rather than hand the app a different key.
+func VerifyDelivered(configEnv, ownEnv []byte, pinnedSHA string) error {
+ key, _ := Effective(ParseEnv(configEnv), ParseEnv(ownEnv))
+ if key == "" || Digest(key) != pinnedSHA {
+ return errors.New("instancekey: the env files would not deliver the pinned " + Var)
+ }
+ return nil
+}
+
+// ValidDigest reports whether s is a lowercase hex SHA-256.
+func ValidDigest(s string) bool {
+ if len(s) != 64 {
+ return false
+ }
+ for _, r := range s {
+ if (r < '0' || r > '9') && (r < 'a' || r > 'f') {
+ return false
+ }
+ }
+ return true
+}
+
+// ParseHomeKeys reads CARLOS_REPLICA_HOME_KEYS, the box's list of verified
+// home keys for its replica rows (instance-key plan rulings 26 and 27):
+// "replica-host=sha256,replica-host=sha256", each digest the home's
+// effective key as the pre-roll check computed it. Only the pre-roll check
+// writes it; nothing refreshes it from a replica's own configuration.
+// Whitespace around entries is ignored and "" is an empty list. A
+// malformed entry, or a host named twice, fails the whole value: a list
+// that may be wrong is not a list to verify keys against. Its errors name
+// the entry by position and the reason, never the entry's text, which may
+// hold a digest.
+func ParseHomeKeys(s string) (map[string]string, error) {
+ out := map[string]string{}
+ n := 0
+ for _, e := range strings.Split(s, ",") {
+ e = strings.TrimSpace(e)
+ if e == "" {
+ continue
+ }
+ n++
+ host, sha, ok := strings.Cut(e, "=")
+ host, sha = strings.TrimSpace(host), strings.TrimSpace(sha)
+ switch {
+ case !ok:
+ return nil, fmt.Errorf("instancekey: CARLOS_REPLICA_HOME_KEYS entry %d: no \"replica-host=\" before the digest", n)
+ case host == "" || strings.ContainsAny(host, " /=") || !strings.Contains(host, "."):
+ return nil, fmt.Errorf("instancekey: CARLOS_REPLICA_HOME_KEYS entry %d: the host is empty or not a host name", n)
+ case !ValidDigest(sha):
+ return nil, fmt.Errorf("instancekey: CARLOS_REPLICA_HOME_KEYS entry %d: the digest is not 64 lowercase hex characters", n)
+ }
+ if _, dup := out[host]; dup {
+ return nil, fmt.Errorf("instancekey: CARLOS_REPLICA_HOME_KEYS entry %d: its host is listed by an earlier entry", n)
+ }
+ out[host] = sha
+ }
+ return out, nil
+}
diff --git a/internal/instancekey/instancekey_test.go b/internal/instancekey/instancekey_test.go
new file mode 100644
index 000000000..39b3eb89b
--- /dev/null
+++ b/internal/instancekey/instancekey_test.go
@@ -0,0 +1,124 @@
+package instancekey
+
+import (
+ "strings"
+ "testing"
+)
+
+// Finding 4: an empty own value never shadows a configured key; a
+// non-empty own value always wins.
+func TestEffectiveNeverHandsOutAnEmptyKeyWhileOneExists(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ config, own string
+ want string
+ layer Layer
+ }{
+ {"own wins", Var + "=cfg\n", Var + "=own\n", "own", LayerOwn},
+ {"empty own, configured", Var + "=cfg\n", Var + "=\n", "cfg", LayerConfig},
+ {"no own, configured", Var + "=cfg\n", "A=1\n", "cfg", LayerConfig},
+ {"own only", "", Var + "=own\n", "own", LayerOwn},
+ {"both empty", Var + "=\n", Var + "=\n", "", LayerNone},
+ {"exported is not the key", "", "export " + Var + "=own\n", "", LayerNone},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ got, layer := Effective(ParseEnv([]byte(tc.config)), ParseEnv([]byte(tc.own)))
+ if got != tc.want || layer != tc.layer {
+ t.Fatalf("Effective = %q, %v; want %q, %v", got, layer, tc.want, tc.layer)
+ }
+ })
+ }
+}
+
+// Ruling 20 (R-B): a record binds one writer. Another session, another
+// generation, another installation or another host never matches, so a
+// later release that picks its own key, a rollback and a reinstall never
+// inherit an earlier writer's adoption.
+func TestAdoptionRecordBindsOneWriter(t *testing.T) {
+ key := strings.Repeat("A", 43) + "="
+ w := Binding{Host: "shop.example.com", Incarnation: "inc-1", Session: 3, Generation: "g3"}
+ rec := AdoptionRecord(w, SourcePlatform, Digest(key))
+ if strings.Contains(string(rec), key) {
+ t.Fatal("the record holds the key itself")
+ }
+ a, err := ParseAdoption(rec)
+ if err != nil || !a.Binds(w) || a.Source != SourcePlatform || a.SHA256 != Digest(key) {
+ t.Fatalf("ParseAdoption = %+v, %v", a, err)
+ }
+ for name, other := range map[string]Binding{
+ "an earlier session (an earlier release)": {Host: w.Host, Incarnation: w.Incarnation, Session: 2, Generation: "g2"},
+ "a later session (a rollback)": {Host: w.Host, Incarnation: w.Incarnation, Session: 4, Generation: "g4"},
+ "the same session, another generation": {Host: w.Host, Incarnation: w.Incarnation, Session: 3, Generation: "g3b"},
+ "another installation (a reinstall)": {Host: w.Host, Incarnation: "inc-2", Session: 3, Generation: "g3"},
+ "another host": {Host: "other.example.com", Incarnation: w.Incarnation, Session: 3, Generation: "g3"},
+ "no writer": {},
+ } {
+ if a.Binds(other) {
+ t.Errorf("%s: the record matched", name)
+ }
+ }
+ for name, raw := range map[string]string{
+ "version 1": `{"v":1,"var":"CARLOS_INSTANCE_KEY","sha256":"` + Digest(key) + `"}`,
+ "no binding": `{"v":2,"var":"CARLOS_INSTANCE_KEY","source":"platform","sha256":"` + Digest(key) + `"}`,
+ "unknown source": `{"v":2,"var":"CARLOS_INSTANCE_KEY","host":"h","incarnation":"i","session":1,"generation":"g","source":"own","sha256":"` + Digest(key) + `"}`,
+ "an unknown field": `{"v":2,"var":"CARLOS_INSTANCE_KEY","host":"h","incarnation":"i","session":1,"generation":"g","source":"config","sha256":"` + Digest(key) + `","key":"x"}`,
+ "trailing data": string(rec) + `{}`,
+ "garbage": "not json",
+ } {
+ if _, err := ParseAdoption([]byte(raw)); err == nil {
+ t.Errorf("%s: parsed", name)
+ }
+ }
+}
+
+// O3': the install refuses env files that would deliver any key but the
+// pinned one, including a configuration changed since the pin.
+func TestVerifyDeliveredRefusesAnyOtherKey(t *testing.T) {
+ a, b := strings.Repeat("A", 43)+"=", strings.Repeat("B", 43)+"="
+ for _, tc := range []struct {
+ name, config, own string
+ ok bool
+ }{
+ {"the configured key, unchanged", Var + "=" + a + "\n", "T=1\n", true},
+ {"the configuration changed since the pin", Var + "=" + b + "\n", "T=1\n", false},
+ {"the configured key removed", "", "T=1\n", false},
+ {"an empty override over the configured key", Var + "=" + a + "\n", Var + "=\n", true},
+ {"the platform key, restored", "", Var + "=" + a + "\n", true},
+ {"a platform key over the pinned configured one", Var + "=" + a + "\n", Var + "=" + b + "\n", false},
+ } {
+ if err := VerifyDelivered([]byte(tc.config), []byte(tc.own), Digest(a)); (err == nil) != tc.ok {
+ t.Errorf("%s: err = %v", tc.name, err)
+ }
+ }
+}
+
+// Ruling 26: the pre-roll check's verified home keys, as the box reads
+// them. A list that may be wrong verifies nothing, and its errors never
+// echo an entry, which may hold a digest (Astra round 5 finding 3).
+func TestParseHomeKeys(t *testing.T) {
+ a, b := Digest("home-key-a"), Digest("home-key-b")
+ got, err := ParseHomeKeys(" shop-au.example.com=" + a + ", shop-uk.example.com=" + b + ",")
+ if err != nil || len(got) != 2 || got["shop-au.example.com"] != a || got["shop-uk.example.com"] != b {
+ t.Fatalf("ParseHomeKeys = %v, %v", got, err)
+ }
+ if got, err := ParseHomeKeys(""); err != nil || len(got) != 0 {
+ t.Fatalf("empty = %v, %v", got, err)
+ }
+ for _, tc := range []struct{ value, want string }{
+ {a, "entry 1: no \"replica-host=\""},
+ {"shop-au.example.com=" + a + "," + b, "entry 2: no \"replica-host=\""},
+ {"shop-au.example.com=" + a[:40], "entry 1: the digest is not"},
+ {"=" + a, "entry 1: the host is empty"},
+ {a[:20] + "=" + a, "entry 1: the host is empty or not a host name"},
+ {"shop-au.example.com=" + a + ",shop-au.example.com=" + b, "entry 2: its host is listed"},
+ } {
+ _, err := ParseHomeKeys(tc.value)
+ if err == nil || !strings.Contains(err.Error(), tc.want) {
+ t.Errorf("%q: err = %v, want %q", tc.value, err, tc.want)
+ continue
+ }
+ if strings.Contains(err.Error(), a[:16]) || strings.Contains(err.Error(), b[:16]) || strings.Contains(err.Error(), "shop-") {
+ t.Errorf("the error echoes its input: %v", err)
+ }
+ }
+}