edges file: publish secrets_key_fp, the fingerprint of the box's secrets key (nearest-region R9)
Same field and shape as the move-any-app spec; appconfig.KeyFingerprint is the one renderer both use.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
4 files changed,
+147
−0
internal/appconfig/fingerprint.go+28 −0internal/appconfig/fingerprint_test.go+37 −0internal/host/edgesfile.go+27 −0internal/host/edgesfile_test.go+55 −0
diff --git a/internal/appconfig/fingerprint.go b/internal/appconfig/fingerprint.go| new file mode 100644 |
| index 000000000..121c212a9 |
| --- /dev/null |
| +++ b/internal/appconfig/fingerprint.go |
| @@ -0,0 +1,28 @@ |
| +package appconfig |
| + |
| +import ( |
| + "crypto/ecdh" |
| + "crypto/sha256" |
| + "encoding/hex" |
| +) |
| + |
| +// KeyFingerprint names a secrets keypair without revealing either half: |
| +// "sha256:" and the lowercase hex SHA-256 of the public key's uncompressed |
| +// SEC1 bytes — the same bytes CARLOS_SECRETS_PUBKEY base64-decodes to. It |
| +// is the ONE renderer of the edges file's secrets_key_fp, shared by the |
| +// nearest-region spec (§6.1, R9) and the move-any-app spec (§6, T2): a box |
| +// that can open this deployment's sealed config publishes exactly the |
| +// string the console derives from its own public key. |
| +func KeyFingerprint(pub *ecdh.PublicKey) string { |
| + sum := sha256.Sum256(pub.Bytes()) |
| + return "sha256:" + hex.EncodeToString(sum[:]) |
| +} |
| + |
| +// PublicFingerprint is KeyFingerprint of a base64 CARLOS_SECRETS_PUBKEY. |
| +func PublicFingerprint(b64 string) (string, error) { |
| + pub, err := ParsePublicKey(b64, "CARLOS_SECRETS_PUBKEY") |
| + if err != nil { |
| + return "", err |
| + } |
| + return KeyFingerprint(pub), nil |
| +} |
diff --git a/internal/appconfig/fingerprint_test.go b/internal/appconfig/fingerprint_test.go| new file mode 100644 |
| index 000000000..2cdca33d8 |
| --- /dev/null |
| +++ b/internal/appconfig/fingerprint_test.go |
| @@ -0,0 +1,37 @@ |
| +package appconfig |
| + |
| +import ( |
| + "bytes" |
| + "crypto/ecdh" |
| + "encoding/base64" |
| + "regexp" |
| + "testing" |
| +) |
| + |
| +// A box computes the fingerprint from its PRIVATE key's public half; the |
| +// console computes it from CARLOS_SECRETS_PUBKEY. They must agree byte for |
| +// byte (nearest-region spec §6.1, R9). |
| +func TestKeyFingerprintAgreesAcrossHalves(t *testing.T) { |
| + priv, err := ecdh.P256().NewPrivateKey(bytes.Repeat([]byte{1}, 32)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + fromPriv := KeyFingerprint(priv.PublicKey()) |
| + fromPub, err := PublicFingerprint(base64.StdEncoding.EncodeToString(priv.PublicKey().Bytes())) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if fromPriv != fromPub { |
| + t.Fatalf("private half %q != public half %q", fromPriv, fromPub) |
| + } |
| + if !regexp.MustCompile(`^sha256:[0-9a-f]{64}$`).MatchString(fromPriv) { |
| + t.Fatalf("format = %q", fromPriv) |
| + } |
| + other, _ := ecdh.P256().NewPrivateKey(bytes.Repeat([]byte{2}, 32)) |
| + if KeyFingerprint(other.PublicKey()) == fromPriv { |
| + t.Fatal("two keys share a fingerprint") |
| + } |
| + if _, err := PublicFingerprint("not base64!"); err == nil { |
| + t.Fatal("garbage parsed") |
| + } |
| +} |
diff --git a/internal/host/edgesfile.go b/internal/host/edgesfile.go| index c969126ac..28fd105f1 100644 |
| --- a/internal/host/edgesfile.go |
| +++ b/internal/host/edgesfile.go |
| @@ -12,6 +12,7 @@ import ( |
| "sync" |
| "time" |
| + "github.com/carlosframework/platform/internal/appconfig" |
| "github.com/carlosframework/platform/internal/registry" |
| "github.com/carlosframework/platform/internal/release" |
| @@ -217,6 +218,15 @@ type edgesFile struct { |
| ServesSharedPool bool `json:"serves_shared_pool"` |
| AcceptsInbound bool `json:"accepts_inbound"` |
| + // SecretsKeyFP is the fingerprint of this box's CARLOS_SECRETS_KEY's |
| + // public half (appconfig.KeyFingerprint), NEVER the key. Additive and |
| + // omitempty, the move-any-app spec's shape: absent means this box holds |
| + // no key, or one that does not parse — so it renders no exec config, |
| + // and an instance placed here would run with none (nearest-region spec |
| + // R9). The console requires it to equal the fingerprint of its own |
| + // CARLOS_SECRETS_PUBKEY before it places anything here automatically. |
| + SecretsKeyFP string `json:"secrets_key_fp,omitempty"` |
| + |
| Generation string `json:"generation"` |
| Hosts map[string]edgeHost `json:"hosts"` |
| } |
| @@ -562,6 +572,22 @@ func edgesFileEligibility(getenv func(string) string) (sharedPool, inbound bool) |
| return provisionTickConfig(getenv).SharedInstanceEligibility() |
| } |
| +// edgesFileSecretsFP fingerprints the box's secrets key, read the same way |
| +// the config loops read it (agent.go configSyncLoop). The key itself never |
| +// leaves this function. |
| +func edgesFileSecretsFP(getenv func(string) string) string { |
| + raw := strings.TrimSpace(getenv("CARLOS_SECRETS_KEY")) |
| + if raw == "" { |
| + return "" |
| + } |
| + priv, err := appconfig.ParsePrivateKey(raw, "CARLOS_SECRETS_KEY") |
| + if err != nil { |
| + log.Printf("agent: CARLOS_SECRETS_KEY does not parse, so no secrets_key_fp is published: %v", err) |
| + return "" |
| + } |
| + return appconfig.KeyFingerprint(priv.PublicKey()) |
| +} |
| + |
| // edgesFileLoop publishes this box's edges file every 5 minutes + jitter — |
| // the same cadence and arming site as registrySnapshotLoop — AND within one |
| // watch interval of any change to this box's registry (#174). |
| @@ -634,6 +660,7 @@ func edgesFileLoop(reg *registry.Registry) { |
| HibernationConfigured: strings.TrimSpace(os.Getenv("CARLOS_HIBERNATE_BUCKET")) != "", |
| ServesSharedPool: sharedPool, |
| AcceptsInbound: inbound, |
| + SecretsKeyFP: edgesFileSecretsFP(os.Getenv), |
| } |
| log.Printf("agent: edges file loop armed (5min + jitter, and %s after a registry change, %s)", edgesFileWatchInterval, edgesFileKey(box)) |
| last := seedEdgesFileGate(ctx, store, box) |
diff --git a/internal/host/edgesfile_test.go b/internal/host/edgesfile_test.go| index 7b1d516fc..7e8e03267 100644 |
| --- a/internal/host/edgesfile_test.go |
| +++ b/internal/host/edgesfile_test.go |
| @@ -1,7 +1,10 @@ |
| package host |
| import ( |
| + "bytes" |
| "context" |
| + "crypto/ecdh" |
| + "encoding/base64" |
| "encoding/json" |
| "log" |
| "os" |
| @@ -11,6 +14,7 @@ import ( |
| "testing" |
| "time" |
| + "github.com/carlosframework/platform/internal/appconfig" |
| "github.com/carlosframework/platform/internal/edgesfile" |
| "github.com/carlosframework/platform/internal/registry" |
| "github.com/carlosframework/platform/internal/release" |
| @@ -1271,3 +1275,54 @@ func TestEdgesFileEligibilityFollowsTheReconcileConfig(t *testing.T) { |
| } |
| } |
| } |
| + |
| +// R9: the box publishes the fingerprint of its secrets key's public half, |
| +// never the key; no key, or a key that does not parse, publishes nothing. |
| +func TestEdgesFileSecretsKeyFingerprint(t *testing.T) { |
| + priv, err := ecdh.P256().NewPrivateKey(bytes.Repeat([]byte{1}, 32)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + privB64 := base64.StdEncoding.EncodeToString(priv.Bytes()) |
| + pubB64 := base64.StdEncoding.EncodeToString(priv.PublicKey().Bytes()) |
| + env := func(v string) func(string) string { |
| + return func(k string) string { |
| + if k == "CARLOS_SECRETS_KEY" { |
| + return v |
| + } |
| + return "" |
| + } |
| + } |
| + want := appconfig.KeyFingerprint(priv.PublicKey()) |
| + if got := edgesFileSecretsFP(env(privB64)); got != want { |
| + t.Fatalf("with a key = %q, want %q", got, want) |
| + } |
| + for _, v := range []string{"", "not base64!", base64.StdEncoding.EncodeToString([]byte("short"))} { |
| + if got := edgesFileSecretsFP(env(v)); got != "" { |
| + t.Errorf("CARLOS_SECRETS_KEY=%q published %q, want nothing", v, got) |
| + } |
| + } |
| + |
| + for _, fp := range []string{want, ""} { |
| + s, reg := release.NewDirStore(t.TempDir()), edgesTestRegistry(t) |
| + seedRoute(t, reg, "app.example.com") |
| + var last []byte |
| + ident := edgesFile{Box: "quiet-harbour", Region: "eu-west-2", Placement: "uk", SecretsKeyFP: fp} |
| + if err := edgesFilePass(context.Background(), s, reg, ident, "203.0.113.10", &last); err != nil { |
| + t.Fatal(err) |
| + } |
| + body, _, err := s.Get(context.Background(), "control/edges/quiet-harbour.json") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + has := strings.Contains(string(body), `"secrets_key_fp"`) |
| + if has != (fp != "") { |
| + t.Fatalf("fp %q: key present = %v in %s", fp, has, body) |
| + } |
| + for _, secret := range []string{privB64, pubB64} { |
| + if strings.Contains(string(body), secret) { |
| + t.Fatal("the edges file carries key material") |
| + } |
| + } |
| + } |
| +} |