carlos / platform Public

Merge branch 'tenant-iso-binding' into main

Bind release pointers and secret envelopes to their account; close relay deny-list gaps (#220)
claude agent, operated by paul@keymail.dev b94ad230f43e00bd57355f86e6da37d4a4a2a512 parent 2129e26parent d2d074a
38 files changed, +1042 −147
  • accounts.go +46 −2
  • apimode.go +18 −12
  • apimode_test.go +46 −5
  • appconfig_cli.go +47 −11
  • cmd/console/main.go +21 −0
  • config_e2e_test.go +1 −1
  • e2e_test.go +13 −2
  • internal/appconfig/configack.go +2 −2
  • internal/appconfig/configack_test.go +1 −1
  • internal/appconfig/crypto.go +161 −20
  • internal/appconfig/crypto_bind_test.go +202 −0
  • internal/appconfig/crypto_test.go +27 −9
  • internal/appconfig/generation_test.go +1 −1
  • internal/appconfig/instances_test.go +1 −1
  • internal/appconfig/materialize.go +4 −3
  • internal/appconfig/materialize_test.go +4 −4
  • internal/appconfig/sync.go +8 −2
  • internal/appconfig/sync_test.go +1 −1
  • internal/appconfig/syncexec.go +2 −2
  • internal/console/apiconfig.go +10 −0
  • internal/console/apiconfig_test.go +31 −3
  • internal/console/apistore_test.go +1 −1
  • internal/console/relayops.go +1 −1
  • internal/mail/walk.go +1 −1
  • internal/mail/walk_enable_test.go +1 −1
  • internal/maintenance/envelope_test.go +12 −5
  • internal/objstore/walk.go +1 −1
  • internal/objstore/walk_env_test.go +1 −1
  • internal/objstore/walk_test.go +8 −8
  • internal/relay/server.go +61 −9
  • internal/relay/server_test.go +15 −1
  • internal/release/accountbind_test.go +150 −0
  • internal/release/golden_test.go +1 −1
  • internal/release/qualified_test.go +2 −2
  • internal/release/rebind.go +70 −0
  • internal/release/release.go +52 −15
  • internal/release/release_test.go +4 −4
  • internal/release/scopedverify_test.go +14 −14

CI cancelled — superseded by a newer push — run details

diff --git a/accounts.go b/accounts.go
index 2da563e7c..7ecadfc1a 100644
--- a/accounts.go
+++ b/accounts.go
@@ -352,6 +352,7 @@ func runAccountsMigrate(args []string) {
if len(plan) == 0 {
mutedf(os.Stdout, "nothing under %s — nothing to copy\n", release.AppPrefix(srcAcct, *app))
}
+ rebind := pointerRebinder(*app, srcAcct, *to)
// Pointers the legacy-source path refused to overwrite because a newer
// qualified pointer already exists; verification must not compare these
// against the legacy copy they deliberately did not take.
@@ -365,6 +366,9 @@ func runAccountsMigrate(args []string) {
if err != nil {
log.Fatalf("accounts migrate: read %s: %v", c.from, err)
}
+ if c.pointer {
+ body = rebind(c.to, body)
+ }
// Channel pointers CONVERGE; everything else is immutable and is
// claimed create-only. The split matters: a release manifest or
// artifact at an existing path must never change meaning, so
@@ -433,7 +437,7 @@ func runAccountsMigrate(args []string) {
"(the flag is signed per-account and cannot be copied)\n", *app)
}
if !*dryRun {
- verifyMigratedPointers(ctx, store, *app, srcAcct, *to, keptPointers)
+ verifyMigratedPointers(ctx, store, *app, srcAcct, *to, keptPointers, rebind)
}
// The routes' account column: what adopt and the edge build keys from.
@@ -648,6 +652,41 @@ func migrationPlan(ctx context.Context, store release.Store, app, from, to, dst
return out, release.OpsAccount, hadProduction
}
+// pointerRebinder returns how a channel pointer crosses from srcAcct to
+// dstAcct. A pointer signed before the account binding names no account and
+// travels as bytes, exactly as before. A pointer that names its account no
+// longer verifies anywhere else (discussion #220), so it is re-signed for the
+// destination with the root key — CARLOS_RELEASE_KEY, as for any promote.
+// The key is read only if such a pointer turns up, and a scoped key is
+// refused: moving a pointer between accounts is a root decision.
+func pointerRebinder(app, srcAcct, dstAcct string) func(dstKey string, body []byte) []byte {
+ var root ed25519.PrivateKey
+ prefix := release.ChannelsPrefix(dstAcct, app)
+ return func(dstKey string, body []byte) []byte {
+ acct, err := release.PointerAccount(body)
+ if err != nil || acct == "" || acct == dstAcct {
+ // Unparseable bytes are copied as they always were; the
+ // verification pass is what judges them.
+ return body
+ }
+ channel := strings.TrimSuffix(strings.TrimPrefix(dstKey, prefix), ".json")
+ if root == nil {
+ raw := os.Getenv("CARLOS_RELEASE_KEY")
+ if strings.TrimSpace(raw) == "" || release.IsScopedSeed(raw) {
+ log.Fatalf("accounts migrate: channel %s of %s is signed for account %s, so it has to be\n"+
+ "re-signed for %s, which needs the root release key in CARLOS_RELEASE_KEY.\n"+
+ "Nothing has been deleted; set it and re-run.", channel, app, acct, dstAcct)
+ }
+ root = signingKey()
+ }
+ out, err := release.RebindPointer(root, body, srcAcct, dstAcct, app, channel)
+ if err != nil {
+ log.Fatalf("accounts migrate: %v", err)
+ }
+ return out
+ }
+}
+
// verifyMigratedPointers re-reads every channel pointer after the copy and
// refuses if the destination does not now serve exactly what the source
// serves. Belt and braces over migrationPlan's precedence — and the one check
@@ -657,7 +696,11 @@ func migrationPlan(ctx context.Context, store release.Store, app, from, to, dst
// Byte comparison is the gate, so no signing key is needed. When
// CARLOS_RELEASE_PUBKEY is set the versions are resolved and printed too, so
// the operator sees what they just moved rather than a bare "ok".
-func verifyMigratedPointers(ctx context.Context, store release.Store, app, srcAcct, dstAcct string, kept []string) {
+//
+// "What the source serves" is the source pointer as rebind carries it: a
+// pointer bound to the source account is re-signed for the destination
+// (pointerRebinder), so the bytes compared are the rebound ones.
+func verifyMigratedPointers(ctx context.Context, store release.Store, app, srcAcct, dstAcct string, kept []string, rebind func(dstKey string, body []byte) []byte) {
prefix := release.ChannelsPrefix(dstAcct, app)
keys, err := store.List(ctx, prefix)
if err != nil {
@@ -700,6 +743,7 @@ func verifyMigratedPointers(ctx context.Context, store release.Store, app, srcAc
if err != nil {
log.Fatalf("accounts migrate: verify: re-read %s/%s at the source: %v", app, channel, err)
}
+ want = rebind(key, want)
if string(got) != string(want) {
// A write landed between the copy and this read — a promote
// racing the migration. CONVERGE rather than refuse: the pointer
diff --git a/apimode.go b/apimode.go
index 57c7d7208..be71d6130 100644
--- a/apimode.go
+++ b/apimode.go
@@ -2490,8 +2490,12 @@ type apiConfig struct {
Bundle string
ExecDelivery string
SecretsPubkey string
- Environments []string
- Vars []apiConfigVar
+ // SecretsAccount is the account sqid secrets for this app are sealed
+ // for; "" from a console that predates bound envelopes (see
+ // handleAPIConfigGet).
+ SecretsAccount string
+ Environments []string
+ Vars []apiConfigVar
}
// configGet reads one app's config bundle through the console — the default
@@ -2522,11 +2526,12 @@ func (s *apiSession) configGet(ctx context.Context, a apiAcct, app, environment
return apiConfig{}, apiErrorFromBody(status, body)
}
var res struct {
- Bundle string `json:"bundle"`
- ExecDelivery string `json:"exec_delivery"`
- SecretsPubkey string `json:"secrets_pubkey"`
- Environments []string `json:"environments"`
- Vars []struct {
+ Bundle string `json:"bundle"`
+ ExecDelivery string `json:"exec_delivery"`
+ SecretsPubkey string `json:"secrets_pubkey"`
+ SecretsAccount string `json:"secrets_account"`
+ Environments []string `json:"environments"`
+ Vars []struct {
Key string `json:"key"`
Kind string `json:"kind"`
Value string `json:"value"`
@@ -2536,11 +2541,12 @@ func (s *apiSession) configGet(ctx context.Context, a apiAcct, app, environment
return apiConfig{}, fmt.Errorf("config: unreadable response: %w", err)
}
out := apiConfig{
- Bundle: res.Bundle,
- ExecDelivery: res.ExecDelivery,
- SecretsPubkey: res.SecretsPubkey,
- Environments: res.Environments,
- Vars: make([]apiConfigVar, len(res.Vars)),
+ Bundle: res.Bundle,
+ ExecDelivery: res.ExecDelivery,
+ SecretsPubkey: res.SecretsPubkey,
+ SecretsAccount: res.SecretsAccount,
+ Environments: res.Environments,
+ Vars: make([]apiConfigVar, len(res.Vars)),
}
for i, v := range res.Vars {
out.Vars[i] = apiConfigVar{Key: v.Key, Kind: v.Kind, Value: v.Value}
diff --git a/apimode_test.go b/apimode_test.go
index a5176d5e3..fa92eb506 100644
--- a/apimode_test.go
+++ b/apimode_test.go
@@ -7,6 +7,7 @@ package main
import (
"bytes"
"context"
+ "encoding/base64"
"encoding/json"
"errors"
"flag"
@@ -2610,10 +2611,11 @@ func TestAPISessionConfigGetFullShape(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
json.NewEncoder(w).Encode(map[string]any{
- "bundle": "default",
- "exec_delivery": "file",
- "secrets_pubkey": "abcdef==",
- "environments": []string{"staging"},
+ "bundle": "default",
+ "exec_delivery": "file",
+ "secrets_pubkey": "abcdef==",
+ "secrets_account": "bab",
+ "environments": []string{"staging"},
"vars": []map[string]any{
{"key": "PORT", "kind": "env", "value": "8080"},
{"key": "BANK_KEY", "kind": "secret"},
@@ -2630,7 +2632,7 @@ func TestAPISessionConfigGetFullShape(t *testing.T) {
if gotPath != "/api/cli/apps/acct/app/config" {
t.Fatalf("path = %q", gotPath)
}
- if cfg.Bundle != "default" || cfg.ExecDelivery != "file" || cfg.SecretsPubkey != "abcdef==" {
+ if cfg.Bundle != "default" || cfg.ExecDelivery != "file" || cfg.SecretsPubkey != "abcdef==" || cfg.SecretsAccount != "bab" {
t.Fatalf("config = %+v", cfg)
}
if len(cfg.Environments) != 1 || cfg.Environments[0] != "staging" {
@@ -2672,6 +2674,10 @@ func TestAPISessionConfigGetAbsentFieldsDecodeEmpty(t *testing.T) {
if cfg.SecretsPubkey != "" {
t.Fatalf("SecretsPubkey = %q, want \"\" for an omitted field", cfg.SecretsPubkey)
}
+ // An older console names no account; the CLI then seals unbound.
+ if cfg.SecretsAccount != "" {
+ t.Fatalf("SecretsAccount = %q, want \"\" for an omitted field", cfg.SecretsAccount)
+ }
if len(cfg.Vars) != 0 {
t.Fatalf("Vars = %+v, want none", cfg.Vars)
}
@@ -3836,3 +3842,38 @@ func TestAPISessionInstanceSetChannel(t *testing.T) {
}
})
}
+
+// sealSecretAPI binds to the account the console named, and seals the
+// unbound shape only for a console that named none — an older door, which
+// would refuse a bound envelope (discussion #220).
+func TestSealSecretAPIBindsWhenTheConsoleNamesAnAccount(t *testing.T) {
+ privB64, pubB64, err := appconfig.GenerateKey()
+ if err != nil {
+ t.Fatal(err)
+ }
+ priv, _ := appconfig.ParsePrivateKey(privB64, "test")
+ pub, _ := appconfig.ParsePublicKey(pubB64, "test")
+
+ bound, err := sealSecretAPI(pub, "bab", "keymail", []byte("shh"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := appconfig.Open(priv, appconfig.Binding{Account: "bab", App: "keymail"}, bound); err != nil {
+ t.Fatalf("bound envelope does not open for its own app: %v", err)
+ }
+ if _, err := appconfig.Open(priv, appconfig.Binding{Account: "zzz", App: "keymail"}, bound); err == nil {
+ t.Fatal("envelope sealed with a named account opened under another account")
+ }
+
+ unbound, err := sealSecretAPI(pub, "", "keymail", []byte("shh"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := appconfig.CheckEnvelope(unbound); err != nil {
+ t.Fatal(err)
+ }
+ raw, _ := base64.StdEncoding.DecodeString(unbound)
+ if raw[0] != 0x04 {
+ t.Fatalf("fallback envelope is not the v1 shape an older door accepts (first byte %#x)", raw[0])
+ }
+}
diff --git a/appconfig_cli.go b/appconfig_cli.go
index 07e722bef..555419de9 100644
--- a/appconfig_cli.go
+++ b/appconfig_cli.go
@@ -12,6 +12,7 @@ import (
"log"
"os"
"sort"
+ "strconv"
"strings"
"github.com/carlosframework/platform/internal/appconfig"
@@ -825,17 +826,22 @@ func secretsPubKey() *ecdh.PublicKey {
// encrypts. Neither present: a fatal naming both fixes — CARLOS_SECRETS_KEY
// (the PRIVATE half) never leaves the box holding it, so there is no third
// way to get a key here.
-func secretsPubKeyAPI(sess *apiSession, account apiAcct, app string) *ecdh.PublicKey {
+//
+// It also returns the account sqid to bind the envelopes to, which only the
+// console can supply (the flag may be a name), so the GET is made even when
+// the key comes from the environment. "" means the console does not write
+// bound envelopes (switched off, or too old); the caller seals v1 then.
+func secretsPubKeyAPI(sess *apiSession, account apiAcct, app string) (*ecdh.PublicKey, string) {
+ cfg, err := sess.configGet(context.Background(), account, app, "")
+ if err != nil {
+ log.Fatal(err)
+ }
if raw := os.Getenv("CARLOS_SECRETS_PUBKEY"); raw != "" {
k, err := appconfig.ParsePublicKey(raw, "CARLOS_SECRETS_PUBKEY")
if err != nil {
log.Fatal(err)
}
- return k
- }
- cfg, err := sess.configGet(context.Background(), account, app, "")
- if err != nil {
- log.Fatal(err)
+ return k, cfg.SecretsAccount
}
if cfg.SecretsPubkey == "" {
log.Fatal("no CARLOS_SECRETS_PUBKEY in env and this console does not serve one — " +
@@ -845,7 +851,33 @@ func secretsPubKeyAPI(sess *apiSession, account apiAcct, app string) *ecdh.Publi
if err != nil {
log.Fatal(err)
}
- return k
+ return k, cfg.SecretsAccount
+}
+
+// secretsEnvelopeV2Env reads CONSOLE_SECRETS_ENVELOPE_V2 for direct-bucket
+// `secrets set`, with the console's own rule: unset is false, and a value
+// that is not a boolean stops rather than guessing.
+func secretsEnvelopeV2Env() bool {
+ v := strings.TrimSpace(os.Getenv("CONSOLE_SECRETS_ENVELOPE_V2"))
+ if v == "" {
+ return false
+ }
+ b, err := strconv.ParseBool(v)
+ if err != nil {
+ log.Fatalf("CONSOLE_SECRETS_ENVELOPE_V2 %q is not a boolean (use true or false)", v)
+ }
+ return b
+}
+
+// sealSecretAPI seals one value for API-mode `secrets set`: bound to
+// (account, app) when the console named the account — which it does only
+// while its deployment writes v2 (CONSOLE_SECRETS_ENVELOPE_V2) — and v1
+// otherwise, which every box and every console door accepts.
+func sealSecretAPI(pub *ecdh.PublicKey, account, app string, v []byte) (string, error) {
+ if account == "" {
+ return appconfig.SealUnbound(pub, v)
+ }
+ return appconfig.SealBound(pub, appconfig.Binding{Account: account, App: app}, v)
}
func runSecretsSet(args []string) {
@@ -910,10 +942,10 @@ func runSecretsSet(args []string) {
// only symptom is a render failure in the hourly adopt log.
acct := resolvedAccount(sess, acctRaw, acctIDRaw)
app2 := resolvedApp(sess, acct, *app, usage)
- pub := secretsPubKeyAPI(sess, acct, app2)
+ pub, sealAcct := secretsPubKeyAPI(sess, acct, app2)
set := make(map[string]appconfig.Var, len(kv))
for k, v := range kv {
- env, err := appconfig.Seal(pub, []byte(v))
+ env, err := sealSecretAPI(pub, sealAcct, app2, []byte(v))
if err != nil {
log.Fatalf("seal %s: %v", k, err)
}
@@ -933,16 +965,20 @@ func runSecretsSet(args []string) {
log.Fatal(usage)
}
pub := secretsPubKey()
+ acct := acctOf()
+ // Direct-bucket mode has no console to ask, so the deployment's switch
+ // comes from the operator's environment under the console's own name.
+ appconfig.SetBoundSealing(secretsEnvelopeV2Env())
sealed := make(map[string]string, len(kv))
for k, v := range kv {
- env, err := appconfig.Seal(pub, []byte(v))
+ env, err := appconfig.Seal(pub, appconfig.Binding{Account: acct, App: *app}, []byte(v))
if err != nil {
log.Fatalf("seal %s: %v", k, err)
}
sealed[k] = env
}
store := openStore()
- if err := saveSecrets(context.Background(), store, acctOf(), *app, *environment, sealed); err != nil {
+ if err := saveSecrets(context.Background(), store, acct, *app, *environment, sealed); err != nil {
log.Fatal(err)
}
fmt.Printf("%s %d secret(s) for %s (%s)\n", colorFor(os.Stdout).ok("set"), len(kv), *app, bundleLabel(*environment))
diff --git a/cmd/console/main.go b/cmd/console/main.go
index f39e65dee..b4fe8ff01 100644
--- a/cmd/console/main.go
+++ b/cmd/console/main.go
@@ -269,6 +269,8 @@ func serve() {
// same one the server got.
rel := openStore()
dbBucket, dbRegion, dbPrefix, placement := dbDoorWiring()
+ // Before the server exists: every door that seals reads this switch.
+ appconfig.SetBoundSealing(secretsEnvelopeV2())
srv, err := console.NewServer(console.Config{
Origin: origin,
Keymail: envOr("CONSOLE_KEYMAIL", "https://keymail.dev"),
@@ -873,6 +875,25 @@ func redirectHosts() []string {
return out
}
+// secretsEnvelopeV2 parses CONSOLE_SECRETS_ENVELOPE_V2: whether secrets this
+// console seals (relay, mail, object storage) and the CLI seals through it
+// are bound v2 envelopes (appconfig.Binding, discussion #220). Off by
+// default, because a box on a binary that predates v2 cannot open one and
+// its whole config render for that app stalls. Turn it on once every box
+// runs a build that reads v2. A value strconv.ParseBool cannot read stops
+// the boot, as for the other boolean settings.
+func secretsEnvelopeV2() bool {
+ v := strings.TrimSpace(os.Getenv("CONSOLE_SECRETS_ENVELOPE_V2"))
+ if v == "" {
+ return false
+ }
+ b, err := strconv.ParseBool(v)
+ if err != nil {
+ log.Fatalf("console: CONSOLE_SECRETS_ENVELOPE_V2 %q is not a boolean (use true or false)", v)
+ }
+ return b
+}
+
// reservePlatformDomains parses CONSOLE_RESERVE_PLATFORM_DOMAINS, the opt-in
// for a deployment that hosts apps for other people (the flagship, carloku).
// When it is set, `carlos domains attach` also refuses everything under
diff --git a/config_e2e_test.go b/config_e2e_test.go
index 6f5bba9eb..1be20e71c 100644
--- a/config_e2e_test.go
+++ b/config_e2e_test.go
@@ -249,7 +249,7 @@ func TestConfigE2EThroughConsole(t *testing.T) {
if !ok || bankVar.Kind != "secret" || bankVar.Envelope == "" {
t.Fatalf("stored manifest BANK_KEY = %+v, want a sealed secret envelope", bankVar)
}
- plaintext, err := appconfig.Open(secPriv, bankVar.Envelope)
+ plaintext, err := appconfig.Open(secPriv, appconfig.Binding{Account: release.OpsAccount, App: app}, bankVar.Envelope)
if err != nil {
t.Fatalf("Open the stored envelope with the test's private key: %v", err)
}
diff --git a/e2e_test.go b/e2e_test.go
index abe518b90..268f42903 100644
--- a/e2e_test.go
+++ b/e2e_test.go
@@ -1036,6 +1036,15 @@ func TestAccountsMigrateTakesTheLiveObjects(t *testing.T) {
t.Errorf("dry run does not mention both source prefixes:\n%s", out)
}
+ // The live pointer was signed for ops, so it has to be re-signed for the
+ // destination (discussion #220): without the root key the verb refuses
+ // and says what it needs, rather than copying a pointer that would not
+ // verify where it lands.
+ out, err = run("accounts", "migrate", "-registry", regPath, "-app", "demo", "-to", sqid)
+ if err == nil || !strings.Contains(out, "CARLOS_RELEASE_KEY") {
+ t.Fatalf("migrate of an account-bound pointer without the root key: err=%v\n%s", err, out)
+ }
+ env = append(env, "CARLOS_RELEASE_KEY="+seed)
out, err = run("accounts", "migrate", "-registry", regPath, "-app", "demo", "-to", sqid)
if err != nil {
t.Fatalf("migrate: %v\n%s", err, out)
@@ -1101,6 +1110,7 @@ func TestAccountsMigrateDoesNotCopyTheProductionFlag(t *testing.T) {
env := append(os.Environ(),
"CARLOS_DEPLOYMENT_DIR="+storeDir,
"CARLOS_RELEASE_PUBKEY="+pubB64,
+ "CARLOS_RELEASE_KEY="+seed, // ops-bound pointers are re-signed for the destination
"HOME="+tmp,
"CARLOS_CONSOLE=",
"CARLOS_DESTINATION=",
@@ -1203,7 +1213,8 @@ func TestAccountsMigrateConvergesADivergedPointer(t *testing.T) {
}
env := carlosTestEnv(t,
"CARLOS_DEPLOYMENT_DIR="+storeDir,
- "CARLOS_RELEASE_PUBKEY="+pubB64)
+ "CARLOS_RELEASE_PUBKEY="+pubB64,
+ "CARLOS_RELEASE_KEY="+seed) // ops-bound pointers are re-signed for the destination
run := func(args ...string) (string, error) {
t.Helper()
cmd := exec.Command(carlosBin, args...)
@@ -1838,7 +1849,7 @@ func TestSecretsCLI(t *testing.T) {
if err != nil {
t.Fatalf("ParsePrivateKey: %v", err)
}
- plain, err := appconfig.Open(privKey, manifest.Vars["SES_SECRET"].Envelope)
+ plain, err := appconfig.Open(privKey, appconfig.Binding{Account: "bab", App: "keymail"}, manifest.Vars["SES_SECRET"].Envelope)
if err != nil {
t.Fatalf("Open: %v", err)
}
diff --git a/internal/appconfig/configack.go b/internal/appconfig/configack.go
index 36ccdc14d..44c5d3ead 100644
--- a/internal/appconfig/configack.go
+++ b/internal/appconfig/configack.go
@@ -258,7 +258,7 @@ const carlosRelayVar = "CARLOS_RELAY"
//
// Returns nil, nil when the manifest carries no CARLOS_RELAY entry at all
// — an ordinary route the relay was never enabled for, not an error.
-func relaySuffixesFromRelayVar(m *Manifest, priv *ecdh.PrivateKey) (map[string]string, error) {
+func relaySuffixesFromRelayVar(m *Manifest, priv *ecdh.PrivateKey, b Binding) (map[string]string, error) {
v, ok := m.Vars[carlosRelayVar]
if !ok {
return nil, nil
@@ -271,7 +271,7 @@ func relaySuffixesFromRelayVar(m *Manifest, priv *ecdh.PrivateKey) (map[string]s
if priv == nil {
return nil, fmt.Errorf("var %s: secret entry but no decryption key was supplied", carlosRelayVar)
}
- plain, err := Open(priv, v.Envelope)
+ plain, err := Open(priv, b, v.Envelope)
if err != nil {
return nil, fmt.Errorf("decrypt %s: %w", carlosRelayVar, err)
}
diff --git a/internal/appconfig/configack_test.go b/internal/appconfig/configack_test.go
index 6725777ec..f354f695c 100644
--- a/internal/appconfig/configack_test.go
+++ b/internal/appconfig/configack_test.go
@@ -45,7 +45,7 @@ func sealRelayConfig(t *testing.T, ctx context.Context, s release.Store, pub *ec
if err != nil {
t.Fatalf("relay.BuildConfig: %v", err)
}
- envelope, err := Seal(pub, []byte(value))
+ envelope, err := Seal(pub, Binding{Account: acct, App: app}, []byte(value))
if err != nil {
t.Fatalf("Seal: %v", err)
}
diff --git a/internal/appconfig/crypto.go b/internal/appconfig/crypto.go
index 922ec2c3d..8628b5ff0 100644
--- a/internal/appconfig/crypto.go
+++ b/internal/appconfig/crypto.go
@@ -1,7 +1,15 @@
// Envelope format and key handling for per-app secrets: ECDH-P256 ephemeral
-// -> HKDF-SHA256 -> AES-256-GCM, wire shape ephPub(65) || nonce(12) ||
-// ciphertext, base64-encoded for storage in a Manifest Var's Envelope
-// field. Mirrors the shape the framework's crypto core plans for its own
+// -> HKDF-SHA256 -> AES-256-GCM, base64-encoded for storage in a Manifest
+// Var's Envelope field. Two wire shapes, told apart by their first bytes:
+//
+// v1 (unbound): ephPub(65) || nonce(12) || ciphertext
+// v2 (bound): "CSB2" || ephPub(65) || nonce(12) || ciphertext
+//
+// v2 authenticates the (account, app) it was sealed for as GCM associated
+// data, so a box opens it only while rendering that app's config — see
+// Binding. v1 is what every envelope written before v2 existed looks like,
+// and still opens anywhere; an uncompressed point always starts 0x04, so the
+// two can never be mistaken for each other. Mirrors the shape the framework's crypto core plans for its own
// Seal/Open (docs/superpowers/specs/2026-08-01-carlos-framework-design.md
// §6), reimplemented natively here since platform cannot import the
// framework repo (platform is the substrate the framework builds on).
@@ -20,12 +28,16 @@ import (
"crypto/rand"
"crypto/sha256"
"encoding/base64"
+ "encoding/binary"
"errors"
"fmt"
"io"
"strings"
+ "sync/atomic"
"golang.org/x/crypto/hkdf"
+
+ "github.com/carlosframework/platform/internal/release"
)
// randReader is a var so tests can inject determinism for golden vectors.
@@ -36,11 +48,47 @@ var randReader io.Reader = rand.Reader
// context strings use for its own purposes.
const hkdfInfo = "carlos-secrets-v1"
+// boundPrefix marks a v2 (bound) envelope and boundInfo is its HKDF info.
+// The info string differs from v1's on purpose: a v2 envelope with its
+// prefix cut off would otherwise be a well-formed v1 envelope, and opening
+// it as one would skip the binding. With a different key derivation (and
+// the associated data missing) that downgrade fails the GCM tag instead.
+const (
+ boundPrefix = "CSB2"
+ boundInfo = "carlos-secrets-v2"
+)
+
const (
ephLen = 65 // uncompressed P-256 point
nonceLen = 12
)
+// Binding names the config a secret belongs to: the account sqid and the app
+// whose bundle it is sealed into (tenant-isolation audit, discussion #220).
+// One CARLOS_SECRETS_KEY opens every envelope in a deployment, so without
+// this a v1 envelope copied from one tenant's config into another's would be
+// decrypted into the second tenant's process environment. The environment
+// bundle is not part of it: moving a secret between one app's own bundles
+// crosses no tenant boundary.
+type Binding struct {
+ Account string
+ App string
+}
+
+// aad is the associated data a v2 envelope authenticates: length-prefixed
+// fields, so no choice of names can make two bindings encode the same.
+func (b Binding) aad() ([]byte, error) {
+ if !release.ValidAccount(b.Account) || !release.ValidApp(b.App) {
+ return nil, fmt.Errorf("envelope binding: invalid account/app %q/%q", b.Account, b.App)
+ }
+ var out []byte
+ for _, f := range []string{boundPrefix, b.Account, b.App} {
+ out = binary.BigEndian.AppendUint32(out, uint32(len(f)))
+ out = append(out, f...)
+ }
+ return out, nil
+}
+
// GenerateKey mints a fresh P-256 ECDH pair: the base64 private key (the
// operator's secret — must reach every host's host.env as
// CARLOS_SECRETS_KEY) and the base64 public key (CARLOS_SECRETS_PUBKEY,
@@ -82,44 +130,131 @@ func ParsePublicKey(b64, src string) (*ecdh.PublicKey, error) {
return k, nil
}
-// Seal encrypts plaintext for pub with a fresh ephemeral key and nonce.
-func Seal(pub *ecdh.PublicKey, plaintext []byte) (string, error) {
- eph, err := ecdh.P256().GenerateKey(randReader)
+// boundSealing is whether Seal writes v2. Off by default, and that default is
+// the rollout guard: a box on a binary from before v2 cannot open one, and a
+// single unopenable secret stalls the app's whole config render on that box.
+// So reading v2 ships first and writing it is a separate, deliberate step —
+// the console turns it on from CONSOLE_SECRETS_ENVELOPE_V2 once every box
+// runs a build that reads v2 (cmd/console). Deployment-wide by nature, which
+// is why it is process state rather than a parameter threaded through every
+// door that seals.
+var boundSealing atomic.Bool
+
+// SetBoundSealing switches Seal between v1 (false, the default) and v2.
+func SetBoundSealing(on bool) { boundSealing.Store(on) }
+
+// BoundSealing reports whether Seal writes v2 in this process. The console
+// advertises it to the CLI (handleAPIConfigGet's secrets_account), so a
+// client-side seal follows the same switch.
+func BoundSealing() bool { return boundSealing.Load() }
+
+// Seal encrypts plaintext for pub as the config of b: a v2 envelope bound to
+// b when bound sealing is on (SetBoundSealing), else a v1 envelope. b is
+// validated either way, so a caller passing the wrong binding fails now and
+// not on the day the switch flips.
+func Seal(pub *ecdh.PublicKey, b Binding, plaintext []byte) (string, error) {
+ if _, err := b.aad(); err != nil {
+ return "", err
+ }
+ if !BoundSealing() {
+ return SealUnbound(pub, plaintext)
+ }
+ return SealBound(pub, b, plaintext)
+}
+
+// SealBound writes a v2 envelope bound to b regardless of the switch: only a
+// render of b's own config opens it. For callers that already know every
+// reader takes v2 — the CLI once the console has said so.
+func SealBound(pub *ecdh.PublicKey, b Binding, plaintext []byte) (string, error) {
+ aad, err := b.aad()
+ if err != nil {
+ return "", err
+ }
+ eph, nonce, err := freshEphemeral()
+ if err != nil {
+ return "", err
+ }
+ return sealWith(eph, nonce, pub, boundPrefix, boundInfo, aad, plaintext)
+}
+
+// SealUnbound writes the v1 envelope: no binding, opens under any account's
+// config. What Seal writes until bound sealing is on, and what the CLI writes
+// for a console that has not said it takes v2 (an older console's
+// CheckEnvelope reads the v2 prefix as a bad point and refuses it).
+func SealUnbound(pub *ecdh.PublicKey, plaintext []byte) (string, error) {
+ eph, nonce, err := freshEphemeral()
if err != nil {
return "", err
}
+ return sealWith(eph, nonce, pub, "", hkdfInfo, nil, plaintext)
+}
+
+func freshEphemeral() (*ecdh.PrivateKey, []byte, error) {
+ eph, err := ecdh.P256().GenerateKey(randReader)
+ if err != nil {
+ return nil, nil, err
+ }
nonce := make([]byte, nonceLen)
if _, err := io.ReadFull(randReader, nonce); err != nil {
- return "", err
+ return nil, nil, err
}
- return sealWith(eph, nonce, pub, plaintext)
+ return eph, nonce, nil
}
-// sealWith is Seal with the ephemeral key and nonce supplied — the seam the
-// golden-vector test uses for a deterministic envelope.
-func sealWith(eph *ecdh.PrivateKey, nonce []byte, pub *ecdh.PublicKey, plaintext []byte) (string, error) {
+// sealWith is the sealing core with the ephemeral key and nonce supplied —
+// the seam the golden-vector tests use for a deterministic envelope.
+func sealWith(eph *ecdh.PrivateKey, nonce []byte, pub *ecdh.PublicKey, prefix, info string, aad, plaintext []byte) (string, error) {
shared, err := eph.ECDH(pub)
if err != nil {
return "", err
}
- gcm, err := gcmFor(shared)
+ gcm, err := gcmFor(shared, info)
if err != nil {
return "", err
}
- ciphertext := gcm.Seal(nil, nonce, plaintext, nil)
- out := make([]byte, 0, ephLen+nonceLen+len(ciphertext))
+ ciphertext := gcm.Seal(nil, nonce, plaintext, aad)
+ out := make([]byte, 0, len(prefix)+ephLen+nonceLen+len(ciphertext))
+ out = append(out, prefix...)
out = append(out, eph.PublicKey().Bytes()...)
out = append(out, nonce...)
out = append(out, ciphertext...)
return base64.StdEncoding.EncodeToString(out), nil
}
-// Open decrypts an envelope Seal produced, using priv.
-func Open(priv *ecdh.PrivateKey, envelopeB64 string) ([]byte, error) {
+// splitVersion reports whether raw is a v2 envelope and returns it with the
+// prefix removed.
+func splitVersion(raw []byte) (bool, []byte) {
+ if len(raw) >= len(boundPrefix) && string(raw[:len(boundPrefix)]) == boundPrefix {
+ return true, raw[len(boundPrefix):]
+ }
+ return false, raw
+}
+
+// Open decrypts an envelope with priv, for the config named by b. A v2
+// envelope opens only if it was sealed for b. A v1 envelope opens regardless
+// of b: those were written before the binding existed and are most of every
+// production bucket today.
+//
+// That v1 path is the residual the binding cannot close by itself. Anyone
+// who can write one tenant's config can copy another tenant's v1 envelope
+// into it and have a box decrypt it there. Stripping a v2 envelope down to
+// v1 does not get there (see boundInfo), so the exposure is the fixed set of
+// v1 envelopes already stored plus every one written while bound sealing is
+// off. Once it is on, each write re-seals as v2. Sunset: once no stored
+// envelope is v1, refuse v1 here.
+func Open(priv *ecdh.PrivateKey, b Binding, envelopeB64 string) ([]byte, error) {
raw, err := base64.StdEncoding.DecodeString(envelopeB64)
if err != nil {
return nil, fmt.Errorf("envelope: invalid base64: %w", err)
}
+ bound, raw := splitVersion(raw)
+ info, aad := hkdfInfo, []byte(nil)
+ if bound {
+ info = boundInfo
+ if aad, err = b.aad(); err != nil {
+ return nil, err
+ }
+ }
if len(raw) < ephLen+nonceLen {
return nil, errors.New("envelope: too short")
}
@@ -133,12 +268,15 @@ func Open(priv *ecdh.PrivateKey, envelopeB64 string) ([]byte, error) {
if err != nil {
return nil, fmt.Errorf("envelope: %w", err)
}
- gcm, err := gcmFor(shared)
+ gcm, err := gcmFor(shared, info)
if err != nil {
return nil, err
}
- plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
+ plaintext, err := gcm.Open(nil, nonce, ciphertext, aad)
if err != nil {
+ if bound {
+ return nil, fmt.Errorf("envelope: decryption failed (sealed for another app or account, or corrupt): %w", err)
+ }
return nil, fmt.Errorf("envelope: decryption failed: %w", err)
}
return plaintext, nil
@@ -162,6 +300,9 @@ func CheckEnvelope(envelopeB64 string) error {
if err != nil {
return errors.New("envelope is not valid base64")
}
+ // Both versions carry the same body after v2's prefix, so the checks
+ // below read the body; the binding itself only a box can verify.
+ _, raw = splitVersion(raw)
// 16: the GCM tag, which is present even for empty plaintext — so this
// is the length of the shortest envelope Seal can produce, not an
// arbitrary floor.
@@ -207,8 +348,8 @@ func CheckEnvelope(envelopeB64 string) error {
}
// gcmFor derives the AES-256-GCM cipher an ECDH shared secret drives.
-func gcmFor(shared []byte) (cipher.AEAD, error) {
- r := hkdf.New(sha256.New, shared, nil, []byte(hkdfInfo))
+func gcmFor(shared []byte, info string) (cipher.AEAD, error) {
+ r := hkdf.New(sha256.New, shared, nil, []byte(info))
key := make([]byte, 32)
if _, err := io.ReadFull(r, key); err != nil {
return nil, err
diff --git a/internal/appconfig/crypto_bind_test.go b/internal/appconfig/crypto_bind_test.go
new file mode 100644
index 000000000..be490cbe3
--- /dev/null
+++ b/internal/appconfig/crypto_bind_test.go
@@ -0,0 +1,202 @@
+package appconfig
+
+import (
+ "bytes"
+ "context"
+ "crypto/ecdh"
+ "encoding/base64"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+func bindKeys(t *testing.T) (*ecdh.PrivateKey, *ecdh.PublicKey) {
+ t.Helper()
+ priv, err := ecdh.P256().NewPrivateKey(fixedScalar(0x11))
+ if err != nil {
+ t.Fatal(err)
+ }
+ return priv, priv.PublicKey()
+}
+
+// One CARLOS_SECRETS_KEY opens every envelope in a deployment, so before
+// the binding a secret sealed into one tenant's config, copied into
+// another's, was decrypted into the second tenant's environment
+// (tenant-isolation audit, discussion #220). A bound envelope opens only for
+// the (account, app) it was sealed for.
+//
+// Mutation-mindedness: seal or open with nil associated data and the
+// cross-account and cross-app opens below succeed.
+func TestBoundEnvelopeOpensOnlyForItsBinding(t *testing.T) {
+ priv, pub := bindKeys(t)
+ env, err := SealBound(pub, Binding{Account: "aaa", App: "blog"}, []byte("s3kr1t"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ got, err := Open(priv, Binding{Account: "aaa", App: "blog"}, env)
+ if err != nil || string(got) != "s3kr1t" {
+ t.Fatalf("own binding: %q, %v", got, err)
+ }
+ for _, b := range []Binding{
+ {Account: "bbb", App: "blog"}, // same app name, another tenant
+ {Account: "aaa", App: "shop"}, // same tenant, another app
+ } {
+ if got, err := Open(priv, b, env); err == nil || got != nil {
+ t.Fatalf("envelope for aaa/blog opened as %s/%s", b.Account, b.App)
+ }
+ }
+}
+
+// Downgrade: cutting the v2 prefix off leaves bytes shaped exactly like a
+// v1 envelope, which opens without a binding. It must not open — the v2 key
+// derivation differs (boundInfo), so the GCM tag fails.
+//
+// Mutation-mindedness: set boundInfo to hkdfInfo and this opens.
+func TestBoundEnvelopeCannotBeDowngradedToUnbound(t *testing.T) {
+ priv, pub := bindKeys(t)
+ env, err := SealBound(pub, Binding{Account: "aaa", App: "blog"}, []byte("s3kr1t"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ raw, _ := base64.StdEncoding.DecodeString(env)
+ if !bytes.HasPrefix(raw, []byte(boundPrefix)) {
+ t.Fatalf("Seal wrote no %s prefix", boundPrefix)
+ }
+ stripped := base64.StdEncoding.EncodeToString(raw[len(boundPrefix):])
+ if err := CheckEnvelope(stripped); err != nil {
+ t.Fatalf("precondition: stripped envelope should look like v1: %v", err)
+ }
+ if got, err := Open(priv, Binding{Account: "bbb", App: "blog"}, stripped); err == nil || got != nil {
+ t.Fatal("a v2 envelope stripped to v1 opened without its binding")
+ }
+}
+
+// The v2 wire shape, pinned like v1's golden vector: a drift here means
+// envelopes already sealed stop opening.
+func TestBoundEnvelopeGoldenVector(t *testing.T) {
+ priv, pub := bindKeys(t)
+ eph, err := ecdh.P256().NewPrivateKey(fixedScalar(0x22))
+ if err != nil {
+ t.Fatal(err)
+ }
+ nonce := make([]byte, 12)
+ for i := range nonce {
+ nonce[i] = byte(i + 1)
+ }
+ b := Binding{Account: "bab", App: "keymail"}
+ aad, err := b.aad()
+ if err != nil {
+ t.Fatal(err)
+ }
+ got, err := sealWith(eph, nonce, pub, boundPrefix, boundInfo, aad, []byte("s3kr1t-value"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ const want = "Q1NCMgSrTT1cMAUIiVUDWxuplu2yTDxWX6TUcFNW0TXikHFdvT876aH9TjYb0calxCZ5P+PqnTz0YNalrQpdLIBLE2lIAQIDBAUGBwgJCgsMxRFVmrtd/T04JgIGwUoHD+0wmlJIwGOB+IO4KQ=="
+ if got != want {
+ t.Fatalf("bound envelope drifted from the pinned wire format:\n got: %s\n want: %s", got, want)
+ }
+ if plain, err := Open(priv, b, got); err != nil || string(plain) != "s3kr1t-value" {
+ t.Fatalf("Open: %q, %v", plain, err)
+ }
+}
+
+func TestSealRefusesAnInvalidBinding(t *testing.T) {
+ _, pub := bindKeys(t)
+ for _, b := range []Binding{{}, {Account: "bab"}, {App: "keymail"}} {
+ if _, err := SealBound(pub, b, []byte("x")); err == nil || !strings.Contains(err.Error(), "binding") {
+ t.Fatalf("Seal with binding %+v: %v", b, err)
+ }
+ }
+}
+
+// The binding as the box applies it: a sync pass renders a route's config
+// as (route account, route app), so another tenant's envelope copied into
+// this route's bundle fails the render and the env file is never written.
+func TestSyncRefusesAnEnvelopeSealedForAnotherAccount(t *testing.T) {
+ ctx := context.Background()
+ priv, pub := bindKeys(t)
+ for _, tc := range []struct {
+ name string
+ sealed Binding
+ render bool
+ }{
+ {"own account", Binding{Account: "bbb", App: "blog"}, true},
+ {"another account's blog", Binding{Account: "aaa", App: "blog"}, false},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ s := release.NewDirStore(t.TempDir())
+ reg := testRegistry(t)
+ envDir := t.TempDir()
+ env, err := SealBound(pub, tc.sealed, []byte("shh"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := SetSecret(ctx, s, "bbb", "blog", map[string]string{"TOKEN": env}); err != nil {
+ t.Fatal(err)
+ }
+ if err := reg.Upsert(registry.Route{
+ Host: "blog.example.com", Kind: "instance", App: "blog", Account: "bbb", Status: "ready",
+ Backing: registry.BackingUnit,
+ }); err != nil {
+ t.Fatal(err)
+ }
+ if err := Sync(ctx, reg, s, priv, envDir, func(string) error { return nil }, nil); err != nil {
+ t.Fatalf("Sync: %v", err)
+ }
+ got, err := os.ReadFile(filepath.Join(envDir, "blog.example.com.env"))
+ if tc.render {
+ if err != nil || !strings.Contains(string(got), "TOKEN=shh\n") {
+ t.Fatalf("own secret not rendered: %q, %v", got, err)
+ }
+ return
+ }
+ if err == nil {
+ t.Fatalf("another account's secret was rendered:\n%s", got)
+ }
+ })
+ }
+}
+
+// The rollout guard: until a deployment turns bound sealing on, Seal writes
+// v1, which every box can open — a box on a binary from before v2 would
+// stall the app's whole config render on a v2 envelope. It still validates
+// the binding, so a wrong caller fails before the switch ever flips.
+//
+// Mutation-mindedness: default boundSealing to true, or have Seal ignore it,
+// and the first assertion fails.
+func TestSealWritesV1UntilBoundSealingIsOn(t *testing.T) {
+ priv, pub := bindKeys(t)
+ t.Cleanup(func() { SetBoundSealing(false) })
+ b := Binding{Account: "aaa", App: "blog"}
+
+ SetBoundSealing(false)
+ env, err := Seal(pub, b, []byte("shh"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ raw, _ := base64.StdEncoding.DecodeString(env)
+ if raw[0] != 0x04 {
+ t.Fatalf("with bound sealing off Seal wrote a non-v1 envelope (first byte %#x)", raw[0])
+ }
+ if _, err := Seal(pub, Binding{}, []byte("shh")); err == nil {
+ t.Fatal("Seal with bound sealing off accepted an empty binding")
+ }
+
+ SetBoundSealing(true)
+ env, err = Seal(pub, b, []byte("shh"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ raw, _ = base64.StdEncoding.DecodeString(env)
+ if !bytes.HasPrefix(raw, []byte(boundPrefix)) {
+ t.Fatal("with bound sealing on Seal did not write v2")
+ }
+ if _, err := Open(priv, Binding{Account: "bbb", App: "blog"}, env); err == nil {
+ t.Fatal("v2 envelope from Seal opened under another account")
+ }
+}
diff --git a/internal/appconfig/crypto_test.go b/internal/appconfig/crypto_test.go
index c18237c79..f18335ea5 100644
--- a/internal/appconfig/crypto_test.go
+++ b/internal/appconfig/crypto_test.go
@@ -21,6 +21,8 @@ func fixedScalar(b byte) []byte {
return s
}
+var testBinding = Binding{Account: "bab", App: "keymail"}
+
// TestSealOpenGoldenVector pins the wire format (ephPub(65) || nonce(12) ||
// ciphertext, base64) against a fixed ephemeral key and nonce so a future
// change to field order, sizes, or the HKDF info string breaks loudly
@@ -44,14 +46,16 @@ func TestSealOpenGoldenVector(t *testing.T) {
const wantEnvelope = "BKtNPVwwBQiJVQNbG6mW7bJMPFZfpNRwU1bRNeKQcV29Pzvpof1ONhvRxqXEJnk/4+qdPPRg1qWtCl0sgEsTaUgBAgMEBQYHCAkKCwwPFyVKJUipEHkmgC9Wpeogyu3CLH/32CzrKb0y"
- got, err := sealWith(ephPriv, nonce, recipientPriv.PublicKey(), plaintext)
+ got, err := sealWith(ephPriv, nonce, recipientPriv.PublicKey(), "", hkdfInfo, nil, plaintext)
if err != nil {
t.Fatalf("sealWith: %v", err)
}
if got != wantEnvelope {
t.Fatalf("envelope drifted from the pinned wire format:\n got: %s\n want: %s", got, wantEnvelope)
}
- opened, err := Open(recipientPriv, got)
+ // SealUnbound is the same v1 shape; and a v1 envelope — every one in a
+ // production bucket today — opens under ANY binding (see Open).
+ opened, err := Open(recipientPriv, Binding{Account: "zzz", App: "other"}, got)
if err != nil {
t.Fatalf("Open: %v", err)
}
@@ -73,11 +77,11 @@ func TestSealOpenRoundTripRandomKeys(t *testing.T) {
if err != nil {
t.Fatalf("ParsePublicKey: %v", err)
}
- envelope, err := Seal(pubKey, []byte("hello"))
+ envelope, err := SealBound(pubKey, testBinding, []byte("hello"))
if err != nil {
t.Fatalf("Seal: %v", err)
}
- got, err := Open(privKey, envelope)
+ got, err := Open(privKey, testBinding, envelope)
if err != nil {
t.Fatalf("Open: %v", err)
}
@@ -99,7 +103,7 @@ func TestOpenRejectsTamperedCiphertext(t *testing.T) {
if err != nil {
t.Fatalf("ParsePublicKey: %v", err)
}
- envelope, err := Seal(pubKey, []byte("hello"))
+ envelope, err := SealBound(pubKey, testBinding, []byte("hello"))
if err != nil {
t.Fatalf("Seal: %v", err)
}
@@ -109,7 +113,7 @@ func TestOpenRejectsTamperedCiphertext(t *testing.T) {
}
raw[len(raw)-1] ^= 0xFF // flip a ciphertext byte
tampered := base64.StdEncoding.EncodeToString(raw)
- if _, err := Open(privKey, tampered); err == nil {
+ if _, err := Open(privKey, testBinding, tampered); err == nil {
t.Fatal("Open accepted tampered ciphertext, want an error")
}
}
@@ -136,13 +140,20 @@ func TestCheckEnvelopeAcceptsSealed(t *testing.T) {
t.Fatalf("ParsePublicKey: %v", err)
}
for _, plaintext := range [][]byte{[]byte("s3kr1t"), []byte("x")} {
- envelope, err := Seal(pubKey, plaintext)
+ envelope, err := SealBound(pubKey, testBinding, plaintext)
if err != nil {
t.Fatalf("Seal(%q): %v", plaintext, err)
}
if err := CheckEnvelope(envelope); err != nil {
t.Fatalf("CheckEnvelope rejected a Seal envelope for plaintext %q: %v", plaintext, err)
}
+ unbound, err := SealUnbound(pubKey, plaintext)
+ if err != nil {
+ t.Fatalf("SealUnbound(%q): %v", plaintext, err)
+ }
+ if err := CheckEnvelope(unbound); err != nil {
+ t.Fatalf("CheckEnvelope rejected a SealUnbound envelope for plaintext %q: %v", plaintext, err)
+ }
}
}
@@ -163,10 +174,17 @@ func TestCheckEnvelopeRefusesEmptyValue(t *testing.T) {
if err != nil {
t.Fatalf("ParsePublicKey: %v", err)
}
- empty, err := Seal(pubKey, []byte{})
+ empty, err := SealBound(pubKey, testBinding, []byte{})
if err != nil {
t.Fatalf("Seal(empty): %v", err)
}
+ unboundEmpty, err := SealUnbound(pubKey, []byte{})
+ if err != nil {
+ t.Fatalf("SealUnbound(empty): %v", err)
+ }
+ if err := CheckEnvelope(unboundEmpty); err == nil || !strings.Contains(err.Error(), "empty value") {
+ t.Fatalf("CheckEnvelope on an unbound empty value: %v", err)
+ }
err = CheckEnvelope(empty)
if err == nil {
t.Fatal("CheckEnvelope accepted an envelope sealing an empty value")
@@ -183,7 +201,7 @@ func TestCheckEnvelopeRefusesEmptyValue(t *testing.T) {
}
// One byte over the floor is a real secret and must survive — the
// shortest value this check must never eat.
- oneByte, err := Seal(pubKey, []byte("x"))
+ oneByte, err := SealBound(pubKey, testBinding, []byte("x"))
if err != nil {
t.Fatalf("Seal(x): %v", err)
}
diff --git a/internal/appconfig/generation_test.go b/internal/appconfig/generation_test.go
index 5b1c6ed05..c5f51aa4c 100644
--- a/internal/appconfig/generation_test.go
+++ b/internal/appconfig/generation_test.go
@@ -204,7 +204,7 @@ func testEnvelope(t *testing.T) string {
if err != nil {
t.Fatalf("ParsePublicKey: %v", err)
}
- envelope, err := Seal(pubKey, []byte("shh"))
+ envelope, err := Seal(pubKey, Binding{Account: "bab", App: "keymail"}, []byte("shh"))
if err != nil {
t.Fatalf("Seal: %v", err)
}
diff --git a/internal/appconfig/instances_test.go b/internal/appconfig/instances_test.go
index beb862c4d..f54f7b45f 100644
--- a/internal/appconfig/instances_test.go
+++ b/internal/appconfig/instances_test.go
@@ -506,7 +506,7 @@ func TestSyncRendersTheInstanceOverlayForItsOwnRouteOnly(t *testing.T) {
if err != nil {
t.Fatalf("LoadMergedHost: %v", err)
}
- rendered, err := RenderEnv(want, priv)
+ rendered, err := RenderEnv(want, priv, Binding{Account: "bab", App: "titogo"})
if err != nil {
t.Fatalf("RenderEnv: %v", err)
}
diff --git a/internal/appconfig/materialize.go b/internal/appconfig/materialize.go
index bafbb70d3..21b557832 100644
--- a/internal/appconfig/materialize.go
+++ b/internal/appconfig/materialize.go
@@ -27,13 +27,14 @@ func ValidVarValue(val string) error {
return nil
}
-// RenderEnv decrypts m's secret entries with priv and renders the whole
+// RenderEnv decrypts m's secret entries with priv, as the config of bind (the
+// route's account and app — see Binding), and renders the whole
// manifest as EnvironmentFile-shaped `KEY=value\n` lines, keys sorted so
// the output is deterministic and diffable against what's already on
// disk. priv may be nil iff m has no "secret" entries (callers that know
// they're only rendering plain env vars, e.g. a future env-only path,
// don't need a key in hand).
-func RenderEnv(m *Manifest, priv *ecdh.PrivateKey) ([]byte, error) {
+func RenderEnv(m *Manifest, priv *ecdh.PrivateKey, bind Binding) ([]byte, error) {
keys := make([]string, 0, len(m.Vars))
for k := range m.Vars {
keys = append(keys, k)
@@ -50,7 +51,7 @@ func RenderEnv(m *Manifest, priv *ecdh.PrivateKey) ([]byte, error) {
if priv == nil {
return nil, fmt.Errorf("var %s: secret entry but no decryption key was supplied", k)
}
- plain, err := Open(priv, v.Envelope)
+ plain, err := Open(priv, bind, v.Envelope)
if err != nil {
return nil, fmt.Errorf("decrypt %s: %w", k, err)
}
diff --git a/internal/appconfig/materialize_test.go b/internal/appconfig/materialize_test.go
index fcc5a5e86..0f5020362 100644
--- a/internal/appconfig/materialize_test.go
+++ b/internal/appconfig/materialize_test.go
@@ -19,7 +19,7 @@ func TestRenderEnvMergesPlainAndDecryptedSecrets(t *testing.T) {
if err != nil {
t.Fatalf("ParsePublicKey: %v", err)
}
- envelope, err := Seal(pubKey, []byte("s3cr3t"))
+ envelope, err := Seal(pubKey, Binding{Account: "bab", App: "keymail"}, []byte("s3cr3t"))
if err != nil {
t.Fatalf("Seal: %v", err)
}
@@ -27,7 +27,7 @@ func TestRenderEnvMergesPlainAndDecryptedSecrets(t *testing.T) {
"LOG_LEVEL": {Kind: "env", Value: "debug"},
"SES_SECRET": {Kind: "secret", Envelope: envelope},
}}
- got, err := RenderEnv(m, privKey)
+ got, err := RenderEnv(m, privKey, Binding{Account: "bab", App: "keymail"})
if err != nil {
t.Fatalf("RenderEnv: %v", err)
}
@@ -57,7 +57,7 @@ func TestRenderEnvRejectsValuesSystemdWouldMangle(t *testing.T) {
m := &Manifest{Schema: 1, App: "keymail", Vars: map[string]Var{
"BAD": {Kind: "env", Value: val},
}}
- _, err := RenderEnv(m, nil)
+ _, err := RenderEnv(m, nil, Binding{Account: "bab", App: "keymail"})
if err == nil {
t.Errorf("%s: RenderEnv accepted %q, want an error", name, val)
continue
@@ -76,7 +76,7 @@ func TestRenderEnvAcceptsOrdinaryPunctuation(t *testing.T) {
m := &Manifest{Schema: 1, App: "keymail", Vars: map[string]Var{
"DATABASE_URL": {Kind: "env", Value: val},
}}
- got, err := RenderEnv(m, nil)
+ got, err := RenderEnv(m, nil, Binding{Account: "bab", App: "keymail"})
if err != nil {
t.Fatalf("RenderEnv: %v", err)
}
diff --git a/internal/appconfig/sync.go b/internal/appconfig/sync.go
index 5e7135fdc..93ed75684 100644
--- a/internal/appconfig/sync.go
+++ b/internal/appconfig/sync.go
@@ -414,6 +414,12 @@ func UnitConfigApps(routes []registry.Route) [][2]string {
return out
}
+// routeBinding is the Binding a route's config renders as: the same
+// (account, app) the sync passes resolved its manifest under.
+func routeBinding(rt registry.Route) Binding {
+ return Binding{Account: release.RouteAccount(rt), App: rt.App}
+}
+
// syncHost converges one route's env file. It is called only with a manifest
// that really resolved — the object (default bundle, named environment, or
// both) exists in the bucket — so an empty manifest here means "every var
@@ -505,7 +511,7 @@ func syncHost(rt registry.Route, m *Manifest, defaultMissing bool, priv *ecdh.Pr
return
}
}
- rendered, err := RenderEnv(m, priv)
+ rendered, err := RenderEnv(m, priv, routeBinding(rt))
if err != nil {
fmt.Printf(" %s: render config: %v\n", rt.Host, err)
return
@@ -549,7 +555,7 @@ func syncHost(rt registry.Route, m *Manifest, defaultMissing bool, priv *ecdh.Pr
// whether the restart below woke a real process or found a hibernating
// one with nothing to wake.
a := ConfigAck{Schema: ConfigAckSchema, Host: rt.Host, Shape: shapeOf(rt), At: time.Now(), InstanceConfigETag: instETag}
- if suffixes, err := relaySuffixesFromRelayVar(m, priv); err != nil {
+ if suffixes, err := relaySuffixesFromRelayVar(m, priv, routeBinding(rt)); err != nil {
// A CARLOS_RELAY entry that fails to decode is loud on its own —
// RenderEnv already refused this render if the failure were a
// decrypt error shared by every var, so this is specific to the
diff --git a/internal/appconfig/sync_test.go b/internal/appconfig/sync_test.go
index 5a0d4263e..c91af7b74 100644
--- a/internal/appconfig/sync_test.go
+++ b/internal/appconfig/sync_test.go
@@ -37,7 +37,7 @@ func TestSyncWritesFileAndRestartsOnChange(t *testing.T) {
if err != nil {
t.Fatalf("ParsePublicKey: %v", err)
}
- envelope, err := Seal(pubKey, []byte("shh"))
+ envelope, err := Seal(pubKey, Binding{Account: "bab", App: "keymail"}, []byte("shh"))
if err != nil {
t.Fatalf("Seal: %v", err)
}
diff --git a/internal/appconfig/syncexec.go b/internal/appconfig/syncexec.go
index 834a10f46..694f88863 100644
--- a/internal/appconfig/syncexec.go
+++ b/internal/appconfig/syncexec.go
@@ -1148,7 +1148,7 @@ func syncExecHost(rt registry.Route, m *Manifest, key string, priv *ecdh.Private
// agent-ownership exists to prevent. The asymmetry with sync.go's
// unit path (which does apply it, unconditionally, because cache
// construction is unconditional there) is correct; do not "fix" it.
- rendered, err := RenderEnv(m, priv)
+ rendered, err := RenderEnv(m, priv, routeBinding(rt))
if err != nil {
fmt.Printf(" %s: render config: %v\n", rt.Host, err)
return
@@ -1187,7 +1187,7 @@ func syncExecHost(rt registry.Route, m *Manifest, key string, priv *ecdh.Private
bounceErr := bounceLogged(rt.Host, bounce)
if ack != nil {
a := ConfigAck{Schema: ConfigAckSchema, Host: rt.Host, Shape: shapeOf(rt), At: time.Now(), InstanceConfigETag: instETag}
- if suffixes, err := relaySuffixesFromRelayVar(m, priv); err != nil {
+ if suffixes, err := relaySuffixesFromRelayVar(m, priv, routeBinding(rt)); err != nil {
fmt.Printf(" %s: config updated, but CARLOS_RELAY could not be read for the config-ack: %v\n", rt.Host, err)
} else {
a.RelaySuffixes = suffixes
diff --git a/internal/console/apiconfig.go b/internal/console/apiconfig.go
index 2de0abb18..208afe5f4 100644
--- a/internal/console/apiconfig.go
+++ b/internal/console/apiconfig.go
@@ -251,6 +251,16 @@ func (s *Server) handleAPIConfigGet(w http.ResponseWriter, r *http.Request) {
if s.cfg.SecretsPub != "" {
out["secrets_pubkey"] = s.cfg.SecretsPub
}
+ // The account a secret for this app is bound to (appconfig.Binding), as
+ // the sqid this console resolved the request to — the CLI may only know
+ // the account by name. Present ONLY while this deployment writes bound
+ // (v2) envelopes (CONSOLE_SECRETS_ENVELOPE_V2): its presence is how the
+ // CLI learns to seal v2, and absent — this console with the switch off,
+ // or an older console — it seals v1, which every box can open
+ // (discussion #220).
+ if appconfig.BoundSealing() {
+ out["secrets_account"] = acct.Sqid
+ }
writeJSON(w, http.StatusOK, out)
}
diff --git a/internal/console/apiconfig_test.go b/internal/console/apiconfig_test.go
index 660e1eced..b9ccde7f0 100644
--- a/internal/console/apiconfig_test.go
+++ b/internal/console/apiconfig_test.go
@@ -148,7 +148,7 @@ func sealedFor(t *testing.T, plaintext string) (pub, priv, envelope string) {
if err != nil {
t.Fatal(err)
}
- envelope, err = appconfig.Seal(pk, []byte(plaintext))
+ envelope, err = appconfig.Seal(pk, appconfig.Binding{Account: release.OpsAccount, App: "titogo"}, []byte(plaintext))
if err != nil {
t.Fatal(err)
}
@@ -269,6 +269,34 @@ func TestAPIConfigGetSecretsPubkey(t *testing.T) {
}
}
+// The GET names the account sqid a secret for this app is bound to — the
+// CLI may only know the account by name — and only while the deployment
+// writes bound (v2) envelopes: its presence is what makes the CLI seal v2,
+// so with CONSOLE_SECRETS_ENVELOPE_V2 off (the default) it must be absent
+// and the CLI keeps writing v1 that every box can open (discussion #220).
+func TestAPIConfigGetNamesTheSecretsAccountOnlyWhenV2IsOn(t *testing.T) {
+ fx, token := configFixture(t, "code-config-secrets-account")
+ t.Cleanup(func() { appconfig.SetBoundSealing(false) })
+
+ appconfig.SetBoundSealing(false)
+ status, out := getJSON(t, configURL(fx, "titogo"), token)
+ if status != http.StatusOK {
+ t.Fatalf("GET = %d %v", status, out)
+ }
+ if _, ok := out["secrets_account"]; ok {
+ t.Fatalf("secrets_account served with bound sealing off: %v", out["secrets_account"])
+ }
+
+ appconfig.SetBoundSealing(true)
+ status, out = getJSON(t, configURL(fx, "titogo"), token)
+ if status != http.StatusOK {
+ t.Fatalf("GET = %d %v", status, out)
+ }
+ if got := str(t, out, "secrets_account"); got != release.OpsAccount {
+ t.Fatalf("secrets_account = %q, want %q", got, release.OpsAccount)
+ }
+}
+
// ---- PUT: the round trips ---------------------------------------------------
// TestAPIConfigPutRoundTrip is the property the whole feature rests on: an
@@ -345,7 +373,7 @@ func TestAPIConfigPutRoundTrip(t *testing.T) {
if err != nil {
t.Fatal(err)
}
- opened, err := appconfig.Open(pk, m.Vars["BANK_KEY"].Envelope)
+ opened, err := appconfig.Open(pk, appconfig.Binding{Account: release.OpsAccount, App: "titogo"}, m.Vars["BANK_KEY"].Envelope)
if err != nil {
t.Fatalf("stored envelope does not open: %v", err)
}
@@ -891,7 +919,7 @@ func TestAPIConfigPutLargeEnvelope(t *testing.T) {
if err != nil {
t.Fatal(err)
}
- got, err := appconfig.Open(pk, m.Vars["BIG_KEY"].Envelope)
+ got, err := appconfig.Open(pk, appconfig.Binding{Account: release.OpsAccount, App: "titogo"}, m.Vars["BIG_KEY"].Envelope)
if err != nil || len(got) != 16<<10 {
t.Fatalf("stored envelope does not open to the plaintext: %v", err)
}
diff --git a/internal/console/apistore_test.go b/internal/console/apistore_test.go
index 018eb85cd..2dbda9e87 100644
--- a/internal/console/apistore_test.go
+++ b/internal/console/apistore_test.go
@@ -545,7 +545,7 @@ func openSecret(t *testing.T, priv string, v appconfig.Var) string {
if err != nil {
t.Fatal(err)
}
- plain, err := appconfig.Open(pk, v.Envelope)
+ plain, err := appconfig.Open(pk, appconfig.Binding{Account: release.OpsAccount, App: "titogo"}, v.Envelope)
if err != nil {
t.Fatalf("open envelope: %v", err)
}
diff --git a/internal/console/relayops.go b/internal/console/relayops.go
index 719e196df..738570384 100644
--- a/internal/console/relayops.go
+++ b/internal/console/relayops.go
@@ -589,7 +589,7 @@ func (s *Server) relayApplyConfig(ctx context.Context, acct, app string, rec *re
if err != nil {
return err
}
- env, err := appconfig.Seal(pub, []byte(value))
+ env, err := appconfig.Seal(pub, appconfig.Binding{Account: acct, App: app}, []byte(value))
if err != nil {
return err
}
diff --git a/internal/mail/walk.go b/internal/mail/walk.go
index f721f61da..a139ea999 100644
--- a/internal/mail/walk.go
+++ b/internal/mail/walk.go
@@ -854,7 +854,7 @@ func writeSMTPEnv(ctx context.Context, s release.Store, sealPub *ecdh.PublicKey,
if err := appconfig.ValidVarValue(password); err != nil {
return fmt.Errorf("mail: minted credential for %s is not EnvironmentFile-safe: %w", passName, err)
}
- env, err := appconfig.Seal(sealPub, []byte(password))
+ env, err := appconfig.Seal(sealPub, appconfig.Binding{Account: acct, App: app}, []byte(password))
if err != nil {
return fmt.Errorf("mail: seal %s: %w", passName, err)
}
diff --git a/internal/mail/walk_enable_test.go b/internal/mail/walk_enable_test.go
index 0abe6a713..8da178a63 100644
--- a/internal/mail/walk_enable_test.go
+++ b/internal/mail/walk_enable_test.go
@@ -127,7 +127,7 @@ func TestEnableDeliversTheDerivedPasswordNotTheSecret(t *testing.T) {
if env == "" {
t.Fatal("PASS has no envelope")
}
- plain, err := appconfig.Open(priv, env)
+ plain, err := appconfig.Open(priv, appconfig.Binding{Account: "bag", App: "harbourmail"}, env)
if err != nil {
t.Fatalf("Open: %v", err)
}
diff --git a/internal/maintenance/envelope_test.go b/internal/maintenance/envelope_test.go
index 0b52517d1..7c566deca 100644
--- a/internal/maintenance/envelope_test.go
+++ b/internal/maintenance/envelope_test.go
@@ -215,21 +215,28 @@ func TestEnvelopeLimitsAndInvalidConfiguration(t *testing.T) {
}
func TestEnvelopeCannotCrossAppconfigPurpose(t *testing.T) {
v, k, _, _ := fixture(t)
- old, err := appconfig.Seal(k.PublicKey(), []byte(v.Plaintext))
+ appBinding := appconfig.Binding{Account: v.Binding.Account, App: v.Binding.App}
+ unbound, err := appconfig.SealUnbound(k.PublicKey(), []byte(v.Plaintext))
if err != nil {
t.Fatal(err)
}
- refused(t, k, v.Binding, old)
- oldRaw, err := base64.StdEncoding.DecodeString(old)
+ bound, err := appconfig.SealBound(k.PublicKey(), appBinding, []byte(v.Plaintext))
if err != nil {
t.Fatal(err)
}
- refused(t, k, v.Binding, base64.StdEncoding.EncodeToString(append([]byte(envelopePrefix), oldRaw...)))
+ for _, old := range []string{unbound, bound} {
+ refused(t, k, v.Binding, old)
+ oldRaw, err := base64.StdEncoding.DecodeString(old)
+ if err != nil {
+ t.Fatal(err)
+ }
+ refused(t, k, v.Binding, base64.StdEncoding.EncodeToString(append([]byte(envelopePrefix), oldRaw...)))
+ }
newRaw, err := base64.StdEncoding.DecodeString(v.Envelope)
if err != nil {
t.Fatal(err)
}
- if got, err := appconfig.Open(k, base64.StdEncoding.EncodeToString(newRaw[len(envelopePrefix):])); err == nil || got != nil {
+ if got, err := appconfig.Open(k, appBinding, base64.StdEncoding.EncodeToString(newRaw[len(envelopePrefix):])); err == nil || got != nil {
t.Fatal("maintenance ciphertext opened as appconfig secret")
}
}
diff --git a/internal/objstore/walk.go b/internal/objstore/walk.go
index 28b2527ec..e8612b757 100644
--- a/internal/objstore/walk.go
+++ b/internal/objstore/walk.go
@@ -872,7 +872,7 @@ func writeEnv(ctx context.Context, s release.Store, sealPub *ecdh.PublicKey,
if err := appconfig.ValidVarValue(half.plain); err != nil {
return fmt.Errorf("objstore: minted credential for %s is not EnvironmentFile-safe: %w", half.name, err)
}
- env, err := appconfig.Seal(sealPub, []byte(half.plain))
+ env, err := appconfig.Seal(sealPub, appconfig.Binding{Account: acct, App: app}, []byte(half.plain))
if err != nil {
return fmt.Errorf("objstore: seal %s: %w", half.name, err)
}
diff --git a/internal/objstore/walk_env_test.go b/internal/objstore/walk_env_test.go
index 56363f7fd..7d0cce33a 100644
--- a/internal/objstore/walk_env_test.go
+++ b/internal/objstore/walk_env_test.go
@@ -76,7 +76,7 @@ func TestGrantInDeliversToTheEnvironmentBundle(t *testing.T) {
if sealed.Kind != "secret" || sealed.Envelope == "" {
t.Fatalf("TITO_S3_SECRET_KEY = %+v, want a sealed secret", sealed)
}
- plain, err := appconfig.Open(priv, sealed.Envelope)
+ plain, err := appconfig.Open(priv, appconfig.Binding{Account: "bab", App: "titogo"}, sealed.Envelope)
if err != nil {
t.Fatal(err)
}
diff --git a/internal/objstore/walk_test.go b/internal/objstore/walk_test.go
index f42481ea5..028edb304 100644
--- a/internal/objstore/walk_test.go
+++ b/internal/objstore/walk_test.go
@@ -138,7 +138,7 @@ func TestGrantMintsAndDelivers(t *testing.T) {
if v := vars["TITO_S3_ACCESS_KEY"]; v.Kind != "secret" || v.Envelope == "" {
t.Fatalf("TITO_S3_ACCESS_KEY = %+v", v)
}
- gotID, err := appconfig.Open(priv, vars["TITO_S3_ACCESS_KEY"].Envelope)
+ gotID, err := appconfig.Open(priv, appconfig.Binding{Account: "bab", App: "titogo"}, vars["TITO_S3_ACCESS_KEY"].Envelope)
if err != nil || string(gotID) != rec.Provisioned.KeyID {
t.Fatalf("delivered access key = %q, %v; want %q", gotID, err, rec.Provisioned.KeyID)
}
@@ -146,7 +146,7 @@ func TestGrantMintsAndDelivers(t *testing.T) {
if sk.Kind != "secret" || sk.Value != "" {
t.Fatalf("TITO_S3_SECRET_KEY = %+v (secret must never be plaintext)", sk)
}
- gotSecret, err := appconfig.Open(priv, sk.Envelope)
+ gotSecret, err := appconfig.Open(priv, appconfig.Binding{Account: "bab", App: "titogo"}, sk.Envelope)
if err != nil || string(gotSecret) != secret {
t.Fatalf("delivered secret = %q, %v; want %q", gotSecret, err, secret)
}
@@ -237,7 +237,7 @@ func TestGrantResumesAfterEachStepFailure(t *testing.T) {
t.Fatalf("recorded key %q not live: %v", rec.Provisioned.KeyID, u.Keys)
}
env := mustVars(t, s, "bab", "titogo")["TITO_S3_SECRET_KEY"].Envelope
- got, err := appconfig.Open(priv, env)
+ got, err := appconfig.Open(priv, appconfig.Binding{Account: "bab", App: "titogo"}, env)
if err != nil || string(got) != u.Keys[rec.Provisioned.KeyID] {
t.Fatalf("delivered secret = %q, %v; want %q", got, err, u.Keys[rec.Provisioned.KeyID])
}
@@ -271,11 +271,11 @@ func TestGrantResumesAfterEachStepFailure(t *testing.T) {
t.Fatalf("recorded key = %q, want the SECOND mint MEMKEY2", rec.Provisioned.KeyID)
}
vars := mustVars(t, s, "bab", "titogo")
- gotID, err := appconfig.Open(priv, vars["TITO_S3_ACCESS_KEY"].Envelope)
+ gotID, err := appconfig.Open(priv, appconfig.Binding{Account: "bab", App: "titogo"}, vars["TITO_S3_ACCESS_KEY"].Envelope)
if err != nil || string(gotID) != "MEMKEY2" {
t.Fatalf("delivered access key = %q, %v; want MEMKEY2", gotID, err)
}
- gotSecret, err := appconfig.Open(priv, vars["TITO_S3_SECRET_KEY"].Envelope)
+ gotSecret, err := appconfig.Open(priv, appconfig.Binding{Account: "bab", App: "titogo"}, vars["TITO_S3_SECRET_KEY"].Envelope)
if err != nil || string(gotSecret) != u.Keys["MEMKEY2"] {
t.Fatalf("delivered secret = %q, %v; want %q", gotSecret, err, u.Keys["MEMKEY2"])
}
@@ -368,7 +368,7 @@ func TestGrantRepairsWrongKindVar(t *testing.T) {
t.Fatalf("wrong-kind secret: missing = %v, %v", missing, err)
}
// Same for a coordinate occupied by a sealed secret.
- sealed, err := appconfig.Seal(pub, []byte("carlos-store-tito-bab-titogo"))
+ sealed, err := appconfig.Seal(pub, appconfig.Binding{Account: "bab", App: "titogo"}, []byte("carlos-store-tito-bab-titogo"))
if err != nil {
t.Fatal(err)
}
@@ -393,7 +393,7 @@ func TestGrantRepairsWrongKindVar(t *testing.T) {
t.Fatalf("secret not repaired: %+v", sk)
}
live := p.Users["carlos-store-bab-titogo"].Keys[rec.Provisioned.KeyID]
- got, err := appconfig.Open(priv, sk.Envelope)
+ got, err := appconfig.Open(priv, appconfig.Binding{Account: "bab", App: "titogo"}, sk.Envelope)
if err != nil || string(got) != live {
t.Fatalf("repaired secret = %q, %v; want %q", got, err, live)
}
@@ -588,7 +588,7 @@ func openVar(t *testing.T, priv *ecdh.PrivateKey, s release.Store, acct, app, na
if v.Kind != "secret" || v.Envelope == "" {
t.Fatalf("%s = %+v, want a sealed secret", name, v)
}
- plain, err := appconfig.Open(priv, v.Envelope)
+ plain, err := appconfig.Open(priv, appconfig.Binding{Account: acct, App: app}, v.Envelope)
if err != nil {
t.Fatalf("open %s: %v", name, err)
}
diff --git a/internal/relay/server.go b/internal/relay/server.go
index 671cc46fd..118f7667d 100644
--- a/internal/relay/server.go
+++ b/internal/relay/server.go
@@ -197,14 +197,48 @@ type admitRec struct {
// builtinDeny is the peer policy every relay carries whatever the operator
// configures: loopback, link-local (the IMDS address included), RFC 1918,
-// unique-local and carrier-grade NAT space.
+// unique-local and carrier-grade NAT space; "this network" (0.0.0.0/8, which
+// Linux delivers to the local host, not only the unspecified address), the
+// IETF protocol-assignment block (192.0.0.0/24) and the benchmarking range
+// (198.18.0.0/15), none of which is a public peer (discussion #220).
+//
+// IPv6 forms that carry an IPv4 address — NAT64 (64:ff9b::/96) and 6to4
+// (2002::/16) — are not listed: they are judged by the address they embed
+// (see embeddedV4), so a NAT64 gateway cannot be used to reach 10.0.0.1 and
+// a public address behind either one still works.
var builtinDeny = mustCIDRs(
"127.0.0.0/8", "::1/128",
"169.254.0.0/16", "fe80::/10",
"10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16",
"fc00::/7", "100.64.0.0/10",
+ "0.0.0.0/8", "192.0.0.0/24", "198.18.0.0/15",
)
+// Prefixes whose addresses embed an IPv4 address: the NAT64 well-known
+// prefix carries it in the last 32 bits (RFC 6052), 6to4 in bits 16-47
+// (RFC 3056).
+var (
+ nat64Prefix = mustCIDRs("64:ff9b::/96")[0]
+ sixToFour = mustCIDRs("2002::/16")[0]
+)
+
+// embeddedV4 returns the IPv4 address a NAT64 or 6to4 peer stands for, or
+// nil for any other address. An IPv4-mapped address (::ffff:a.b.c.d) needs
+// no help: net.IPNet.Contains already matches it against the IPv4 ranges.
+func embeddedV4(ip net.IP) net.IP {
+ ip16 := ip.To16()
+ if ip16 == nil || ip.To4() != nil {
+ return nil
+ }
+ switch {
+ case nat64Prefix.Contains(ip16):
+ return net.IPv4(ip16[12], ip16[13], ip16[14], ip16[15])
+ case sixToFour.Contains(ip16):
+ return net.IPv4(ip16[2], ip16[3], ip16[4], ip16[5])
+ }
+ return nil
+}
+
func mustCIDRs(ss ...string) []*net.IPNet {
out := make([]*net.IPNet, 0, len(ss))
for _, s := range ss {
@@ -860,35 +894,53 @@ func (s *Server) permit(client net.Addr, peer net.IP) bool {
s.log("relay: permission refused for %s to %s: %s", client, peer, why)
return false
}
- switch {
- case peer == nil || peer.IsUnspecified():
+ if peer == nil {
return deny("unspecified address")
- case peer.IsMulticast():
- return deny("multicast address")
}
// Our own EIP is allowed: two clients that both relay here hold relay
// candidates on it and must reach each other.
if s.cfg.RelayIP != nil && peer.Equal(s.cfg.RelayIP) {
return true
}
+ if why := s.peerRefusal(peer); why != "" {
+ return deny(why)
+ }
+ // A NAT64 or 6to4 address is a route to the IPv4 address inside it, so
+ // that address has to pass too.
+ if v4 := embeddedV4(peer); v4 != nil {
+ if why := s.peerRefusal(v4); why != "" {
+ return deny(why + " (embedded in " + peer.String() + ")")
+ }
+ }
+ return true
+}
+
+// peerRefusal is permit's verdict on one address, "" when it may be reached.
+func (s *Server) peerRefusal(peer net.IP) string {
+ switch {
+ case peer.IsUnspecified():
+ return "unspecified address"
+ case peer.IsMulticast():
+ return "multicast address"
+ }
// ExtraAllow is the operator's (and the test fixture's) way to permit
// a range the built-in list denies. Empty on a real box.
for _, n := range s.allow {
if n.Contains(peer) {
- return true
+ return ""
}
}
for _, n := range s.deny {
if n.Contains(peer) {
- return deny("denied range " + n.String())
+ return "denied range " + n.String()
}
}
for _, ip := range s.local {
if peer.Equal(ip) {
- return deny("the relay's own address")
+ return "the relay's own address"
}
}
- return true
+ return ""
}
// --- metering ------------------------------------------------------------
diff --git a/internal/relay/server_test.go b/internal/relay/server_test.go
index 0b69675e4..dacb57a41 100644
--- a/internal/relay/server_test.go
+++ b/internal/relay/server_test.go
@@ -598,13 +598,27 @@ func TestServerPermit(t *testing.T) {
"127.0.0.1", "::1", "169.254.169.254", "fe80::1", "10.0.0.1",
"172.16.5.5", "192.168.1.1", "fd00::1", "100.64.0.1", "172.31.9.9",
"0.0.0.0", "224.0.0.1",
+ // Discussion #220: "this network" beyond the unspecified address,
+ // IETF protocol assignments, benchmarking.
+ "0.1.2.3", "0.255.255.255", "192.0.0.8", "198.18.0.1", "198.19.255.254",
+ // NAT64 and 6to4 judged by the IPv4 address they carry.
+ "64:ff9b::a00:1", "64:ff9b::7f00:1", "64:ff9b::a9fe:a9fe", "64:ff9b::ac1f:909",
+ "2002:a00:1::1", "2002:a9fe:a9fe::", "2002:c0a8:101::1", "2002:0:0::1",
+ // IPv4-mapped forms are matched by the IPv4 ranges already.
+ "::ffff:10.0.0.1", "::ffff:198.18.0.1",
}
for _, ip := range refused {
if s.permit(client, net.ParseIP(ip)) {
t.Fatalf("peer %s was permitted", ip)
}
}
- for _, ip := range []string{"203.0.113.5", "198.51.100.9", "2001:db8::1"} {
+ for _, ip := range []string{
+ "203.0.113.5", "198.51.100.9", "2001:db8::1",
+ // Neighbours of the new ranges stay reachable, and a public address
+ // behind NAT64 or 6to4 is an ordinary peer.
+ "1.0.0.1", "192.0.1.1", "198.17.255.255", "198.20.0.1",
+ "64:ff9b::808:808", "2002:808:808::1",
+ } {
if !s.permit(client, net.ParseIP(ip)) {
t.Fatalf("peer %s was refused", ip)
}
diff --git a/internal/release/accountbind_test.go b/internal/release/accountbind_test.go
new file mode 100644
index 000000000..3043a857c
--- /dev/null
+++ b/internal/release/accountbind_test.go
@@ -0,0 +1,150 @@
+package release
+
+import (
+ "context"
+ "crypto/ed25519"
+ "encoding/base64"
+ "encoding/json"
+ "strings"
+ "testing"
+)
+
+// A pointer signed for one account's app must not verify at another
+// account's key for an app of the same name (tenant-isolation audit,
+// discussion #220). App names are unique only WITHIN an account, so
+// app+channel alone named "blog/edge" in every account at once: a byte copy
+// of A's pointer into B's prefix verified there.
+//
+// Mutation-mindedness: drop the account comparison from validatePointer and
+// the cross-account read below verifies again.
+func TestPointerDoesNotVerifyUnderAnotherAccount(t *testing.T) {
+ s := NewDirStore(t.TempDir())
+ ctx := context.Background()
+ priv, pub := testKeys(t)
+ shipRelease(t, s, "aaa", "blog", "9f3c1ad", "linux-arm64", []byte("bytes"))
+ if err := Promote(ctx, s, priv, "aaa", "blog", "edge", "9f3c1ad"); err != nil {
+ t.Fatalf("Promote: %v", err)
+ }
+ raw, _, err := s.Get(ctx, ChannelKey("aaa", "blog", "edge"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := Channel(ctx, s, pub, "aaa", "blog", "edge"); err != nil {
+ t.Fatalf("own account: %v", err)
+ }
+ if _, err := s.Put(ctx, ChannelKey("bbb", "blog", "edge"), raw, ""); err != nil {
+ t.Fatal(err)
+ }
+ _, err = Channel(ctx, s, pub, "bbb", "blog", "edge")
+ if err == nil {
+ t.Fatal("account aaa's pointer verified under account bbb")
+ }
+ if !strings.Contains(err.Error(), "aaa") {
+ t.Fatalf("refusal should name the account the pointer was signed for: %v", err)
+ }
+}
+
+// Every pointer written from now on carries its account under the
+// signature, so the binding cannot be removed without breaking it.
+func TestFreshPointerPayloadNamesItsAccount(t *testing.T) {
+ var sw signedWrapper
+ if err := json.Unmarshal(freshRootWrapper(t), &sw); err != nil {
+ t.Fatal(err)
+ }
+ payload, err := base64.StdEncoding.DecodeString(sw.Payload)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var p ChannelPointer
+ if err := json.Unmarshal(payload, &p); err != nil {
+ t.Fatal(err)
+ }
+ if p.Account != "bab" {
+ t.Fatalf("signed payload account = %q, want bab", p.Account)
+ }
+}
+
+// A pointer written before the binding existed has no account and must keep
+// verifying wherever it sits: that is most of every production bucket. This
+// is the residual the binding cannot close by itself — see verifyPointer.
+func TestAccountlessPointerStillVerifiesAnywhere(t *testing.T) {
+ pub, err := ParsePublicKey(goldenRootPub, "golden")
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, acct := range []string{"bab", "zzz"} {
+ if _, err := verifyPointer(pub, []byte(goldenRootSignedWrapper), acct, "kass", "canary/platform-adoption"); err != nil {
+ t.Fatalf("golden account-less pointer under %s: %v", acct, err)
+ }
+ }
+}
+
+// A signer cannot produce an unbound pointer: the account is required at
+// signing, so no code path quietly writes the old shape.
+func TestSignPointerRequiresAccount(t *testing.T) {
+ priv, _ := testKeys(t)
+ if _, err := signPointer(RootSigner(priv), &ChannelPointer{App: "blog", Channel: "edge", Version: "9f3c1ad"}); err == nil {
+ t.Fatal("signed a pointer with no account")
+ }
+}
+
+// Moving an app between accounts: a bound pointer is re-signed for the new
+// account (deterministically, so a re-run compares equal), an unbound one
+// travels as bytes, and the result verifies at the destination only.
+func TestRebindPointerMovesTheBinding(t *testing.T) {
+ s := NewDirStore(t.TempDir())
+ ctx := context.Background()
+ priv, pub := testKeys(t)
+ shipRelease(t, s, "aaa", "blog", "9f3c1ad", "linux-arm64", []byte("bytes"))
+ if err := Promote(ctx, s, priv, "aaa", "blog", "edge", "9f3c1ad"); err != nil {
+ t.Fatal(err)
+ }
+ raw, _, err := s.Get(ctx, ChannelKey("aaa", "blog", "edge"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if acct, err := PointerAccount(raw); err != nil || acct != "aaa" {
+ t.Fatalf("PointerAccount = %q, %v", acct, err)
+ }
+ moved, err := RebindPointer(priv, raw, "aaa", "bbb", "blog", "edge")
+ if err != nil {
+ t.Fatal(err)
+ }
+ again, err := RebindPointer(priv, raw, "aaa", "bbb", "blog", "edge")
+ if err != nil || string(again) != string(moved) {
+ t.Fatalf("rebind is not deterministic (%v)", err)
+ }
+ got, err := verifyPointer(pub, moved, "bbb", "blog", "edge")
+ if err != nil {
+ t.Fatalf("rebound pointer does not verify at the destination: %v", err)
+ }
+ orig, _ := verifyPointer(pub, raw, "aaa", "blog", "edge")
+ if got.Version != orig.Version || !got.PromotedAt.Equal(orig.PromotedAt) || got.Account != "bbb" {
+ t.Fatalf("rebind changed more than the account: %+v vs %+v", got, orig)
+ }
+ if _, err := verifyPointer(pub, moved, "aaa", "blog", "edge"); err == nil {
+ t.Fatal("rebound pointer still verifies at the source account")
+ }
+ // A pointer from before the binding travels unchanged.
+ golden := []byte(goldenRootSignedWrapper)
+ goldPriv := ed25519.NewKeyFromSeed(goldenSeed())
+ same, err := RebindPointer(goldPriv, golden, "aaa", "bbb", "kass", "canary/platform-adoption")
+ if err != nil || string(same) != string(golden) {
+ t.Fatalf("account-less pointer was rewritten (%v)", err)
+ }
+ // Rebinding needs the root key the pointer verifies against.
+ wrong := ed25519.NewKeyFromSeed(make([]byte, ed25519.SeedSize))
+ if _, err := RebindPointer(wrong, raw, "aaa", "bbb", "blog", "edge"); err == nil {
+ t.Fatal("rebound a pointer with a key it does not verify against")
+ }
+}
+
+// goldenSeed is the deterministic seed golden_test.go's fixture was signed
+// with (bytes 1..32).
+func goldenSeed() []byte {
+ seed := make([]byte, ed25519.SeedSize)
+ for i := range seed {
+ seed[i] = byte(i + 1)
+ }
+ return seed
+}
diff --git a/internal/release/golden_test.go b/internal/release/golden_test.go
index 2ce0970be..9dee0fc14 100644
--- a/internal/release/golden_test.go
+++ b/internal/release/golden_test.go
@@ -42,7 +42,7 @@ func TestGoldenRootSignedWrapperStillVerifies(t *testing.T) {
if err != nil {
t.Fatalf("ParsePublicKey: %v", err)
}
- p, err := verifyPointer(pub, []byte(goldenRootSignedWrapper), "kass", "canary/platform-adoption")
+ p, err := verifyPointer(pub, []byte(goldenRootSignedWrapper), "bab", "kass", "canary/platform-adoption")
if err != nil {
t.Fatalf("golden root-signed pointer no longer verifies: %v", err)
}
diff --git a/internal/release/qualified_test.go b/internal/release/qualified_test.go
index 71109d20a..755ca68ca 100644
--- a/internal/release/qualified_test.go
+++ b/internal/release/qualified_test.go
@@ -178,7 +178,7 @@ func TestLegacyOnlyAppIsInvisibleToTheRuntime(t *testing.T) {
if _, err := dir.Put(ctx, LegacyManifestKey("old", "v1"), raw, ""); err != nil {
t.Fatal(err)
}
- ptr, err := signPointer(RootSigner(priv), &ChannelPointer{App: "old", Channel: "stable", Version: "v1"})
+ ptr, err := signPointer(RootSigner(priv), &ChannelPointer{Account: "bab", App: "old", Channel: "stable", Version: "v1"})
if err != nil {
t.Fatal(err)
}
@@ -525,7 +525,7 @@ func TestPromoteDoesNotInheritALegacyPointer(t *testing.T) {
shipRelease(t, s, "kqmv", "myapp", "v1", "linux-amd64", []byte("v1"))
shipRelease(t, s, "kqmv", "myapp", "v2", "linux-amd64", []byte("v2"))
- legacy, err := signPointer(RootSigner(priv), &ChannelPointer{App: "myapp", Channel: "stable", Version: "v1"})
+ legacy, err := signPointer(RootSigner(priv), &ChannelPointer{Account: "bab", App: "myapp", Channel: "stable", Version: "v1"})
if err != nil {
t.Fatal(err)
}
diff --git a/internal/release/rebind.go b/internal/release/rebind.go
new file mode 100644
index 000000000..844c598bd
--- /dev/null
+++ b/internal/release/rebind.go
@@ -0,0 +1,70 @@
+package release
+
+import (
+ "crypto/ed25519"
+ "encoding/base64"
+ "encoding/json"
+ "fmt"
+)
+
+// PointerAccount reads the account a channel pointer names WITHOUT verifying
+// it — "" for a pointer from before the binding existed (discussion #220).
+// For planning only: `accounts migrate` asks it whether a pointer can travel
+// as bytes or has to be re-signed for its new account, and it trusts nothing
+// it learns here. Whatever it then writes is verified like any pointer.
+func PointerAccount(wrapper []byte) (string, error) {
+ var sw signedWrapper
+ if err := json.Unmarshal(wrapper, &sw); err != nil {
+ return "", fmt.Errorf("pointer wrapper: %v", err)
+ }
+ payload, err := base64.StdEncoding.DecodeString(sw.Payload)
+ if err != nil {
+ return "", fmt.Errorf("pointer payload: %v", err)
+ }
+ var p ChannelPointer
+ if err := json.Unmarshal(payload, &p); err != nil {
+ return "", fmt.Errorf("pointer payload: %v", err)
+ }
+ return p.Account, nil
+}
+
+// RebindPointer is how a channel pointer moves between accounts. A pointer
+// names the account it was signed for, so a byte copy into another account's
+// prefix no longer verifies there — which is the point of the binding, and
+// what `accounts migrate` would otherwise trip over. The pointer is verified
+// under fromAcct with root's public half, then re-signed by root for toAcct
+// with every other field kept, PromotedAt included.
+//
+// Deterministic on purpose: Ed25519 signatures are, and nothing is
+// re-stamped, so the same source bytes always rebind to the same bytes. The
+// migration compares destination bytes against this output to decide whether
+// a pointer has already been copied, and a fresh timestamp would make every
+// re-run look like a disagreement.
+//
+// A pointer that names no account (written before the binding) or already
+// names toAcct is returned as is: it verifies at the destination unchanged.
+func RebindPointer(root ed25519.PrivateKey, wrapper []byte, fromAcct, toAcct, app, channel string) ([]byte, error) {
+ if !ValidAccount(toAcct) {
+ return nil, fmt.Errorf("rebind: invalid account %q", toAcct)
+ }
+ p, err := verifyPointer(root.Public().(ed25519.PublicKey), wrapper, fromAcct, app, channel)
+ if err != nil {
+ return nil, fmt.Errorf("rebind %s/%s: %w", app, channel, err)
+ }
+ if p.Account == "" || p.Account == toAcct {
+ return wrapper, nil
+ }
+ p.Account = toAcct
+ if err := validatePointer(p, toAcct, app, channel); err != nil {
+ return nil, err
+ }
+ payload, err := json.MarshalIndent(p, "", " ")
+ if err != nil {
+ return nil, err
+ }
+ b64 := base64.StdEncoding.EncodeToString(payload)
+ return json.MarshalIndent(signedWrapper{
+ Payload: b64,
+ Sig: base64.StdEncoding.EncodeToString(ed25519.Sign(root, []byte(b64))),
+ }, "", " ")
+}
diff --git a/internal/release/release.go b/internal/release/release.go
index 27b332bb3..b9bfcb531 100644
--- a/internal/release/release.go
+++ b/internal/release/release.go
@@ -101,7 +101,19 @@ type Artifact struct {
// document the fleet trusts, so it ships wrapped in signedWrapper and only
// moves via CAS.
type ChannelPointer struct {
- Schema int `json:"schema"`
+ Schema int `json:"schema"`
+ // Account is the sqid of the account the pointer was signed for. App
+ // names are unique only WITHIN an account, so app+channel alone matched
+ // "blog/edge" in every account at once, and a byte copy of one account's
+ // pointer verified at another's key (tenant-isolation audit, discussion
+ // #220). Signed into every pointer written since, and checked against the
+ // key's account whenever present.
+ //
+ // ADDITIVE, and deliberately optional on read: every pointer written
+ // before the field existed has no "account" key and keeps verifying
+ // wherever it sits — see verifyPointer for what that leaves open. Old
+ // binaries ignore the key and verify exactly as before.
+ Account string `json:"account,omitempty"`
App string `json:"app"`
Channel string `json:"channel"`
Version string `json:"version"`
@@ -349,7 +361,12 @@ func signPointer(sg Signer, p *ChannelPointer) ([]byte, error) {
}
p.Schema = 1
p.PromotedAt = time.Now().UTC().Truncate(time.Second)
- if err := validatePointer(p, p.App, p.Channel); err != nil {
+ // Optional on READ (old pointers), required on WRITE: nothing signed from
+ // here on may be an unbound pointer, or the binding is a convention.
+ if !ValidAccount(p.Account) {
+ return nil, fmt.Errorf("channel pointer %s/%s: invalid account %q", p.App, p.Channel, p.Account)
+ }
+ if err := validatePointer(p, p.Account, p.App, p.Channel); err != nil {
return nil, err
}
if err := sg.allows(p.Channel, p.PromotedAt); err != nil {
@@ -373,9 +390,23 @@ func signPointer(sg Signer, p *ChannelPointer) ([]byte, error) {
// verifyPointer checks the signature against pub and only then decodes and
// validates the payload — everything the caller acts on came from under the
-// signature. wantApp/wantChannel are matched against the SIGNED payload, so
-// a validly-signed pointer copied from another channel's key (a cross-channel
-// replay) is rejected.
+// signature. wantAcct/wantApp/wantChannel are matched against the SIGNED
+// payload, so a validly-signed pointer copied from another channel's key (a
+// cross-channel replay) is rejected, and so is one copied from another
+// account's key when it names its account.
+//
+// What the account check cannot close: a pointer signed BEFORE the field
+// existed names no account, and verifies under any account's key with that
+// app and channel name — refusing it would take every channel not promoted
+// since dark. The field is inside the signature, so it cannot be stripped
+// from a newer pointer without breaking it; the exposure is the fixed set of
+// account-less pointers already signed, which (having no expiry) stay
+// replayable until verification stops accepting them. Every promote,
+// rollback or fast-track re-signs a channel with its account, so that set
+// only shrinks. Sunset: once no live channel pointer lacks an account, make
+// the account required here; until then the copy still needs write access
+// to the victim's prefix, and resolves only to a version shipped there,
+// since the manifest is read under the key's own account.
//
// pub is the deployment's ROOT public key, and it is the only key a box is
// ever configured with. A wrapper with no grant was signed by that key
@@ -390,7 +421,7 @@ func signPointer(sg Signer, p *ChannelPointer) ([]byte, error) {
// This is the single parse site for channel pointers, and it must stay that
// way: every consumer (Channel, adoption, VersionRung, the console, the edge)
// inherits grant handling by funnelling through here.
-func verifyPointer(pub ed25519.PublicKey, wrapper []byte, wantApp, wantChannel string) (*ChannelPointer, error) {
+func verifyPointer(pub ed25519.PublicKey, wrapper []byte, wantAcct, wantApp, wantChannel string) (*ChannelPointer, error) {
var sw signedWrapper
if err := json.Unmarshal(wrapper, &sw); err != nil {
return nil, fmt.Errorf("pointer wrapper: %v", err)
@@ -427,7 +458,7 @@ func verifyPointer(pub ed25519.PublicKey, wrapper []byte, wantApp, wantChannel s
if err := json.Unmarshal(payload, &p); err != nil {
return nil, fmt.Errorf("pointer payload: %v", err)
}
- if err := validatePointer(&p, wantApp, wantChannel); err != nil {
+ if err := validatePointer(&p, wantAcct, wantApp, wantChannel); err != nil {
return nil, err
}
if p.FastTrack && sw.Grant != nil {
@@ -445,10 +476,15 @@ func verifyPointer(pub ed25519.PublicKey, wrapper []byte, wantApp, wantChannel s
return &p, nil
}
-func validatePointer(p *ChannelPointer, wantApp, wantChannel string) error {
+func validatePointer(p *ChannelPointer, wantAcct, wantApp, wantChannel string) error {
if p.Schema != 1 {
return fmt.Errorf("unsupported pointer schema %d", p.Schema)
}
+ // Absent means a pointer from before the binding (see verifyPointer);
+ // present means it must be this key's account.
+ if p.Account != "" && p.Account != wantAcct {
+ return fmt.Errorf("pointer %s/%s was signed for account %s, expected %s", p.App, p.Channel, p.Account, wantAcct)
+ }
if p.App != wantApp || p.Channel != wantChannel {
return fmt.Errorf("pointer names %s/%s, expected %s/%s", p.App, p.Channel, wantApp, wantChannel)
}
@@ -689,7 +725,7 @@ func (sg Signer) Promote(ctx context.Context, s Store, acct, app, channel, versi
var old *ChannelPointer
switch {
case err == nil:
- old, err = verifyPointer(pub, cur, app, channel)
+ old, err = verifyPointer(pub, cur, acct, app, channel)
if err != nil {
return fmt.Errorf("promote: existing pointer at %s: %w", key, err)
}
@@ -698,7 +734,7 @@ func (sg Signer) Promote(ctx context.Context, s Store, acct, app, channel, versi
default:
return err
}
- next := &ChannelPointer{App: app, Channel: channel, Version: version,
+ next := &ChannelPointer{Account: acct, App: app, Channel: channel, Version: version,
History: old.History, Audit: old.Audit}
if old.Version != "" && old.Version != version {
next.History = prependCapped(old.Version, old.History, historyMax)
@@ -841,7 +877,7 @@ func (sg Signer) PromoteOnLadder(ctx context.Context, s Store, acct, app, channe
// A repeat is a no-op, checked before anything else: re-running a command
// must not fail, whichever rung the build has since climbed to.
if cur, _, err := s.Get(ctx, ChannelKey(acct, app, channel)); err == nil {
- if p, verr := verifyPointer(pub, cur, app, channel); verr == nil && p.Version == version {
+ if p, verr := verifyPointer(pub, cur, acct, app, channel); verr == nil && p.Version == version {
res.NoOp = true
// The same fallback res.To takes above, for the same reason: the
// build IS on this channel (that is what makes this a repeat), so
@@ -966,7 +1002,7 @@ func Channel(ctx context.Context, s Store, pub ed25519.PublicKey, acct, app, cha
if err != nil {
return nil, err
}
- return verifyPointer(pub, raw, app, channel)
+ return verifyPointer(pub, raw, acct, app, channel)
}
// Rollback swaps a channel back to History[0], pushing the outgoing version
@@ -1000,7 +1036,7 @@ func (sg Signer) Rollback(ctx context.Context, s Store, acct, app, channel strin
if err != nil {
return fmt.Errorf("rollback: %w", err)
}
- old, err := verifyPointer(pub, cur, app, channel)
+ old, err := verifyPointer(pub, cur, acct, app, channel)
if err != nil {
return fmt.Errorf("rollback: existing pointer at %s: %w", key, err)
}
@@ -1008,6 +1044,7 @@ func (sg Signer) Rollback(ctx context.Context, s Store, acct, app, channel strin
return fmt.Errorf("rollback: %s/%s has no previous version recorded", app, channel)
}
next := &ChannelPointer{
+ Account: acct,
App: app,
Channel: channel,
Version: old.History[0],
@@ -1049,7 +1086,7 @@ func FastTrack(ctx context.Context, s Store, priv ed25519.PrivateKey, acct, app,
if err != nil {
return "", fmt.Errorf("fast-track: %w", err)
}
- old, err := verifyPointer(sg.verifyPub(), cur, app, channel)
+ old, err := verifyPointer(sg.verifyPub(), cur, acct, app, channel)
if err != nil {
return "", fmt.Errorf("fast-track: existing pointer at %s: %w", key, err)
}
@@ -1058,7 +1095,7 @@ func FastTrack(ctx context.Context, s Store, priv ed25519.PrivateKey, acct, app,
}
rung, _ := Rung(channel)
next := &ChannelPointer{
- App: app, Channel: channel, Version: old.Version,
+ Account: acct, App: app, Channel: channel, Version: old.Version,
History: old.History, FastTrack: true,
Audit: prependCapped(Promotion{
Version: old.Version, From: rung, To: rung, Actor: actor,
diff --git a/internal/release/release_test.go b/internal/release/release_test.go
index 5c8be0dd2..841f4ffa2 100644
--- a/internal/release/release_test.go
+++ b/internal/release/release_test.go
@@ -268,13 +268,13 @@ func TestValidChannel(t *testing.T) {
// stamp is informational. This just pins that signPointer stamps it.
func TestSignStampsPromotedAt(t *testing.T) {
priv, pub := testKeys(t)
- p := &ChannelPointer{App: "myapp", Channel: "stable", Version: "v1"}
+ p := &ChannelPointer{Account: "bab", App: "myapp", Channel: "stable", Version: "v1"}
before := time.Now().Add(-time.Second)
raw, err := signPointer(RootSigner(priv), p)
if err != nil {
t.Fatalf("signPointer: %v", err)
}
- got, err := verifyPointer(pub, raw, "myapp", "stable")
+ got, err := verifyPointer(pub, raw, "bab", "myapp", "stable")
if err != nil {
t.Fatalf("verifyPointer: %v", err)
}
@@ -713,7 +713,7 @@ func TestFastTrackIsRootOnly(t *testing.T) {
}
// Mint side: the scoped signer refuses the payload.
if _, err := signPointer(scoped, &ChannelPointer{
- App: "kass", Channel: "edge", Version: "v1", FastTrack: true,
+ Account: "bab", App: "kass", Channel: "edge", Version: "v1", FastTrack: true,
}); err == nil || !strings.Contains(err.Error(), "root") {
t.Fatalf("a scoped signer signed a fast-tracked pointer (err=%v)", err)
}
@@ -731,7 +731,7 @@ func TestFastTrackIsRootOnly(t *testing.T) {
Sig: base64.StdEncoding.EncodeToString(ed25519.Sign(key, []byte(b64))),
Grant: sg,
})
- if _, err := verifyPointer(pub, wrapper, "kass", "edge"); err == nil ||
+ if _, err := verifyPointer(pub, wrapper, "bab", "kass", "edge"); err == nil ||
!strings.Contains(err.Error(), "root") {
t.Fatalf("a scoped-signed fast_track pointer verified (err=%v)", err)
}
diff --git a/internal/release/scopedverify_test.go b/internal/release/scopedverify_test.go
index 90510874a..4c7c50d26 100644
--- a/internal/release/scopedverify_test.go
+++ b/internal/release/scopedverify_test.go
@@ -57,7 +57,7 @@ func scopedFixture(t *testing.T, rungs []string, ttl time.Duration) (root ed2551
// really be written.
func signAs(t *testing.T, sg Signer, app, channel, version string) []byte {
t.Helper()
- w, err := signPointer(sg, &ChannelPointer{App: app, Channel: channel, Version: version})
+ w, err := signPointer(sg, &ChannelPointer{Account: "bab", App: app, Channel: channel, Version: version})
if err != nil {
t.Fatalf("signPointer(%s): %v", channel, err)
}
@@ -98,7 +98,7 @@ func TestGoldenGrantSignedWrapperShapeAndVerification(t *testing.T) {
t.Fatalf("grant sub-object keys = %v, want %v", got, want)
}
- p, err := verifyPointer(rootPubKey(t), []byte(goldenGrantSignedWrapper), "kass", "edge")
+ p, err := verifyPointer(rootPubKey(t), []byte(goldenGrantSignedWrapper), "bab", "kass", "edge")
if err != nil {
t.Fatalf("golden grant-signed pointer does not verify: %v", err)
}
@@ -142,7 +142,7 @@ func TestVerifyEnforcesGrantScope(t *testing.T) {
for _, ch := range []string{"canary/keymail", "edge"} {
w := signAs(t, sg, "kass", ch, "9f3c1ad")
- if _, err := verifyPointer(rootPub, w, "kass", ch); err != nil {
+ if _, err := verifyPointer(rootPub, w, "bab", "kass", ch); err != nil {
t.Errorf("in-scope channel %q did not verify: %v", ch, err)
}
}
@@ -153,7 +153,7 @@ func TestVerifyEnforcesGrantScope(t *testing.T) {
// promote path.
for _, ch := range []string{"beta", "stable"} {
w := forgePointerAs(t, sg, "kass", ch, "9f3c1ad", time.Now().UTC().Truncate(time.Second))
- _, err := verifyPointer(rootPub, w, "kass", ch)
+ _, err := verifyPointer(rootPub, w, "bab", "kass", ch)
if err == nil {
t.Fatalf("out-of-scope channel %q verified", ch)
}
@@ -217,13 +217,13 @@ func TestExpiryGatesPromotionNotAdoption(t *testing.T) {
// pointer that went dark on the grant's expiry date would take a running
// route's channel resolution with it.
w := forgePointerAs(t, sg, "kass", "edge", "9f3c1ad", time.Date(2025, 2, 1, 0, 0, 0, 0, time.UTC))
- if _, err := verifyPointer(rootPub, w, "kass", "edge"); err != nil {
+ if _, err := verifyPointer(rootPub, w, "bab", "kass", "edge"); err != nil {
t.Fatalf("a pointer promoted while the grant was valid stopped verifying: %v", err)
}
// Promoted one second after expiry: dead, and it says why.
late := forgePointerAs(t, sg, "kass", "edge", "9f3c1ad", g.ExpiresAt.Add(time.Second))
- _, err := verifyPointer(rootPub, late, "kass", "edge")
+ _, err := verifyPointer(rootPub, late, "bab", "kass", "edge")
if err == nil {
t.Fatal("a pointer promoted after the grant expired verified")
}
@@ -232,7 +232,7 @@ func TestExpiryGatesPromotionNotAdoption(t *testing.T) {
}
// And the promote side refuses to sign with it at all, now.
- if _, err := signPointer(sg, &ChannelPointer{App: "kass", Channel: "edge", Version: "9f3c1ad"}); err == nil {
+ if _, err := signPointer(sg, &ChannelPointer{Account: "bab", App: "kass", Channel: "edge", Version: "9f3c1ad"}); err == nil {
t.Fatal("an expired grant signed a new pointer")
}
}
@@ -263,7 +263,7 @@ func TestGrantChainFailuresAreDistinct(t *testing.T) {
// (1) grant signed by a key that is not the root.
forged := withGrant(t, good, forgeGrantSub(t, notRoot, *sg.scope))
- if _, err := verifyPointer(rootPub, forged, "kass", "edge"); err == nil {
+ if _, err := verifyPointer(rootPub, forged, "bab", "kass", "edge"); err == nil {
t.Error("a grant signed by a non-root key was honoured")
} else if !strings.Contains(err.Error(), "key grant signature") {
t.Errorf("non-root grant refusal = %q", err)
@@ -279,7 +279,7 @@ func TestGrantChainFailuresAreDistinct(t *testing.T) {
}
sub.Payload = base64.StdEncoding.EncodeToString(widerPayload)
tampered := withGrant(t, good, &sub)
- if _, err := verifyPointer(rootPub, tampered, "kass", "edge"); err == nil {
+ if _, err := verifyPointer(rootPub, tampered, "bab", "kass", "edge"); err == nil {
t.Error("a tampered grant payload was honoured")
} else if !strings.Contains(err.Error(), "key grant signature") {
t.Errorf("tampered grant refusal = %q", err)
@@ -294,7 +294,7 @@ func TestGrantChainFailuresAreDistinct(t *testing.T) {
t.Fatal(err)
}
mismatched := withGrant(t, good, otherSub)
- if _, err := verifyPointer(rootPub, mismatched, "kass", "edge"); err == nil {
+ if _, err := verifyPointer(rootPub, mismatched, "bab", "kass", "edge"); err == nil {
t.Error("a grant for a different key was honoured")
} else if !strings.Contains(err.Error(), "channel pointer signature") {
t.Errorf("mismatched-key refusal = %q, want it to name the POINTER signature", err)
@@ -355,7 +355,7 @@ func TestScopedPointerWithoutItsGrantIsRefused(t *testing.T) {
t.Fatal(err)
}
sw.Grant = nil
- if _, err := verifyPointer(rootPub, mustJSON(t, sw), "kass", "edge"); err == nil {
+ if _, err := verifyPointer(rootPub, mustJSON(t, sw), "bab", "kass", "edge"); err == nil {
t.Fatal("a scoped signature was accepted as a root signature")
}
}
@@ -368,13 +368,13 @@ func TestScopedPointerWithoutItsGrantIsRefused(t *testing.T) {
// fails; it is what makes "a caller forgot to check" not a vulnerability.
func TestSignPointerRefusesOutOfScope(t *testing.T) {
_, _, sg := scopedFixture(t, []string{RungCanary, RungEdge}, 30*24*time.Hour)
- if _, err := signPointer(sg, &ChannelPointer{App: "kass", Channel: "stable", Version: "9f3c1ad"}); err == nil {
+ if _, err := signPointer(sg, &ChannelPointer{Account: "bab", App: "kass", Channel: "stable", Version: "9f3c1ad"}); err == nil {
t.Fatal("a canary/edge signer signed stable")
}
- if _, err := signPointer(sg, &ChannelPointer{App: "kass", Channel: "beta", Version: "9f3c1ad"}); err == nil {
+ if _, err := signPointer(sg, &ChannelPointer{Account: "bab", App: "kass", Channel: "beta", Version: "9f3c1ad"}); err == nil {
t.Fatal("a canary/edge signer signed beta")
}
- if _, err := signPointer(sg, &ChannelPointer{App: "kass", Channel: "edge", Version: "9f3c1ad"}); err != nil {
+ if _, err := signPointer(sg, &ChannelPointer{Account: "bab", App: "kass", Channel: "edge", Version: "9f3c1ad"}); err != nil {
t.Fatalf("a canary/edge signer refused edge: %v", err)
}
}