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>
4 files changed,
+369
−12
internal/activator/admintoken.go+2 −12internal/activator/backend_exec.go+11 −0internal/instancekey/instancekey.go+232 −0internal/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) |
| + } |
| + } |
| +} |