carlos / platform Public

activator: a replica launches only with its home's key, verified, or none and a socket its helper refuses on (L21)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev 366067d54d6eb6969a468c1fe8babfc156b4043c parent f896570
12 files changed, +937 −9
  • internal/activator/backend_exec.go +31 −2
  • internal/activator/prepare.go +14 −1
  • internal/activator/replica_instancekey_linux_test.go +106 −0
  • internal/activator/replica_instancekey_test.go +268 −0
  • internal/activator/replicakey.go +122 −0
  • internal/activator/replicakey_race_test.go +94 −0
  • internal/appconfig/instancekey.go +34 −0
  • internal/appconfig/instancekey_test.go +42 −0
  • internal/host/agent.go +7 −0
  • internal/host/replicahomekeys.go +88 −0
  • internal/host/replicahomekeys_test.go +121 −0
  • internal/instancekey/instancekey.go +10 −6
diff --git a/internal/activator/backend_exec.go b/internal/activator/backend_exec.go
index 57323a46f..8d953eb9b 100644
--- a/internal/activator/backend_exec.go
+++ b/internal/activator/backend_exec.go
@@ -96,6 +96,20 @@ type ExecBackend struct {
// STRIPPED when the agent knows nothing — a tenant's config env must not
// be able to tell an app it runs somewhere it does not.
Region string
+ // ReplicaHomeKeys answers, for a replica row's host, the SHA-256 of its
+ // home's effective CARLOS_INSTANCE_KEY (instance-key plan rulings 26 and
+ // 30). O8 will supply it from the home's published env item; until then
+ // the agent reads the box's list (/etc/carlos/replica-home-keys), which
+ // only the pre-roll check writes, again at every call. Nil, or false for
+ // a host, means the home key cannot be verified, and a replica launch
+ // that would be handed a key is refused (replicaKeyGate).
+ ReplicaHomeKeys func(host string) (sha256 string, ok bool)
+ // ReplicaConfigKey reads rt's configuration from the bucket now and
+ // returns the SHA-256 of the CARLOS_INSTANCE_KEY it renders, "" for
+ // none (appconfig.ConfiguredKeyDigest). replicaKeyGate refuses a
+ // replica whose configured key is not that digest: a configuration
+ // change is in flight (ruling 27). Nil refuses every configured key.
+ ReplicaConfigKey func(ctx context.Context, rt registry.Route) (sha256 string, err error)
// Cache, when non-nil, adds the purge verb to each spawned instance's
// control socket so it can invalidate its own cached responses right
// after a write. Nil disables that ONE verb, not the socket: the control
@@ -837,10 +851,15 @@ func (b *ExecBackend) seedChild(ctx context.Context, rt registry.Route) error {
if _, err := b.ensureInstanceKeyFor(rt, paths); err != nil {
return fmt.Errorf("instance key for %s: %w", host, err)
}
- fileEnv, err := b.instanceEnv(rt)
+ fileEnv, keyLayer, err := b.instanceEnvLayer(rt)
if err != nil {
return err
}
+ // A seed has no control socket, so a replica's seed is gated as a
+ // launch without one (ruling 26), on the layer this same read found.
+ if err := b.replicaKeyGate(rt, fileEnv, keyLayer, false); err != nil {
+ return err
+ }
dbPath := paths.dbPath
// Whether the database existed BEFORE the seed ran decides whether a
@@ -1383,7 +1402,9 @@ func (b *ExecBackend) spawnChildFrom(ctx context.Context, rt registry.Route, soc
return nil, "", fmt.Errorf("instance key for %s: %w", host, err)
}
- fileEnv, err := b.instanceEnv(rt)
+ // One read gives the env and the layer its CARLOS_INSTANCE_KEY came
+ // from, for the replica gate below (ruling 29).
+ fileEnv, keyLayer, err := b.instanceEnvLayer(rt)
if err != nil {
return nil, "", err
}
@@ -1429,6 +1450,14 @@ func (b *ExecBackend) spawnChildFrom(ctx context.Context, rt registry.Route, soc
log.Printf("activator: control listener for %s: %v (continuing without it)", rt.Host, err)
ctl = nil
}
+ // A replica is started only with its home's key, verified, or with
+ // none and a control socket its helper can refuse on (ruling 26).
+ if err := b.replicaKeyGate(rt, fileEnv, keyLayer, ctl != nil); err != nil {
+ if ctl != nil {
+ ctl.Close()
+ }
+ return nil, "", err
+ }
// Both control-channel vars are AGENT-OWNED in BOTH directions: set when the
// channel exists, and REMOVED when it does not. The removal half is not
// decoration. fileEnv is tenant-supplied — <host>.config.env comes from the
diff --git a/internal/activator/prepare.go b/internal/activator/prepare.go
index 3ffc0af70..83706e7f9 100644
--- a/internal/activator/prepare.go
+++ b/internal/activator/prepare.go
@@ -19,6 +19,7 @@ import (
"github.com/carlosframework/platform/internal/edge"
"github.com/carlosframework/platform/internal/instancectl"
"github.com/carlosframework/platform/internal/instancefs"
+ "github.com/carlosframework/platform/internal/instancekey"
"github.com/carlosframework/platform/internal/provision"
"github.com/carlosframework/platform/internal/registry"
"github.com/carlosframework/platform/internal/release"
@@ -53,6 +54,7 @@ func (b *ExecBackend) prepareAt(rt registry.Route, role instancectl.Role, sock s
return p, nil, nil, nil, err
}
var env map[string]string
+ var keyLayer instancekey.Layer // the layer env's key came from, read with it (ruling 29)
if role == instancectl.RoleRun || role == instancectl.RoleSeed {
// p.dataDir is the instance directory here, tenant-writable ground
// (design §3.2), so the token file is minted and read through
@@ -64,7 +66,7 @@ func (b *ExecBackend) prepareAt(rt registry.Route, role instancectl.Role, sock s
return p, nil, nil, nil, fmt.Errorf("instance key for %s: %w", rt.Host, err)
}
var err error
- env, err = b.instanceEnv(rt)
+ env, keyLayer, err = b.instanceEnvLayer(rt)
if err != nil {
return p, nil, nil, nil, err
}
@@ -105,6 +107,17 @@ func (b *ExecBackend) prepareAt(rt registry.Route, role instancectl.Role, sock s
_ = os.Remove(sock)
ctl = b.listenControl(rt, sock+".control", p.envFile, gen)
}
+ // A replica's run and seed roles are gated like a flat launch: the
+ // seed has no control socket, the run role has the one just bound or
+ // none (ruling 26). The sidecar role reads no instance env.
+ if role == instancectl.RoleRun || role == instancectl.RoleSeed {
+ if err := b.replicaKeyGate(rt, env, keyLayer, ctl != nil); err != nil {
+ if ctl != nil {
+ ctl.Close()
+ }
+ return p, nil, nil, nil, err
+ }
+ }
logs, err := listenLogs(b.logSocketFor(rt.Host, role, gen), b.logEmitter(rt))
if err != nil {
if ctl != nil {
diff --git a/internal/activator/replica_instancekey_linux_test.go b/internal/activator/replica_instancekey_linux_test.go
new file mode 100644
index 000000000..34c079cc4
--- /dev/null
+++ b/internal/activator/replica_instancekey_linux_test.go
@@ -0,0 +1,106 @@
+package activator
+
+import (
+ "context"
+ "errors"
+ "net"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/instancectl"
+ "github.com/carlosframework/platform/internal/instancekey"
+ "github.com/carlosframework/platform/internal/provision"
+ "github.com/carlosframework/platform/internal/registry"
+)
+
+// socketsAllowed reports whether this process may bind a unix socket (the
+// sandbox refuses it).
+func socketsAllowed(t *testing.T) bool {
+ dir, err := os.MkdirTemp("", "ik") // short: a test's own temp dir can exceed a socket path's limit
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer os.RemoveAll(dir)
+ l, err := net.Listen("unix", filepath.Join(dir, "p.sock"))
+ if err != nil {
+ return false
+ }
+ l.Close()
+ return true
+}
+
+// Ruling 26 with a control socket: a keyless replica runs, and the app's
+// helper refuses at step 3 (ErrNoInstanceKey) instead of minting.
+func TestKeylessReplicaWithAControlSocketLeavesTheHelperToRefuse(t *testing.T) {
+ if !socketsAllowed(t) {
+ t.Skip("needs unix sockets (run with the sandbox off)")
+ }
+ b := testExecBackend(t)
+ rt := registry.Route{Host: "shop-au.example.com", Kind: "instance", Replica: true, Socket: tmpSock(t)}
+ if err := os.WriteFile(filepath.Join(b.DataDir, rt.Host+".env"), []byte("CARLOS_ADMIN_TOKEN="+strings.Repeat("c", 64)+"\n"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ out := stageHelperApp(t, b, rt.Host, "v1")
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+ defer cancel()
+ proc, _, _ := b.Start(ctx, rt)
+ if proc != nil {
+ defer proc.Stop(context.Background())
+ }
+ if got := outcomeOf(out); got != "refused" {
+ t.Fatalf("app outcome %q, want the helper's refusal", got)
+ }
+}
+
+// Ruling 26 on the nested layout: the seed role has no control socket, so
+// a keyless replica's seed is refused at prepare; a run role handed a key
+// that is not the home's is refused too; one handed the home's key is
+// prepared with it.
+func TestNestedReplicaPrepareIsGated(t *testing.T) {
+ homeKey := strings.Repeat("H", 43) + "="
+ for _, tc := range []struct {
+ name, config string
+ role instancectl.Role
+ refused bool
+ }{
+ {"a keyless seed", "", instancectl.RoleSeed, true},
+ {"a run role with another key", InstanceKeyVar + "=configured-B\n", instancectl.RoleRun, true},
+ {"a run role with the home's key", InstanceKeyVar + "=" + homeKey + "\n", instancectl.RoleRun, false},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ b, rt := nestedBackend(t, &fakeControl{inv: "inv1"}, &fakeUnit{})
+ rt.Replica = true
+ b.ReplicaHomeKeys = func(string) (string, bool) { return instancekey.Digest(homeKey), true }
+ b.ReplicaConfigKey = func(context.Context, registry.Route) (string, error) { return instancekey.Digest(homeKey), nil }
+ dir := provision.InstanceDir(b.DataDir, rt.Host)
+ if err := os.MkdirAll(dir, 0o700); err != nil {
+ t.Fatal(err)
+ }
+ if tc.config != "" {
+ if err := os.WriteFile(filepath.Join(dir, rt.Host+".config.env"), []byte(tc.config), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ }
+ _, ctl, logs, env, err := b.prepare(rt, tc.role)
+ if tc.refused {
+ if !errors.Is(err, ErrReplicaKey) {
+ t.Fatalf("prepare = %v, want ErrReplicaKey", err)
+ }
+ return
+ }
+ if err != nil {
+ t.Fatalf("prepare: %v", err)
+ }
+ logs.Close()
+ if ctl != nil {
+ ctl.Close()
+ }
+ if env[InstanceKeyVar] != homeKey {
+ t.Fatal("the prepared env does not carry the home's key")
+ }
+ })
+ }
+}
diff --git a/internal/activator/replica_instancekey_test.go b/internal/activator/replica_instancekey_test.go
new file mode 100644
index 000000000..0091b8ee7
--- /dev/null
+++ b/internal/activator/replica_instancekey_test.go
@@ -0,0 +1,268 @@
+package activator
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/instancekey"
+ "github.com/carlosframework/platform/internal/registry"
+)
+
+// The app the replica tests launch is this test binary, running
+// TestInstanceKeyHelperProcess: rastrillo e3ea08de's carlos.InstanceKey
+// and its documented caller, so a test sees what an adopting app does, not
+// only which variables it was handed.
+const helperOutEnv = "GO_TEST_INSTANCE_KEY_HELPER_OUT"
+
+var (
+ errHelperNoInstanceKey = errors.New("ErrNoInstanceKey")
+ errHelperNotOnCarlos = errors.New("ErrNotOnCarlos")
+)
+
+// helperInstanceKey is rastrillo e3ea08de's InstanceKey(own) with Running():
+// own, then $CARLOS_INSTANCE_KEY verbatim, then ErrNoInstanceKey when
+// $CARLOS_CONTROL_SOCKET is set, else ErrNotOnCarlos.
+func helperInstanceKey(own string) (string, error) {
+ if own != "" {
+ return own, nil
+ }
+ if k := os.Getenv(InstanceKeyVar); k != "" {
+ return k, nil
+ }
+ if os.Getenv("CARLOS_CONTROL_SOCKET") != "" {
+ return "", errHelperNoInstanceKey
+ }
+ return "", errHelperNotOnCarlos
+}
+
+// TestInstanceKeyHelperProcess is the app, not a test: with no output path
+// set it does nothing. It resolves its key as an adopting app with no key
+// of its own does, records the outcome, and exits:
+// - "key <sha256>": it sealed with the key it was handed;
+// - "refused": ErrNoInstanceKey, it did not start;
+// - "minted": ErrNotOnCarlos, and the documented caller minted its own.
+func TestInstanceKeyHelperProcess(t *testing.T) {
+ out := os.Getenv(helperOutEnv)
+ if out == "" {
+ return
+ }
+ var outcome string
+ switch key, err := helperInstanceKey(""); {
+ case err == nil:
+ outcome = "key " + instancekey.Digest(key)
+ case errors.Is(err, errHelperNoInstanceKey):
+ outcome = "refused"
+ default:
+ outcome = "minted"
+ }
+ _ = os.WriteFile(out+".tmp", []byte(outcome), 0o600)
+ _ = os.Rename(out+".tmp", out)
+ os.Exit(0)
+}
+
+// stageHelperApp makes this test binary host's live build, running only
+// TestInstanceKeyHelperProcess, and returns where its outcome lands.
+func stageHelperApp(t *testing.T, b *ExecBackend, host, ver string) string {
+ t.Helper()
+ self, err := os.Executable()
+ if err != nil {
+ t.Fatal(err)
+ }
+ out := filepath.Join(t.TempDir(), "outcome")
+ t.Setenv(helperOutEnv, out)
+ vdir := filepath.Join(b.OptDir, "versions", ver)
+ if err := os.MkdirAll(vdir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ bin := filepath.Join(vdir, "app")
+ script := fmt.Sprintf("#!/bin/sh\nexec '%s' -test.run='^TestInstanceKeyHelperProcess$'\n", self)
+ if err := os.WriteFile(bin, []byte(script), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.MkdirAll(filepath.Join(b.OptDir, "live"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ os.Remove(b.liveLink(host))
+ if err := os.Symlink(bin, b.liveLink(host)); err != nil {
+ t.Fatal(err)
+ }
+ return out
+}
+
+// outcomeOf waits up to two seconds for the helper's outcome; "" is no app
+// ran at all.
+func outcomeOf(path string) string {
+ deadline := time.Now().Add(2 * time.Second)
+ for time.Now().Before(deadline) {
+ if raw, err := os.ReadFile(path); err == nil {
+ return string(raw)
+ }
+ time.Sleep(10 * time.Millisecond)
+ }
+ return ""
+}
+
+// noControlSocket is a socket path too long to bind, so the launch's
+// control listener fails and CARLOS_CONTROL_SOCKET is withheld: the same
+// launch a claim box runs before O6, and a flat launch whose bind failed.
+func noControlSocket(t *testing.T) string {
+ return filepath.Join(t.TempDir(), strings.Repeat("s", 120)+".sock")
+}
+
+type replicaCase struct {
+ name string
+ replica bool
+ own, config string // env file bodies ("" = no config file)
+ homeKey string // the home's effective key the box lists ("" = none listed)
+ start string // "refused" (no app runs) or the helper outcome wanted
+ wantKeyFromOwnOK bool // a home row: the outcome is the key minted into <host>.env
+ // bucket is the key the replica's configuration in the bucket renders
+ // now (ReplicaConfigKey; "" renders none). bucketDown makes that read
+ // fail; noBucket leaves ReplicaConfigKey nil.
+ bucket string
+ bucketDown, noBucket bool
+}
+
+// freshConfig is ExecBackend.ReplicaConfigKey for one case.
+func freshConfig(tc replicaCase) func(context.Context, registry.Route) (string, error) {
+ if tc.noBucket {
+ return nil
+ }
+ return func(context.Context, registry.Route) (string, error) {
+ switch {
+ case tc.bucketDown:
+ return "", errors.New("bucket unreachable")
+ case tc.bucket == "":
+ return "", nil
+ }
+ return instancekey.Digest(tc.bucket), nil
+ }
+}
+
+// Rulings 26 to 28 through a real launch and the helper (Astra rounds 4 and
+// 5): a replica runs only with exactly its home's key, listed by the
+// pre-roll check and, when configured, still the bucket's configured key;
+// or with none and a control socket its helper refuses on. Every launch
+// here has no control socket, which is where the helper would otherwise
+// mint.
+func TestReplicaLaunchWithoutAControlSocket(t *testing.T) {
+ tok := "CARLOS_ADMIN_TOKEN=" + strings.Repeat("c", 64) + "\n"
+ homeKey := strings.Repeat("H", 43) + "="
+ for _, tc := range []replicaCase{
+ {name: "a keyless replica is refused by the agent, not left to mint", replica: true, own: tok, start: "refused"},
+ {name: "a configured key that is the home's runs and seals with it", replica: true, own: tok, config: InstanceKeyVar + "=" + homeKey + "\n", homeKey: homeKey, bucket: homeKey, start: "key " + instancekey.Digest(homeKey)},
+ {name: "a pre-existing env key that is the home's runs", replica: true, own: tok + InstanceKeyVar + "=" + homeKey + "\n", homeKey: homeKey, start: "key " + instancekey.Digest(homeKey)},
+ {name: "a configured key that differs is refused", replica: true, own: tok, config: InstanceKeyVar + "=configured-B\n", homeKey: homeKey, bucket: "configured-B", start: "refused"},
+ {name: "a configured key with no verified home key is refused", replica: true, own: tok, config: InstanceKeyVar + "=" + homeKey + "\n", bucket: homeKey, start: "refused"},
+ // Ruling 27, Astra round 5 finding 1: the key changed in the bucket
+ // to B and the home already holds it, while this box's sync is
+ // behind and the replica's configuration and list entry still say A.
+ {name: "the home updated, this replica's configuration delayed: refused", replica: true, own: tok, config: InstanceKeyVar + "=" + homeKey + "\n", homeKey: homeKey, bucket: "configured-B", start: "refused"},
+ {name: "the bucket cannot be read: refused", replica: true, own: tok, config: InstanceKeyVar + "=" + homeKey + "\n", homeKey: homeKey, bucketDown: true, start: "refused"},
+ {name: "no fresh reader: refused", replica: true, own: tok, config: InstanceKeyVar + "=" + homeKey + "\n", homeKey: homeKey, noBucket: true, start: "refused"},
+ // Ruling 27's residual, documented: a change the bucket does not
+ // hold (the home's own <host>.env edited by hand, or a per-host key
+ // configured on the home only) is invisible here. The replica's
+ // list entry and the bucket both still say A, so it runs with A.
+ // Only the procedure, or O8's fresh evidence, closes this.
+ {name: "residual: a change only on the home is not seen, and the replica runs with the old key", replica: true, own: tok, config: InstanceKeyVar + "=" + homeKey + "\n", homeKey: homeKey, bucket: homeKey, start: "key " + instancekey.Digest(homeKey)},
+ // Ruling 28: a home whose key exists only in configuration, with a
+ // replica that is handed none (its own overlay blanks it). O8 never
+ // delivers a configured key, so the replica stays keyless and is
+ // refused here, where it has no control socket.
+ {name: "a config-only home's keyless replica is refused", replica: true, own: tok, config: InstanceKeyVar + "=\n", homeKey: homeKey, bucket: "", start: "refused"},
+ {name: "a home row mints and its app seals with the minted key", own: tok, wantKeyFromOwnOK: true},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ b := testExecBackend(t)
+ rt := registry.Route{Host: "shop-au.example.com", Kind: "instance", Replica: tc.replica, Socket: noControlSocket(t)}
+ own := filepath.Join(b.DataDir, rt.Host+".env")
+ if err := os.WriteFile(own, []byte(tc.own), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if tc.config != "" {
+ if err := os.WriteFile(filepath.Join(b.DataDir, rt.Host+".config.env"), []byte(tc.config), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if tc.homeKey != "" {
+ b.ReplicaHomeKeys = func(h string) (string, bool) {
+ return instancekey.Digest(tc.homeKey), h == rt.Host
+ }
+ }
+ b.ReplicaConfigKey = freshConfig(tc)
+ out := stageHelperApp(t, b, rt.Host, "v1")
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+ defer cancel()
+ proc, _, err := b.Start(ctx, rt)
+ if proc != nil {
+ defer proc.Stop(context.Background())
+ }
+ got := outcomeOf(out)
+ if tc.wantKeyFromOwnOK {
+ env, _ := readEnvFile(own)
+ if got != "key "+instancekey.Digest(env[InstanceKeyVar]) {
+ t.Fatalf("home row: outcome %q, want the minted key", got)
+ }
+ return
+ }
+ if tc.start == "refused" {
+ if !errors.Is(err, ErrReplicaKey) || got != "" {
+ t.Fatalf("Start = %v, app outcome %q; want ErrReplicaKey and no app", err, got)
+ }
+ } else if got != tc.start {
+ t.Fatalf("app outcome %q, want %q (Start: %v)", got, tc.start, err)
+ }
+ if raw, _ := os.ReadFile(own); string(raw) != tc.own {
+ t.Fatal("a replica launch changed its own env file: a replica never mints")
+ }
+ })
+ }
+}
+
+// A replica's seed has no control socket: a keyless replica's seed is
+// refused before it runs; one handed its home's key runs.
+func TestReplicaSeedIsGated(t *testing.T) {
+ tok := "CARLOS_ADMIN_TOKEN=" + strings.Repeat("c", 64) + "\n"
+ homeKey := strings.Repeat("H", 43) + "="
+ for _, tc := range []struct {
+ name, config string
+ want string
+ }{
+ {"keyless", "", ""},
+ {"the home's key", InstanceKeyVar + "=" + homeKey + "\n", "key " + instancekey.Digest(homeKey)},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ b := testExecBackend(t)
+ rt := registry.Route{Host: "shop-au.example.com", Kind: "instance", Replica: true, Seed: `["seed"]`}
+ if err := os.WriteFile(filepath.Join(b.DataDir, rt.Host+".env"), []byte(tok), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if tc.config != "" {
+ if err := os.WriteFile(filepath.Join(b.DataDir, rt.Host+".config.env"), []byte(tc.config), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ }
+ b.ReplicaHomeKeys = func(h string) (string, bool) { return instancekey.Digest(homeKey), true }
+ b.ReplicaConfigKey = freshConfig(replicaCase{bucket: homeKey})
+ out := stageHelperApp(t, b, rt.Host, "v1")
+ err := b.Seed(context.Background(), rt)
+ got := outcomeOf(out)
+ if tc.want == "" {
+ if !errors.Is(err, ErrReplicaKey) || got != "" {
+ t.Fatalf("Seed = %v, outcome %q; want ErrReplicaKey and no seed process", err, got)
+ }
+ return
+ }
+ if err != nil || got != tc.want {
+ t.Fatalf("Seed = %v, outcome %q; want %q", err, got, tc.want)
+ }
+ })
+ }
+}
diff --git a/internal/activator/replicakey.go b/internal/activator/replicakey.go
new file mode 100644
index 000000000..7905b5d9b
--- /dev/null
+++ b/internal/activator/replicakey.go
@@ -0,0 +1,122 @@
+package activator
+
+// A replica gets its home's key (instance-key plan rulings 26 to 28). A
+// replica (#162) mirrors its home's data, sealed with the home's key, so
+// the only key it may be handed is exactly the home's effective key. The
+// platform never mints one on a replica row (ensureInstanceKeyFor), and
+// every replica launch passes replicaKeyGate before anything runs:
+//
+// - a replica whose env files deliver a key starts only when that key's
+// digest is its entry in the box's verified list (ReplicaHomeKeys, which
+// only the pre-roll check writes, read again at every launch) and, when
+// the key comes from the replica's configuration, the configuration in
+// the bucket renders that same key now (ReplicaConfigKey). Where the key
+// came from is decided by the same read that composed the env the child
+// gets (instanceEnvLayer, ruling 29), never by reading the files again.
+// A key that differs from either, or that cannot be checked, refuses
+// the launch with a log line. The fresh
+// read catches a configuration change that this box has not synced yet
+// while the home already holds the new key (ruling 27);
+// - a replica whose env files deliver no key starts only with a control
+// socket. There an app on rastrillo's InstanceKey with no key of its
+// own gets ErrNoInstanceKey and refuses (step 3), and an app that never
+// asks for a key runs as before. Without a socket (a seed, a claim
+// launch before O6, a control bind that failed) the helper would fall
+// to step 4 and mint its own key over the home's data, so the agent
+// refuses the launch itself rather than leave that to the helper.
+//
+// Delivering the home's key to a replica that has none is O8's, for a
+// platform-minted home key only (ruling 28): until then, and always for a
+// home whose key exists only in configuration, a keyless replica gets
+// nothing and is gated as above.
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "log"
+ "time"
+
+ "github.com/carlosframework/platform/internal/instancekey"
+ "github.com/carlosframework/platform/internal/registry"
+)
+
+// ErrReplicaKey is a replica launch refused by replicaKeyGate.
+var ErrReplicaKey = errors.New("activator: replica launch refused: its instance key is not its home's")
+
+// replicaConfigTimeout bounds replicaKeyGate's fresh read of the bucket.
+const replicaConfigTimeout = 10 * time.Second
+
+// instanceEnvLayer is instanceEnv returning, from the same read, the layer
+// its CARLOS_INSTANCE_KEY came from (ruling 29). mergeEnv delivers exactly
+// the key instancekey.Effective names, so env[Var] and the layer always
+// describe the same bytes, however the files change afterwards.
+func (b *ExecBackend) instanceEnvLayer(rt registry.Route) (map[string]string, instancekey.Layer, error) {
+ cfgEnv, ownEnv, err := b.instanceEnvParts(rt)
+ if err != nil {
+ return nil, instancekey.LayerNone, err
+ }
+ _, layer := instancekey.Effective(cfgEnv, ownEnv)
+ return mergeEnv(cfgEnv, ownEnv), layer, nil
+}
+
+// replicaGateHook runs at the start of every replica gate, after the env
+// was read. A test seam: it lets a test change the env files between the
+// read and the gate, as a concurrent config sync can. Nil in production.
+var replicaGateHook func()
+
+// replicaKeyGate is rulings 26, 27 and 29's check for a launch of rt with
+// env (the composed env files), layer (where env's key came from, decided
+// by the same read) and, when haveControl, a bound control socket. It reads
+// no env file. It is nil for every row that is not a replica. Its errors and
+// log lines name the host and the reason, never a key or a digest.
+func (b *ExecBackend) replicaKeyGate(rt registry.Route, env map[string]string, layer instancekey.Layer, haveControl bool) error {
+ if !rt.Replica {
+ return nil
+ }
+ if replicaGateHook != nil {
+ replicaGateHook()
+ }
+ refuse := func(why string) error {
+ log.Printf("activator: replica %s not started: %s", rt.Host, why)
+ return fmt.Errorf("%w: %s: %s", ErrReplicaKey, rt.Host, why)
+ }
+ key := env[instancekey.Var]
+ if key == "" {
+ if haveControl {
+ return nil
+ }
+ return refuse("it has no key of its home's and no control socket, so an app on the instance-key helper would mint its own")
+ }
+ var home string
+ var ok bool
+ if b.ReplicaHomeKeys != nil {
+ home, ok = b.ReplicaHomeKeys(rt.Host)
+ }
+ switch {
+ case !ok:
+ return refuse(instancekey.Var + " cannot be checked against its home's key (no entry in CARLOS_REPLICA_HOME_KEYS; O8)")
+ case instancekey.Digest(key) != home:
+ return refuse(instancekey.Var + " is not its home's key")
+ }
+ switch layer {
+ case instancekey.LayerOwn:
+ return nil // the replica's own file: it changes only by hand
+ case instancekey.LayerConfig:
+ default:
+ return refuse("where its " + instancekey.Var + " came from is unknown")
+ }
+ if b.ReplicaConfigKey == nil {
+ return refuse("its configured " + instancekey.Var + " cannot be checked against the bucket's configuration")
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), replicaConfigTimeout)
+ defer cancel()
+ fresh, err := b.ReplicaConfigKey(ctx, rt)
+ switch {
+ case err != nil:
+ return refuse("the bucket's configuration could not be read: " + err.Error())
+ case fresh != instancekey.Digest(key):
+ return refuse("its configured " + instancekey.Var + " is not the one the bucket's configuration now holds: a key change is in flight (ruling 27)")
+ }
+ return nil
+}
diff --git a/internal/activator/replicakey_race_test.go b/internal/activator/replicakey_race_test.go
new file mode 100644
index 000000000..931666206
--- /dev/null
+++ b/internal/activator/replicakey_race_test.go
@@ -0,0 +1,94 @@
+package activator
+
+import (
+ "context"
+ "errors"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/instancekey"
+ "github.com/carlosframework/platform/internal/registry"
+)
+
+// Ruling 29 (Astra round 6 finding 1), deterministic: the launch reads a
+// configured key A, and then a config sync removes the key from this box's
+// <host>.config.env (the bucket no longer holds it) before the replica
+// gate runs. The gate decides on the layer the launch's own read found, so
+// it still consults the bucket, which now renders no key, and refuses. A
+// second read of the files would have found no key, skipped the bucket
+// and admitted a child that is handed A.
+func TestReplicaGateDecidesOnTheLaunchesOwnRead(t *testing.T) {
+ tok := "CARLOS_ADMIN_TOKEN=" + strings.Repeat("c", 64) + "\n"
+ keyA := strings.Repeat("A", 43) + "="
+ b := testExecBackend(t)
+ rt := registry.Route{Host: "shop-au.example.com", Kind: "instance", Replica: true, Socket: noControlSocket(t)}
+ cfgPath := filepath.Join(b.DataDir, rt.Host+".config.env")
+ if err := os.WriteFile(filepath.Join(b.DataDir, rt.Host+".env"), []byte(tok), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(cfgPath, []byte(InstanceKeyVar+"="+keyA+"\n"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ b.ReplicaHomeKeys = func(string) (string, bool) { return instancekey.Digest(keyA), true }
+ b.ReplicaConfigKey = func(context.Context, registry.Route) (string, error) { return "", nil } // the bucket: key removed
+ syncs := 0
+ replicaGateHook = func() {
+ syncs++
+ if err := os.WriteFile(cfgPath, []byte("OTHER=1\n"), 0o600); err != nil {
+ t.Error(err)
+ }
+ }
+ t.Cleanup(func() { replicaGateHook = nil })
+ out := stageHelperApp(t, b, rt.Host, "v1")
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+ defer cancel()
+ proc, _, err := b.Start(ctx, rt)
+ if proc != nil {
+ defer proc.Stop(context.Background())
+ }
+ if syncs != 1 {
+ t.Fatalf("the sync ran %d times between the read and the gate, want once", syncs)
+ }
+ if got := outcomeOf(out); !errors.Is(err, ErrReplicaKey) || got != "" {
+ t.Fatalf("Start = %v, app outcome %q; want ErrReplicaKey and no app", err, got)
+ }
+}
+
+// The gate reads no env file: given the env and layer the launch's read
+// captured, it decides the same with the files gone.
+func TestReplicaGateReadsNoEnvFile(t *testing.T) {
+ keyA := strings.Repeat("A", 43) + "="
+ b := &ExecBackend{DataDir: t.TempDir()}
+ rt := registry.Route{Host: "shop-au.example.com", Kind: "instance", Replica: true}
+ if err := os.WriteFile(filepath.Join(b.DataDir, rt.Host+".config.env"), []byte(InstanceKeyVar+"="+keyA+"\n"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ env, layer, err := b.instanceEnvLayer(rt)
+ if err != nil || layer != instancekey.LayerConfig || env[InstanceKeyVar] != keyA {
+ t.Fatalf("instanceEnvLayer = %v, %v", layer, err)
+ }
+ if err := os.RemoveAll(b.DataDir); err != nil {
+ t.Fatal(err)
+ }
+ b.ReplicaHomeKeys = func(string) (string, bool) { return instancekey.Digest(keyA), true }
+ for _, tc := range []struct {
+ bucket string
+ ok bool
+ }{{keyA, true}, {"", false}} {
+ b.ReplicaConfigKey = func(context.Context, registry.Route) (string, error) {
+ if tc.bucket == "" {
+ return "", nil
+ }
+ return instancekey.Digest(tc.bucket), nil
+ }
+ if err := b.replicaKeyGate(rt, env, layer, false); (err == nil) != tc.ok {
+ t.Fatalf("bucket %q: gate = %v, want ok %v", tc.bucket, err, tc.ok)
+ }
+ }
+ if err := b.replicaKeyGate(rt, env, instancekey.LayerNone, false); !errors.Is(err, ErrReplicaKey) {
+ t.Fatalf("a key with no known layer = %v, want a refusal", err)
+ }
+}
diff --git a/internal/appconfig/instancekey.go b/internal/appconfig/instancekey.go
new file mode 100644
index 000000000..c60051498
--- /dev/null
+++ b/internal/appconfig/instancekey.go
@@ -0,0 +1,34 @@
+package appconfig
+
+import (
+ "context"
+ "crypto/ecdh"
+
+ "github.com/carlosframework/platform/internal/instancekey"
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// ConfiguredKeyDigest is the SHA-256 of the CARLOS_INSTANCE_KEY rt's
+// configuration in the bucket renders now, "" when it renders none: the
+// same merge and renderer SyncExec writes <host>.config.env with
+// (LoadMergedHostVersions, RenderEnv), read fresh rather than from the
+// box's last sync. A replica launch whose configured key is not this
+// digest is refused (instance-key plan ruling 27): a configuration change
+// is in flight, and the home may already hold the new key. The value
+// never leaves this function.
+func ConfiguredKeyDigest(ctx context.Context, s release.Store, priv *ecdh.PrivateKey, rt registry.Route) (string, error) {
+ m, _, err := LoadMergedHostVersions(ctx, s, release.RouteAccount(rt), rt.App, rt.Environment, rt.Host)
+ if err != nil || m == nil {
+ return "", err
+ }
+ rendered, err := RenderEnv(m, priv, routeBinding(rt))
+ if err != nil {
+ return "", err
+ }
+ key, _ := instancekey.Effective(instancekey.ParseEnv(rendered), nil)
+ if key == "" {
+ return "", nil
+ }
+ return instancekey.Digest(key), nil
+}
diff --git a/internal/appconfig/instancekey_test.go b/internal/appconfig/instancekey_test.go
new file mode 100644
index 000000000..e6d23aba8
--- /dev/null
+++ b/internal/appconfig/instancekey_test.go
@@ -0,0 +1,42 @@
+package appconfig
+
+import (
+ "testing"
+
+ "github.com/carlosframework/platform/internal/instancekey"
+ "github.com/carlosframework/platform/internal/registry"
+)
+
+// Ruling 27's fresh check: the digest of the key the bucket's
+// configuration renders now, from the app's bundle or the host's overlay,
+// plain or sealed; "" when it renders none.
+func TestConfiguredKeyDigestReadsTheBucketNow(t *testing.T) {
+ ctx, s, _, _, priv, pub := execKit(t)
+ pk, _ := ParsePrivateKey(priv, "test")
+ pubKey, _ := ParsePublicKey(pub, "test")
+ rt := registry.Route{Host: "shop-au.example.com", Kind: "instance", App: "shop", Account: "acct", Replica: true}
+ check := func(want string) {
+ t.Helper()
+ got, err := ConfiguredKeyDigest(ctx, s, pk, rt)
+ if err != nil || got != want {
+ t.Fatalf("ConfiguredKeyDigest = %q, %v; want %q", got, err, want)
+ }
+ }
+ check("")
+ if err := SetEnv(ctx, s, "acct", "shop", map[string]string{"CARLOS_INSTANCE_KEY": "key-A"}); err != nil {
+ t.Fatal(err)
+ }
+ check(instancekey.Digest("key-A"))
+ if err := SetEnv(ctx, s, "acct", "shop", map[string]string{"CARLOS_INSTANCE_KEY": "key-B"}); err != nil {
+ t.Fatal(err)
+ }
+ check(instancekey.Digest("key-B"))
+ sealed, err := Seal(pubKey, Binding{Account: "acct", App: "shop"}, []byte("key-C"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, _, err := ApplyInstance(ctx, s, "acct", "shop", rt.Host, map[string]Var{"CARLOS_INSTANCE_KEY": {Kind: "secret", Envelope: sealed}}, nil); err != nil {
+ t.Fatal(err)
+ }
+ check(instancekey.Digest("key-C"))
+}
diff --git a/internal/host/agent.go b/internal/host/agent.go
index 7457ed444..8ac99978b 100644
--- a/internal/host/agent.go
+++ b/internal/host/agent.go
@@ -360,6 +360,13 @@ func runAgent(args []string, buildClaims ClaimFactory) {
if logSink != nil { // a typed-nil in the interface would NOT be nil
eb.Logs = logSink
}
+ // The verified home keys of this box's replica rows (L21 rulings 26
+ // and 30), read again at every replica launch, until O8 reads them
+ // from each home's published env item.
+ eb.ReplicaHomeKeys = replicaHomeKeys(func() ([]byte, error) { return os.ReadFile(replicaHomeKeysPath) })
+ // The bucket's configuration, read fresh at each replica launch
+ // whose key is configured (ruling 27).
+ eb.ReplicaConfigKey = replicaConfigKey(os.Getenv, openDeployStore)
// The root instance-control channel (design §5.3) and the box facts
// a nested wake needs beyond the row (design §8.1, Task 6): the
// journal check and the peer list both read this SAME registry
diff --git a/internal/host/replicahomekeys.go b/internal/host/replicahomekeys.go
new file mode 100644
index 000000000..a522fdc57
--- /dev/null
+++ b/internal/host/replicahomekeys.go
@@ -0,0 +1,88 @@
+package host
+
+import (
+ "context"
+ "crypto/ecdh"
+ "errors"
+ "log"
+ "os"
+ "strings"
+ "sync"
+
+ "github.com/carlosframework/platform/internal/appconfig"
+ "github.com/carlosframework/platform/internal/instancekey"
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// replicaHomeKeysPath is the box's list of verified home keys for its
+// replica rows (the list the plan calls CARLOS_REPLICA_HOME_KEYS):
+// "replica-host=sha256" entries, separated by commas or newlines. Only the
+// pre-roll check writes it, as root, replacing it whole (write a temporary
+// file, then rename it into place); the agent only reads it.
+const replicaHomeKeysPath = "/etc/carlos/replica-home-keys"
+
+// replicaHomeKeys is ExecBackend.ReplicaHomeKeys (instance-key plan rulings
+// 26, 27 and 30): it reads the list again at every call, so an entry the
+// procedure removes or replaces takes effect at the next replica launch,
+// with no agent restart and no cached copy to go stale. Nothing refreshes
+// the list from a replica's own configuration. An absent list verifies
+// nothing. A list that cannot be read or parsed is logged by the error's
+// position and reason, never its text, and verifies nothing, so every
+// replica launch that would be handed a key is refused until it is fixed.
+func replicaHomeKeys(read func() ([]byte, error)) func(host string) (string, bool) {
+ return func(host string) (string, bool) {
+ raw, err := read()
+ if errors.Is(err, os.ErrNotExist) {
+ return "", false
+ }
+ if err != nil {
+ log.Printf("agent: replica home keys: the list could not be read (no replica home key verifies): %v", err)
+ return "", false
+ }
+ keys, err := instancekey.ParseHomeKeys(strings.ReplaceAll(string(raw), "\n", ","))
+ if err != nil {
+ log.Printf("agent: replica home keys: %v (no replica home key verifies until it is fixed)", err)
+ return "", false
+ }
+ sha, ok := keys[host]
+ return sha, ok
+ }
+}
+
+// replicaConfigKey is ExecBackend.ReplicaConfigKey (ruling 27): the digest
+// of the key rt's configuration in the bucket renders now, read with the
+// box's secrets key through the deploy store. The store and key are opened
+// on first use and kept once both open; a failure is returned, so the
+// replica is refused, and is tried again at its next launch.
+func replicaConfigKey(getenv func(string) string, open func(context.Context) (release.Store, error)) func(context.Context, registry.Route) (string, error) {
+ var (
+ mu sync.Mutex
+ store release.Store
+ priv *ecdh.PrivateKey
+ )
+ return func(ctx context.Context, rt registry.Route) (string, error) {
+ mu.Lock()
+ if store == nil {
+ raw := getenv("CARLOS_SECRETS_KEY")
+ if raw == "" {
+ mu.Unlock()
+ return "", errors.New("CARLOS_SECRETS_KEY is not set")
+ }
+ k, err := appconfig.ParsePrivateKey(raw, "CARLOS_SECRETS_KEY")
+ if err != nil {
+ mu.Unlock()
+ return "", err
+ }
+ st, err := open(ctx)
+ if err != nil {
+ mu.Unlock()
+ return "", err
+ }
+ store, priv = st, k
+ }
+ st, k := store, priv
+ mu.Unlock()
+ return appconfig.ConfiguredKeyDigest(ctx, st, k, rt)
+ }
+}
diff --git a/internal/host/replicahomekeys_test.go b/internal/host/replicahomekeys_test.go
new file mode 100644
index 000000000..3a321272d
--- /dev/null
+++ b/internal/host/replicahomekeys_test.go
@@ -0,0 +1,121 @@
+package host
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "log"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/appconfig"
+ "github.com/carlosframework/platform/internal/instancekey"
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// Ruling 30 (Astra round 6 finding 2): the list is read again at every
+// call, so the procedure's invalidation and its new entries take effect at
+// the next replica launch, with no restart. An absent list verifies
+// nothing.
+func TestReplicaHomeKeysReadsTheListAtEveryLaunch(t *testing.T) {
+ a, b := instancekey.Digest("home-key-a"), instancekey.Digest("home-key-b")
+ path := filepath.Join(t.TempDir(), "replica-home-keys")
+ keys := replicaHomeKeys(func() ([]byte, error) { return os.ReadFile(path) })
+ if _, ok := keys("shop-au.example.com"); ok {
+ t.Fatal("an absent list verified a key")
+ }
+ write := func(body string) {
+ t.Helper()
+ tmp := path + ".tmp"
+ if err := os.WriteFile(tmp, []byte(body), 0o640); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Rename(tmp, path); err != nil {
+ t.Fatal(err)
+ }
+ }
+ write("shop-au.example.com=" + a + "\nshop-uk.example.com=" + a + "\n")
+ if got, ok := keys("shop-au.example.com"); !ok || got != a {
+ t.Fatalf("listed replica = %q, %v", got, ok)
+ }
+ // The procedure's invalidation: the entry is gone at the next call.
+ write("shop-uk.example.com=" + a + "\n")
+ if _, ok := keys("shop-au.example.com"); ok {
+ t.Fatal("a removed entry still verified: the list was cached")
+ }
+ // And its replacement, after the key change and the re-run check.
+ write("shop-au.example.com=" + b + ",shop-uk.example.com=" + a)
+ if got, ok := keys("shop-au.example.com"); !ok || got != b {
+ t.Fatalf("the replaced entry = %q, %v; want the new digest", got, ok)
+ }
+ if _, ok := keys("shop-nz.example.com"); ok {
+ t.Fatal("an unlisted replica verified")
+ }
+}
+
+// Astra round 5 finding 3: a malformed list is logged by entry position and
+// reason, never by its text (a bare digest, with no host before it, would
+// otherwise reach the log), and verifies nothing.
+func TestReplicaHomeKeysLogsAMalformedListWithoutItsText(t *testing.T) {
+ sha := instancekey.Digest("home-key")
+ var buf bytes.Buffer
+ log.SetOutput(&buf)
+ defer log.SetOutput(os.Stderr)
+ keys := replicaHomeKeys(func() ([]byte, error) { return []byte("shop-au.example.com=" + sha + "\n" + sha + "\n"), nil })
+ if _, ok := keys("shop-au.example.com"); ok {
+ t.Fatal("a malformed list verified a key")
+ }
+ line := buf.String()
+ if !strings.Contains(line, "CARLOS_REPLICA_HOME_KEYS entry 2") || strings.Contains(line, sha[:16]) || strings.Contains(line, "shop-au") {
+ t.Fatalf("log line %q: want the entry's position and reason, and none of its text", line)
+ }
+ buf.Reset()
+ if _, ok := replicaHomeKeys(func() ([]byte, error) { return nil, errors.New("permission denied") })("shop-au.example.com"); ok {
+ t.Fatal("an unreadable list verified a key")
+ }
+ if !strings.Contains(buf.String(), "could not be read") {
+ t.Fatalf("log line %q", buf.String())
+ }
+}
+
+// Ruling 27's fresh read, as the agent wires it: the bucket's current
+// configured key, through the deploy store and the box's secrets key. With
+// no secrets key it is an error, so the replica is refused.
+func TestReplicaConfigKeyReadsTheBucketNow(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ priv, _, err := appconfig.GenerateKey()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := appconfig.SetEnv(ctx, s, "acct", "shop", map[string]string{instancekey.Var: "key-A"}); err != nil {
+ t.Fatal(err)
+ }
+ rt := registry.Route{Host: "shop-au.example.com", Kind: "instance", App: "shop", Account: "acct", Replica: true}
+ opened := 0
+ open := func(context.Context) (release.Store, error) { opened++; return s, nil }
+ read := replicaConfigKey(func(k string) string {
+ if k == "CARLOS_SECRETS_KEY" {
+ return priv
+ }
+ return ""
+ }, open)
+ for i := 0; i < 2; i++ {
+ if got, err := read(ctx, rt); err != nil || got != instancekey.Digest("key-A") {
+ t.Fatalf("read %d = %q, %v", i, got, err)
+ }
+ }
+ if opened != 1 {
+ t.Fatalf("the store was opened %d times, want once", opened)
+ }
+ failing := replicaConfigKey(func(string) string { return priv }, func(context.Context) (release.Store, error) { return nil, errors.New("bucket unreachable") })
+ if _, err := failing(ctx, rt); err == nil {
+ t.Fatal("an unreachable bucket was not an error")
+ }
+ if _, err := replicaConfigKey(func(string) string { return "" }, open)(ctx, rt); err == nil {
+ t.Fatal("no secrets key was not an error")
+ }
+}
diff --git a/internal/instancekey/instancekey.go b/internal/instancekey/instancekey.go
index f99491301..825194d95 100644
--- a/internal/instancekey/instancekey.go
+++ b/internal/instancekey/instancekey.go
@@ -200,12 +200,16 @@ func ValidDigest(s string) bool {
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
+// ParseHomeKeys parses the box's list of verified home keys for its replica
+// rows, the file /etc/carlos/replica-home-keys (the list the plan calls
+// CARLOS_REPLICA_HOME_KEYS; instance-key plan rulings 26, 27 and 30):
+// "replica-host=sha256" entries, each digest the home's effective key as
+// the pre-roll check computed it. Only the pre-roll check writes the file;
+// nothing refreshes it from a replica's own configuration. The file
+// separates entries with commas or newlines, but this splits on commas
+// only, so its reader turns newlines into commas first (the agent's
+// replicaHomeKeys). 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