Merge branch 'tenant-iso-binding' into main
Bind release pointers and secret envelopes to their account; close relay deny-list gaps (#220)
38 files changed,
+1042
−147
accounts.go+46 −2apimode.go+18 −12apimode_test.go+46 −5appconfig_cli.go+47 −11cmd/console/main.go+21 −0config_e2e_test.go+1 −1e2e_test.go+13 −2internal/appconfig/configack.go+2 −2internal/appconfig/configack_test.go+1 −1internal/appconfig/crypto.go+161 −20internal/appconfig/crypto_bind_test.go+202 −0internal/appconfig/crypto_test.go+27 −9internal/appconfig/generation_test.go+1 −1internal/appconfig/instances_test.go+1 −1internal/appconfig/materialize.go+4 −3internal/appconfig/materialize_test.go+4 −4internal/appconfig/sync.go+8 −2internal/appconfig/sync_test.go+1 −1internal/appconfig/syncexec.go+2 −2internal/console/apiconfig.go+10 −0internal/console/apiconfig_test.go+31 −3internal/console/apistore_test.go+1 −1internal/console/relayops.go+1 −1internal/mail/walk.go+1 −1internal/mail/walk_enable_test.go+1 −1internal/maintenance/envelope_test.go+12 −5internal/objstore/walk.go+1 −1internal/objstore/walk_env_test.go+1 −1internal/objstore/walk_test.go+8 −8internal/relay/server.go+61 −9internal/relay/server_test.go+15 −1internal/release/accountbind_test.go+150 −0internal/release/golden_test.go+1 −1internal/release/qualified_test.go+2 −2internal/release/rebind.go+70 −0internal/release/release.go+52 −15internal/release/release_test.go+4 −4internal/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) |
| } |
| } |