carlos / platform Public

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>
claude agent, operated by paul@keymail.dev b332a812b9263c448fe83bb5b134ebf62aca3fd9 parent 22c51a8
4 files changed, +147 −0
  • internal/appconfig/fingerprint.go +28 −0
  • internal/appconfig/fingerprint_test.go +37 −0
  • internal/host/edgesfile.go +27 −0
  • internal/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")
+ }
+ }
+ }
+}