From 515fbcfb453d24e7787b445e5397d008e73f951b Mon Sep 17 00:00:00 2001 From: jerryfane Date: Sun, 25 Oct 2026 15:11:35 +0100 Subject: [PATCH 1/2] Add non-destructive shared Workspace client secret overlay --- cmd/keyring/cloudflare_import.go | 44 ++++++++------ cmd/keyring/cloudflare_import_test.go | 82 +++++++++++++++++++++++++++ deploy/cloudflare/README.md | 46 ++++++++++++++- deploy/cloudflare/smoke/smoke.ts | 44 +++++++++++--- deploy/cloudflare/src/index.ts | 5 +- 5 files changed, 195 insertions(+), 26 deletions(-) diff --git a/cmd/keyring/cloudflare_import.go b/cmd/keyring/cloudflare_import.go index 4c39c87..47f3a68 100644 --- a/cmd/keyring/cloudflare_import.go +++ b/cmd/keyring/cloudflare_import.go @@ -22,8 +22,8 @@ const scopedClientBinding = "OAUTH_CLIENT_SECRETS_BY_INSTANCE" // This is an initial import, not a read/modify/write of an opaque Worker secret. // Cloudflare offers no compare-and-set here: the operator must exclude competing -// deployment/secret writers until the command finishes. An existing binding of -// any type is refused rather than risking loss of another instance's entries. +// deployment/secret writers until the command finishes. An existing destination +// binding of any type is refused rather than overwriting unknown entries. func runCloudflareClientImport(args []string, stdin io.Reader, stdout, stderr io.Writer) int { fs := flag.NewFlagSet("cloudflare-import-client", flag.ContinueOnError) fs.SetOutput(io.Discard) @@ -31,6 +31,7 @@ func runCloudflareClientImport(args []string, stdin io.Reader, stdout, stderr io account := fs.String("account", "", "confirmed Cloudflare account ID") worker := fs.String("worker", "", "confirmed existing Worker name") id := fs.String("keyring-id", "", "pilot ID confirmed from the authenticated person's canonical mapping") + shared := fs.Bool("shared", false, "initial shared-client overlay import instead of a per-instance binding") checkOnly := fs.Bool("check-only", false, "check destination and binding absence without reading a secret or writing") exclusive := fs.Bool("exclusive-writer", false, "operator has excluded other Worker deployment/secret writers during import") if err := fs.Parse(args); err != nil { @@ -47,8 +48,12 @@ func runCloudflareClientImport(args []string, stdin io.Reader, stdout, stderr io fmt.Fprintln(stderr, "cloudflare-import-client: "+message) return 1 } - if fs.NArg() != 0 || !lowerHexID(*account) || !lowerHexID(*id) || !workerName(*worker) { - return fail("pass confirmed --account, --worker and --keyring-id; no positional secret or file arguments") + if fs.NArg() != 0 || !lowerHexID(*account) || !workerName(*worker) || (*shared && *id != "") || (!*shared && !lowerHexID(*id)) { + return fail("pass confirmed --account, --worker and exactly one of --keyring-id or --shared; no positional secret or file arguments") + } + binding := scopedClientBinding + if *shared { + binding = "OAUTH_CLIENT_SECRETS_SHARED" } u, err := url.Parse(*base) if err != nil || u.Scheme != "http" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || u.RawPath != "" || !net.ParseIP(u.Hostname()).IsLoopback() || u.Path == "" || strings.Contains(u.Path, "..") { @@ -97,11 +102,11 @@ func runCloudflareClientImport(args []string, stdin io.Reader, stdout, stderr io if err != nil { return fail("could not verify existing Worker bindings; no write attempted") } - if _, exists := before[scopedClientBinding]; exists { - return fail("target binding already exists; refuse to overwrite unknown per-instance entries") + if _, exists := before[binding]; exists { + return fail("target binding already exists; refuse to overwrite unknown entries") } if *checkOnly { - return cloudflareImportReceipt(stdout, stderr, *account, *worker, *id, false) + return cloudflareImportReceipt(stdout, stderr, *account, *worker, *id, binding, false) } // Reuse the native Linux/Darwin private-pipe and bounded-input checks. Neither // the pipe contents nor provider response bodies are ever printed or saved. @@ -112,15 +117,20 @@ func runCloudflareClientImport(args []string, stdin io.Reader, stdout, stderr io if !utf8.ValidString(secret) || strings.TrimSpace(secret) != secret || strings.ContainsAny(secret, "\r\n\x00") { return fail("expected an exact UTF-8 client-secret value without surrounding whitespace or line breaks") } - value, err := json.Marshal(map[string]map[string]string{*id: {"GOOGLE_CLIENT_SECRET": secret}}) + var value []byte + if *shared { + value, err = json.Marshal(map[string]string{"GOOGLE_CLIENT_SECRET": secret}) + } else { + value, err = json.Marshal(map[string]map[string]string{*id: {"GOOGLE_CLIENT_SECRET": secret}}) + } if err != nil { - return fail("could not encode scoped secret") + return fail("could not encode client secret") } body, err := json.Marshal(struct { Name string `json:"name"` Type string `json:"type"` Text string `json:"text"` - }{scopedClientBinding, "secret_text", string(value)}) + }{binding, "secret_text", string(value)}) if err != nil { return fail("could not encode binding") } @@ -134,18 +144,18 @@ func runCloudflareClientImport(args []string, stdin io.Reader, stdout, stderr io Name string `json:"name"` Type string `json:"type"` } - if err := cloudflareImportRequest(client, http.MethodPut, root+"/secrets", body, &accepted); err != nil || accepted.Name != scopedClientBinding || accepted.Type != "secret_text" { + if err := cloudflareImportRequest(client, http.MethodPut, root+"/secrets", body, &accepted); err != nil || accepted.Name != binding || accepted.Type != "secret_text" { return fail("submission was not confirmed; binding state may have changed; do not retry blindly") } after, err := cloudflareBindings(client, root) - if err != nil || after[scopedClientBinding] != "secret_text" { + if err != nil || after[binding] != "secret_text" { return fail("write acknowledged but binding metadata verification failed; do not retry blindly") } - delete(after, scopedClientBinding) + delete(after, binding) if !sameBindings(before, after) { return fail("write acknowledged but unrelated binding metadata changed; stop and reconcile with the deployment owner") } - return cloudflareImportReceipt(stdout, stderr, *account, *worker, *id, true) + return cloudflareImportReceipt(stdout, stderr, *account, *worker, *id, binding, true) } func lowerHexID(s string) bool { @@ -243,17 +253,17 @@ func cloudflareImportRequest(client *http.Client, method, target string, body [] return nil } -func cloudflareImportReceipt(stdout, stderr io.Writer, account, worker, id string, written bool) int { +func cloudflareImportReceipt(stdout, stderr io.Writer, account, worker, id, binding string, written bool) int { receipt := struct { Account string `json:"account"` Worker string `json:"worker"` - KeyringID string `json:"keyring_id"` + KeyringID string `json:"keyring_id,omitempty"` Binding string `json:"binding"` Ready bool `json:"ready"` Written bool `json:"write_acknowledged"` ValueReadback bool `json:"value_readback"` RuntimeChecked bool `json:"runtime_import_verified"` - }{account, worker, id, scopedClientBinding, !written, written, false, false} + }{account, worker, id, binding, !written, written, false, false} if json.NewEncoder(stdout).Encode(receipt) != nil { fmt.Fprintln(stderr, "cloudflare-import-client: receipt output failed; inspect binding metadata before retrying") return 1 diff --git a/cmd/keyring/cloudflare_import_test.go b/cmd/keyring/cloudflare_import_test.go index 5495ba0..1b4b0ca 100644 --- a/cmd/keyring/cloudflare_import_test.go +++ b/cmd/keyring/cloudflare_import_test.go @@ -201,3 +201,85 @@ func TestCloudflareClientImport(t *testing.T) { }) } } + +func TestCloudflareSharedClientImport(t *testing.T) { + const binding = "OAUTH_CLIENT_SECRETS_SHARED" + const secret = "synthetic-new-shared-secret" + const global = `{"EXISTING_CLIENT":"synthetic-global"}` + const pilot = `{"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb":{"GOOGLE_CLIENT_SECRET":"synthetic-pilot"}}` + for _, mode := range []string{"import", "existing", "ambiguous-scope"} { + t.Run(mode, func(t *testing.T) { + var mu sync.Mutex + values := map[string]string{"OAUTH_CLIENT_SECRETS": global, scopedClientBinding: pilot} + if mode == "existing" { + values[binding] = `{"OTHER":"already-installed"}` + } + writes := 0 + api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + defer mu.Unlock() + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == "GET" && strings.HasSuffix(r.URL.Path, "/settings"): + bindings := make([]map[string]string, 0, len(values)) + for name := range values { + bindings = append(bindings, map[string]string{"name": name, "type": "secret_text"}) + } + json.NewEncoder(w).Encode(map[string]any{"success": true, "result": map[string]any{"bindings": bindings}}) + case r.Method == "PUT" && strings.HasSuffix(r.URL.Path, "/secrets"): + writes++ + var input struct{ Name, Type, Text string } + if json.NewDecoder(r.Body).Decode(&input) != nil || input.Name != binding || input.Type != "secret_text" { + w.WriteHeader(http.StatusBadRequest) + return + } + values[input.Name] = input.Text + json.NewEncoder(w).Encode(map[string]any{"success": true, "result": map[string]string{"name": input.Name, "type": input.Type}}) + default: + http.NotFound(w, r) + } + })) + defer api.Close() + args := []string{"cloudflare-import-client", "--relay-base", api.URL + "/operator/cloudflare/client/v4", "--account", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "--worker", "keyring-instances", "--shared", "--exclusive-writer"} + if mode == "ambiguous-scope" { + args = append(args, "--keyring-id", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb") + } + reader, writer, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + defer reader.Close() + if _, err := io.WriteString(writer, secret); err != nil { + writer.Close() + t.Fatal(err) + } + writer.Close() + var out, diagnostics bytes.Buffer + code := run(args, reader, &out, &diagnostics) + mu.Lock() + defer mu.Unlock() + if values["OAUTH_CLIENT_SECRETS"] != global || values[scopedClientBinding] != pilot { + t.Fatal("existing global or pilot secret map changed") + } + if strings.Contains(out.String()+diagnostics.String(), secret) { + t.Fatal("shared secret appeared in output") + } + if mode == "import" { + var imported map[string]string + if code != 0 || writes != 1 || json.Unmarshal([]byte(values[binding]), &imported) != nil || len(imported) != 1 || imported["GOOGLE_CLIENT_SECRET"] != secret { + t.Fatal("shared secret did not reach the independent overlay") + } + var receipt struct { + Binding string `json:"binding"` + KeyringID string `json:"keyring_id"` + Written bool `json:"write_acknowledged"` + } + if json.Unmarshal(out.Bytes(), &receipt) != nil || receipt.Binding != binding || receipt.KeyringID != "" || !receipt.Written { + t.Fatal("shared import receipt misidentifies its scope") + } + } else if code == 0 || writes != 0 || (mode == "existing" && values[binding] != `{"OTHER":"already-installed"}`) { + t.Fatal("unsafe shared import changed protected settings") + } + }) + } +} diff --git a/deploy/cloudflare/README.md b/deploy/cloudflare/README.md index 3e9baa1..964f982 100644 --- a/deploy/cloudflare/README.md +++ b/deploy/cloudflare/README.md @@ -52,7 +52,8 @@ Inputs: | `CALLER_SECRET` | secret | the router's `X-Keyring-Caller` value, one per Phobos server | | `ADMIN_TOKEN` | secret | the router's admin bearer token | | `OAUTH_CLIENT_SECRETS` | secret | JSON `{"GOOGLE_CLIENT_SECRET": "..."}`, pushed into every keyring | -| `OAUTH_CLIENT_SECRETS_BY_INSTANCE` | secret | JSON `{"keyring_id": {"GOOGLE_CLIENT_SECRET": "..."}}`, imported only by the named keyring; overrides matching global names there | +| `OAUTH_CLIENT_SECRETS_SHARED` | secret | Optional additive JSON map, pushed into every keyring; overrides matching global names without replacing the existing opaque global binding | +| `OAUTH_CLIENT_SECRETS_BY_INSTANCE` | secret | JSON `{"keyring_id": {"GOOGLE_CLIENT_SECRET": "..."}}`, imported only by the named keyring; overrides matching global and shared names there | Gmail and Calendar need an owner-approved Web application OAuth client with redirect URI `/connect/callback`; put the same client ID in both service entries. For a personal pilot, update only that person's existing instance, not the global template. The separate Drive connection below uses Google's own `gcloud` client and needs no Phobos-owned OAuth application. Do not publish a Google project, create shared credentials, or deploy the sample Workspace grants without the owner's approval. @@ -247,6 +248,49 @@ Compatible Worker/container deployment, instance startup/import and real-account acceptance are separate gates. No public receiver or additional permission is introduced by this command. +### Promoting a pilot client without replacing opaque bindings + +The merge order is global, shared, then per-instance. Use +`OAUTH_CLIENT_SECRETS_SHARED` when the existing global binding's contents cannot +be recovered safely. Do not overwrite an unknown global map or remove the pilot +override. An absent shared binding leaves existing behavior unchanged. + +With owner approval for the shared credential scope, the same importer accepts +`--shared` **instead of** `--keyring-id`. Preflight: + +```sh +keyring cloudflare-import-client \ + --relay-base "$CLOUDFLARE_RELAY_BASE" \ + --account "$CLOUDFLARE_ACCOUNT_ID" \ + --worker "$KEYRING_WORKER" \ + --shared --check-only +``` + +The authorized producer then invokes that command with `--exclusive-writer` +instead of `--check-only`, passing the exact secret through an anonymous pipe. +It creates only `{"GOOGLE_CLIENT_SECRET":"..."}` in the shared binding and refuses +any existing destination. The old global and per-instance bindings are untouched; +the same input, secrecy, no-retry and exclusive-writer requirements apply. +There is no arbitrary binding-name option. + +For a new shared Workspace rollout, keep both services' OAuth client, scopes, +refresh key and router access consistent with the approved pilot. Changing +`SERVICES_TEMPLATE` affects new instances only: repair approved existing instances +with the documented per-service PUT, preserving their other grants. Do not repair +or sign in the pilot as a rollout check. Confirm container rollout readiness and +non-pilot consent-link creation before handing off the real Gmail/Calendar check. + +If the original secret is unavailable, provisioning an additional active secret +on the same Google client requires its own applicable owner authorization; never +reset or revoke the pilot's secret as a shortcut. Importing the shared binding +changes future imports into all instances, not just the repaired instances. +Per-instance values still win, so a pilot retaining its override keeps that secret. +Rollback must preserve both original bindings and the pilot configuration. +Removing a binding does **not** erase a value already imported into a keyring; +reverting the Worker is not credential revocation. Coordinate rollback with the +deployment owner rather than deleting secrets or restoring whole old settings. + + ### Pilot authorization is not public-launch approval diff --git a/deploy/cloudflare/smoke/smoke.ts b/deploy/cloudflare/smoke/smoke.ts index 9c91dbb..fdbed59 100644 --- a/deploy/cloudflare/smoke/smoke.ts +++ b/deploy/cloudflare/smoke/smoke.ts @@ -159,7 +159,8 @@ const env = { MASTER_KEY: Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString("base64"), CALLER_SECRET: "caller-secret", ADMIN_TOKEN: "admin-token", - OAUTH_CLIENT_SECRETS: JSON.stringify({ GOOGLE_CLIENT_SECRET: "client-secret-value" }), + OAUTH_CLIENT_SECRETS: JSON.stringify({ GOOGLE_CLIENT_SECRET: "client-secret-value", OTHER_CLIENT_SECRET: "client-secret-value" }), + OAUTH_CLIENT_SECRETS_SHARED: "", OAUTH_CLIENT_SECRETS_BY_INSTANCE: "{}", // Passed as a string when SMOKE_TEMPLATE_AS_STRING is set, as `wrangler deploy --var` does. SERVICES_TEMPLATE: ((t: object) => (process.env.SMOKE_TEMPLATE_AS_STRING ? JSON.stringify(t) : t))({ @@ -427,11 +428,12 @@ try { port: 0, async fetch(req) { const path = new URL(req.url).pathname; - if (req.method !== "POST" || !["/pilot/token", "/global/token"].includes(path)) return new Response("", { status: 404 }); + if (req.method !== "POST" || !["/pilot/token", "/global/token", "/shared/token"].includes(path)) return new Response("", { status: 404 }); const form = await req.formData(); - const pilot = path === "/pilot/token"; - if (form.get("client_id") !== (pilot ? "pilot-client" : "global-client") || - form.get("client_secret") !== (pilot ? "synthetic-owner-only-secret" : "client-secret-value")) { + const mode = path.split("/")[1]; + const expectedSecret = mode === "pilot" ? "synthetic-owner-only-secret" : mode === "shared" ? "synthetic-shared-secret" : "client-secret-value"; + if (form.get("client_id") !== `${mode}-client` || + form.get("client_secret") !== expectedSecret) { return Response.json({ error: "invalid_client" }, { status: 400 }); } return Response.json({ refresh_token: "synthetic-refresh", access_token: "synthetic-access", token_type: "Bearer", expires_in: 3600 }); @@ -445,13 +447,13 @@ try { await containers.get(person.keyring_id)!.destroy(); objects.delete(person.keyring_id); } - const configure = async (id: string, mode: "pilot" | "global") => { + const configure = async (id: string, mode: "pilot" | "global" | "shared") => { const response = await call("PUT", `/admin/instances/${id}/services/gmail`, ADMIN, JSON.stringify({ service: { base: `http://127.0.0.1:${api.port}`, key: "GMAIL_REFRESH", auth: "oauth2", oauth2: { token_url: `http://127.0.0.1:${provider.port}/${mode}/token`, - client_id: `${mode}-client`, client_secret_key: "GOOGLE_CLIENT_SECRET", + client_id: `${mode}-client`, client_secret_key: mode === "global" ? "OTHER_CLIENT_SECRET" : "GOOGLE_CLIENT_SECRET", auth_url: "https://accounts.example.com/auth", scopes: ["read"], }, }, @@ -482,6 +484,33 @@ try { await configure(other.keyring_id, "pilot"); expect("unselected keyring cannot consent with the pilot client", (await connect(other.keyring_id)).status, 502); expect("unselected keyring cannot reuse its old grant as the pilot", (await read(other)).status, 503); + // Add a shared client without reading/replacing either existing opaque map. + env.OAUTH_CLIENT_SECRETS_SHARED = JSON.stringify({ GOOGLE_CLIENT_SECRET: "synthetic-shared-secret" }); + for (const person of [pilot, other]) { + await containers.get(person.keyring_id)!.destroy(); + objects.delete(person.keyring_id); + } + await configure(other.keyring_id, "shared"); + expect("shared overlay enables an existing non-pilot keyring", (await connect(other.keyring_id)).status, 200); + expect("shared overlay grant can reach its upstream", (await read(other)).status, 200); + expect("pilot override still wins over the shared client", (await read(pilot)).status, 200); + await configure(other.keyring_id, "global"); + expect("unrelated global client survives shared overlay", (await connect(other.keyring_id)).status, 200); + const fresh = await (await call("POST", "/admin/instances", ADMIN, JSON.stringify({ label: "shared-fresh" }))).json() as typeof pilot; + await configure(fresh.keyring_id, "shared"); + expect("new keyring receives shared client", (await connect(fresh.keyring_id)).status, 200); + await containers.get(fresh.keyring_id)!.destroy(); + objects.delete(fresh.keyring_id); + expect("shared grant survives sealed cold restore", (await read(fresh)).status, 200); + env.OAUTH_CLIENT_SECRETS_SHARED = "synthetic-shared-secret-not-json"; + await containers.get(fresh.keyring_id)!.destroy(); + objects.delete(fresh.keyring_id); + const sharedFailure = await call("GET", `/admin/instances/${fresh.keyring_id}/services`, ADMIN); + expect("malformed shared settings fail without leaking input", + [sharedFailure.status, (await sharedFailure.text()).includes("synthetic-shared-secret")], [502, false]); + env.OAUTH_CLIENT_SECRETS_SHARED = JSON.stringify({ GOOGLE_CLIENT_SECRET: "synthetic-shared-secret" }); + objects.delete(fresh.keyring_id); + await call("DELETE", `/admin/instances/${fresh.keyring_id}`, ADMIN); const savedSettings = env.OAUTH_CLIENT_SECRETS_BY_INSTANCE; env.OAUTH_CLIENT_SECRETS_BY_INSTANCE = "synthetic-private-secret-not-json"; await containers.get(pilot.keyring_id)!.destroy(); @@ -496,6 +525,7 @@ try { } finally { env.OAUTH_CLIENT_SECRETS_BY_INSTANCE = "{}"; provider.stop(true); + env.OAUTH_CLIENT_SECRETS_SHARED = ""; } } diff --git a/deploy/cloudflare/src/index.ts b/deploy/cloudflare/src/index.ts index 0d0c325..e2e9bfb 100644 --- a/deploy/cloudflare/src/index.ts +++ b/deploy/cloudflare/src/index.ts @@ -31,6 +31,8 @@ export interface Env { ADMIN_TOKEN: string; /** Secret (optional): JSON {"KEY_NAME": "value"}, OAuth client secrets put in every instance. */ OAUTH_CLIENT_SECRETS?: string; + /** Optional additive JSON map; shared clients override globals but never per-instance entries. */ + OAUTH_CLIENT_SECRETS_SHARED?: string; /** Secret (optional): JSON {"keyring_id": {"KEY_NAME": "value"}}; selected values override globals only in that instance. */ OAUTH_CLIENT_SECRETS_BY_INSTANCE?: string; } @@ -459,8 +461,9 @@ export class KeyringInstance extends DurableObject { let secrets: Record; try { const global = this.env.OAUTH_CLIENT_SECRETS ? JSON.parse(this.env.OAUTH_CLIENT_SECRETS) : {}; + const shared = this.env.OAUTH_CLIENT_SECRETS_SHARED ? JSON.parse(this.env.OAUTH_CLIENT_SECRETS_SHARED) : {}; const scoped = JSON.parse(this.env.OAUTH_CLIENT_SECRETS_BY_INSTANCE || "{}"); - secrets = { ...global, ...scoped[m.id] }; + secrets = { ...global, ...shared, ...scoped[m.id] }; } catch { // JSON parse errors can quote the input: never expose secret text. throw new Error("OAuth client-secret settings must be valid JSON objects"); From 2dc8eeee5eb3fee254cd8ca95ed1754c5ee17d4b Mon Sep 17 00:00:00 2001 From: jerryfane Date: Sun, 25 Oct 2026 15:23:29 +0100 Subject: [PATCH 2/2] Document shared OAuth binding in deployment configuration --- deploy/cloudflare/wrangler.jsonc | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/deploy/cloudflare/wrangler.jsonc b/deploy/cloudflare/wrangler.jsonc index ecbf32b..ab0d876 100644 --- a/deploy/cloudflare/wrangler.jsonc +++ b/deploy/cloudflare/wrangler.jsonc @@ -86,5 +86,6 @@ } } // Secrets: MASTER_KEY, CALLER_SECRET, ADMIN_TOKEN; approved OAuth clients use - // OAUTH_CLIENT_SECRETS (global) or OAUTH_CLIENT_SECRETS_BY_INSTANCE (targeted). + // OAUTH_CLIENT_SECRETS (global), OAUTH_CLIENT_SECRETS_SHARED (additive shared), + // or OAUTH_CLIENT_SECRETS_BY_INSTANCE (targeted; highest precedence). }