diff --git a/CLAUDE.md b/CLAUDE.md index 4f991c5..7f8734a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -71,6 +71,17 @@ supports. PostgREST receives only public JWKS. PowerSync uses the Auth JWKS URL, audience `authenticated`, and disabled Supabase HMAC mode; it has no JWT secret environment variable. +`publishableKey` and `secretKey` use Supabase's canonical self-hosted opaque-key +format: their role-specific prefix is followed by exactly 22 unpadded +Base64URL random characters, a fixed underscore separator, and exactly 8 +unpadded Base64URL checksum characters (total lengths 46 and 41 respectively). +The checksum is the first 8 Base64URL characters of SHA-256 over the literal +`supabase-self-hosted|` context, matching +the pinned self-hosted v0.7.0 generation and rotation scripts. Validation is +strict with no legacy format. Existing projects must rotate both opaque keys +and their matching consumers before deploying the strict validator; this does +not rotate ES256 signing keys or invalidate sessions. + `auth.accessTokenExpirationSeconds` defaults to 3600. Security-owned GoTrue JWT environment names are rejected in `goTrueEnv`; provider-specific settings remain supported. diff --git a/README.md b/README.md index ab87faf..25b57f3 100644 --- a/README.md +++ b/README.md @@ -46,8 +46,8 @@ The Secret contains exactly these string fields: | Key | Meaning | | --- | --- | | `signingKeys` | JSON array containing exactly one signing-capable P-256 ES256 private JWK with a non-empty `kid` | -| `publishableKey` | Opaque `sb_publishable_*` client credential | -| `secretKey` | Opaque `sb_secret_*` backend credential, distinct from the publishable key | +| `publishableKey` | Canonical `sb_publishable_<22 Base64URL random>_<8 Base64URL checksum>` client credential (46 characters) | +| `secretKey` | Canonical `sb_secret_<22 Base64URL random>_<8 Base64URL checksum>` backend credential (41 characters), distinct from the publishable key | | `anonRoleJwt` | ES256 JWT for role `anon`, audience `authenticated` | | `serviceRoleJwt` | ES256 JWT for role `service_role`, audience `authenticated` | @@ -58,6 +58,30 @@ errors set `SecretsReady=False` without putting credential contents in status. The fallback value is used only by GoTrue; it is not part of the signing-key array or public JWKS. +### Canonical opaque API keys + +The opaque values are the self-hosted format used by Supabase. After the +role-specific prefix, each key has exactly 22 unpadded Base64URL characters, +one underscore separator at that fixed boundary, and exactly 8 unpadded +Base64URL checksum characters. The random segment may itself contain +underscores. The checksum is the first 8 Base64URL characters of SHA-256 over +`supabase-self-hosted|`. This literal +context and algorithm match the pinned Supabase self-hosted v0.7.0 scripts: +[`add-new-auth-keys.sh`](https://github.com/supabase/supabase/blob/self-hosted/v0.7.0/docker/utils/add-new-auth-keys.sh#L125-L139) +and +[`rotate-new-api-keys.sh`](https://github.com/supabase/supabase/blob/self-hosted/v0.7.0/docker/utils/rotate-new-api-keys.sh#L60-L76). +The format is also documented in Supabase's +[`self-hosted-auth-keys.mdx`](https://github.com/supabase/supabase/blob/master/apps/docs/content/guides/self-hosting/self-hosted-auth-keys.mdx#L78-L85). +The evidence and enforcement boundary are collected in +[`docs/research/supabase-self-hosted-opaque-api-keys.md`](docs/research/supabase-self-hosted-opaque-api-keys.md). + +The operator rejects noncanonical opaque values before reconciling dependent +workloads; there is no legacy or dual-format compatibility mode. Before +deploying an operator image with this strict validator, rotate both opaque +keys for every existing project and atomically update the matching client and +backend consumers. This rotation is independent of the ES256 signing key and +does not invalidate user sessions. + The operator does not integrate with Infisical. A typical deployment stores the five values at one Infisical project/environment path and uses the Infisical Kubernetes operator to synchronize them into the orphaned Secret @@ -223,4 +247,6 @@ There is no compatibility or hybrid mode. Replace manifests using and `gateway`, provision the five external credential fields, and plan a coordinated client cutover to opaque keys and ES256 sessions. Existing databases can be retained and readopted; callers must not expect old HS256 -tokens or Kong routes to continue working. +tokens or Kong routes to continue working. Existing projects with +noncanonical opaque keys must complete the two-key rotation and matching +consumer cutover before the strict operator image is rolled out. diff --git a/docs/research/supabase-self-hosted-opaque-api-keys.md b/docs/research/supabase-self-hosted-opaque-api-keys.md new file mode 100644 index 0000000..ad5364d --- /dev/null +++ b/docs/research/supabase-self-hosted-opaque-api-keys.md @@ -0,0 +1,79 @@ +# Supabase self-hosted opaque API keys + +## Question + +What exact `sb_publishable_*` and `sb_secret_*` format does current self-hosted +Supabase generate, what does its checksum cover, and which part of the stack +enforces it? + +This note records the primary-source evidence behind CloudNative Supabase's +project-credential validation. It is not a second configuration source. + +## Verified format + +Supabase documents these forms: + +```text +sb_publishable_<22-character-random>_<8-character-checksum> +sb_secret_<22-character-random>_<8-character-checksum> +``` + +Both variable segments use the unpadded Base64URL alphabet: ASCII letters, +digits, `-`, and `_`. The total lengths are 46 characters for a publishable key +and 41 for a secret key. + +The pinned self-hosted v0.7.0 scripts generate 17 cryptographically random +bytes, encode them as unpadded Base64URL, and take the first 22 characters. For +both key roles they then compute: + +```text +base64url(sha256("supabase-self-hosted|" + prefix + random))[0:8] +``` + +The prefix in that expression includes its trailing underscore. The final +underscore between the random and checksum segments sits at a fixed position; +the random segment may itself contain underscores. + +The checksum context is the literal `supabase-self-hosted`. It is not derived +from a Kubernetes namespace, `SupabaseProject` name, hostname, or a hosted +Supabase project reference. + +## Enforcement boundary + +The upstream self-hosted gateway does not recompute this checksum. Envoy +compares the supplied opaque key with its configured value exactly. The +checksum is therefore a canonical-format and typo-detection mechanism, not an +additional authorization primitive. + +CloudNative Supabase deliberately validates the canonical format and checksum +when it reads the external five-field project credential Secret. That catches +bad provisioning before dependent workloads change while leaving Envoy's +runtime exact-match semantics aligned with upstream. + +There is no legacy-format fallback. A project using a prefix-only, readable, +or otherwise noncanonical value must rotate both opaque keys and update its +matching client and backend consumers before deploying a strict operator +image. This rotation is independent of the ES256 signing key and does not by +itself invalidate user sessions. + +## Boundaries and unknowns + +Hosted Supabase uses the same visible key shape, but its platform-side issuance +implementation is not public. CloudNative Supabase follows the documented +self-hosted algorithm and does not infer hosted-project checksum inputs. + +This research does not change ES256 signing keys, internal role JWTs, JWKS, +GoTrue's compatibility secret, or Envoy authorization behavior. + +## Primary sources + +- Format and the explicit gateway checksum note: + [self-hosted-auth-keys.mdx](https://github.com/supabase/supabase/blob/master/apps/docs/content/guides/self-hosting/self-hosted-auth-keys.mdx#L78-L85) +- Pinned initial-generation implementation: + [self-hosted/v0.7.0 add-new-auth-keys.sh](https://github.com/supabase/supabase/blob/self-hosted/v0.7.0/docker/utils/add-new-auth-keys.sh#L125-L139) +- Pinned independent rotation implementation: + [self-hosted/v0.7.0 rotate-new-api-keys.sh](https://github.com/supabase/supabase/blob/self-hosted/v0.7.0/docker/utils/rotate-new-api-keys.sh#L60-L76) +- Envoy's exact-value comparison: + [lds.template.yaml](https://github.com/supabase/supabase/blob/master/docker/volumes/api/envoy/lds.template.yaml#L732-L751) +- Opaque API-key semantics: + [api-keys.mdx](https://github.com/supabase/supabase/blob/master/apps/docs/content/guides/getting-started/api-keys.mdx#L46-L57) diff --git a/internal/controller/modern_platform_test.go b/internal/controller/modern_platform_test.go index 7af658c..234325e 100644 --- a/internal/controller/modern_platform_test.go +++ b/internal/controller/modern_platform_test.go @@ -686,8 +686,8 @@ func TestInvalidCredentialRotationLeavesExistingWorkloadsUnchanged(t *testing.T) } invalidCredentials := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: project.Spec.ProjectCredentialsSecret, Namespace: project.Namespace}, Data: map[string][]byte{ secretresources.ProjectCredentialsSigningKeysKey: []byte("{"), - secretresources.ProjectCredentialsPublishableKey: []byte("sb_publishable_previous"), - secretresources.ProjectCredentialsSecretKey: []byte("sb_secret_previous"), + secretresources.ProjectCredentialsPublishableKey: []byte("sb_publishable_A1_b2-C3d4_E5f6-G7h8I9_oWzQ-j5j"), + secretresources.ProjectCredentialsSecretKey: []byte("sb_secret_Z9_y8-X7w6_V5u4-T3s2_R_6LqoZ8QA"), secretresources.ProjectCredentialsAnonRoleJWTKey: []byte("previous.anon.jwt"), secretresources.ProjectCredentialsServiceRoleJWTKey: []byte("previous.service.jwt"), }} @@ -879,8 +879,8 @@ func validProjectCredentialsSecret(t *testing.T, project *supabasev1alpha1.Supab ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: project.Namespace}, Data: map[string][]byte{ secretresources.ProjectCredentialsSigningKeysKey: []byte(keys), - secretresources.ProjectCredentialsPublishableKey: []byte("sb_publishable_fixture"), - secretresources.ProjectCredentialsSecretKey: []byte("sb_secret_fixture"), + secretresources.ProjectCredentialsPublishableKey: []byte("sb_publishable_A1_b2-C3d4_E5f6-G7h8I9_oWzQ-j5j"), + secretresources.ProjectCredentialsSecretKey: []byte("sb_secret_Z9_y8-X7w6_V5u4-T3s2_R_6LqoZ8QA"), secretresources.ProjectCredentialsAnonRoleJWTKey: []byte(sign("anon")), secretresources.ProjectCredentialsServiceRoleJWTKey: []byte(sign("service_role")), }, diff --git a/internal/resources/configmaps/envoy_test.go b/internal/resources/configmaps/envoy_test.go index d7ffe87..e302190 100644 --- a/internal/resources/configmaps/envoy_test.go +++ b/internal/resources/configmaps/envoy_test.go @@ -40,8 +40,8 @@ func TestRenderedEnvoyFixtureImplementsOpaqueGatewaySemantics(t *testing.T) { project := &supabasev1alpha1.SupabaseProject{ObjectMeta: metav1.ObjectMeta{Name: "fixture", Namespace: "default"}} config := BuildEnvoyConfigMap(project) values := map[string]string{ - "${SUPABASE_PUBLISHABLE_KEY}": "sb_publishable_fixture", - "${SUPABASE_SECRET_KEY}": "sb_secret_fixture", + "${SUPABASE_PUBLISHABLE_KEY}": "sb_publishable_A1_b2-C3d4_E5f6-G7h8I9_oWzQ-j5j", + "${SUPABASE_SECRET_KEY}": "sb_secret_Z9_y8-X7w6_V5u4-T3s2_R_6LqoZ8QA", "${SUPABASE_ANON_ROLE_JWT}": "eyJanon.fixture.token", "${SUPABASE_SERVICE_ROLE_JWT}": "eyJservice.fixture.token", } @@ -176,8 +176,8 @@ func TestRenderedEnvoyFixtureImplementsOpaqueGatewaySemantics(t *testing.T) { for _, test := range []struct { key, want string }{ - {"sb_publishable_fixture", "eyJanon.fixture.token"}, - {"sb_secret_fixture", "eyJservice.fixture.token"}, + {"sb_publishable_A1_b2-C3d4_E5f6-G7h8I9_oWzQ-j5j", "eyJanon.fixture.token"}, + {"sb_secret_Z9_y8-X7w6_V5u4-T3s2_R_6LqoZ8QA", "eyJservice.fixture.token"}, } { if got, ok := translateOpaqueFixture(test.key, values, true); !ok || got != test.want { t.Fatalf("opaque key %q translated to %q (ok=%v), want %q", test.key, got, ok, test.want) @@ -190,7 +190,7 @@ func TestRenderedEnvoyFixtureImplementsOpaqueGatewaySemantics(t *testing.T) { authorization, translated, want string }{ {"Bearer eyJuser.claims.signature", "eyJanon.fixture.token", "Bearer eyJuser.claims.signature"}, - {"Bearer sb_publishable_fixture", "eyJanon.fixture.token", "Bearer eyJanon.fixture.token"}, + {"Bearer sb_publishable_A1_b2-C3d4_E5f6-G7h8I9_oWzQ-j5j", "eyJanon.fixture.token", "Bearer eyJanon.fixture.token"}, {"Basic abc", "eyJservice.fixture.token", "Bearer eyJservice.fixture.token"}, {"", "eyJanon.fixture.token", "Bearer eyJanon.fixture.token"}, } { diff --git a/internal/resources/secrets/credentials_test.go b/internal/resources/secrets/credentials_test.go index 268e8c7..20b2007 100644 --- a/internal/resources/secrets/credentials_test.go +++ b/internal/resources/secrets/credentials_test.go @@ -15,6 +15,11 @@ import ( corev1 "k8s.io/api/core/v1" ) +const ( + fixturePublishableKey = "sb_publishable_A1_b2-C3d4_E5f6-G7h8I9_oWzQ-j5j" + fixtureSecretKey = "sb_secret_Z9_y8-X7w6_V5u4-T3s2_R_6LqoZ8QA" +) + func TestValidateProjectCredentials(t *testing.T) { key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) if err != nil { @@ -26,11 +31,14 @@ func TestValidateProjectCredentials(t *testing.T) { } bundle := map[string]string{ ProjectCredentialsSigningKeysKey: mustMarshal(t, []any{private}), - ProjectCredentialsPublishableKey: "sb_publishable_fixture", - ProjectCredentialsSecretKey: "sb_secret_fixture", + ProjectCredentialsPublishableKey: fixturePublishableKey, + ProjectCredentialsSecretKey: fixtureSecretKey, ProjectCredentialsAnonRoleJWTKey: signRole(t, key, "anon"), ProjectCredentialsServiceRoleJWTKey: signRole(t, key, "service_role"), } + if len(bundle[ProjectCredentialsPublishableKey]) != 46 || len(bundle[ProjectCredentialsSecretKey]) != 41 { + t.Fatalf("canonical fixture lengths = %d and %d, want 46 and 41", len(bundle[ProjectCredentialsPublishableKey]), len(bundle[ProjectCredentialsSecretKey])) + } projection, err := ValidateProjectCredentials(&corev1.Secret{Data: stringData(bundle)}) if err != nil { t.Fatalf("ValidateProjectCredentials() error = %v", err) @@ -74,8 +82,41 @@ func TestValidateProjectCredentials(t *testing.T) { "expired": func(values map[string]string) { values[ProjectCredentialsAnonRoleJWTKey] = signRoleWithClaims(t, key, "anon", "authenticated", "fixture", 1) }, - "wrong secret prefix": func(values map[string]string) { values[ProjectCredentialsSecretKey] = "sb_publishable_other" }, - "wrong publishable prefix": func(values map[string]string) { values[ProjectCredentialsPublishableKey] = "jwt_publishable_other" }, + "wrong secret prefix": func(values map[string]string) { + values[ProjectCredentialsSecretKey] = fixturePublishableKey + }, + "wrong publishable prefix": func(values map[string]string) { + values[ProjectCredentialsPublishableKey] = "jwt_publishable_A1_b2-C3d4_E5f6-G7h8I9_oWzQ-j5j" + }, + "short random segment": func(values map[string]string) { + values[ProjectCredentialsPublishableKey] = canonicalOpaqueFixture("sb_publishable_", "short") + }, + "long random segment": func(values map[string]string) { + values[ProjectCredentialsPublishableKey] = canonicalOpaqueFixture("sb_publishable_", "ABCDEFGHIJKLMNOPQRSTUVW") + }, + "short checksum": func(values map[string]string) { + values[ProjectCredentialsPublishableKey] = "sb_publishable_A1_b2-C3d4_E5f6-G7h8I9_oWzQ-j5" + }, + "long checksum": func(values map[string]string) { + values[ProjectCredentialsPublishableKey] = "sb_publishable_A1_b2-C3d4_E5f6-G7h8I9_oWzQ-j5jA" + }, + "invalid random character": func(values map[string]string) { + values[ProjectCredentialsPublishableKey] = canonicalOpaqueFixture("sb_publishable_", "A1_b2-C3d4_E5f6-G7h8I!") + }, + "invalid checksum character": func(values map[string]string) { + values[ProjectCredentialsPublishableKey] = "sb_publishable_A1_b2-C3d4_E5f6-G7h8I9_oWzQ-j!j" + }, + "misplaced fixed-boundary separator": func(values map[string]string) { + key := []byte(fixturePublishableKey) + key[len("sb_publishable_")+5] = '_' + key[len("sb_publishable_")+22] = 'A' + values[ProjectCredentialsPublishableKey] = string(key) + }, + "checksum mismatch": func(values map[string]string) { + key := []byte(fixturePublishableKey) + key[len(key)-1] = 'A' + values[ProjectCredentialsPublishableKey] = string(key) + }, } for _, field := range RequiredProjectCredentialKeys { mutations["missing "+field] = func(values map[string]string) { delete(values, field) } @@ -88,13 +129,19 @@ func TestValidateProjectCredentials(t *testing.T) { if err == nil { t.Fatal("invalid credential bundle was accepted") } - if strings.Contains(err.Error(), "sb_publishable_fixture") || strings.Contains(err.Error(), "sb_secret_fixture") || strings.Contains(err.Error(), "eyJ") { + if strings.Contains(err.Error(), fixturePublishableKey) || strings.Contains(err.Error(), fixtureSecretKey) || strings.Contains(err.Error(), "eyJ") { t.Fatalf("validation error exposed credential material: %v", err) } }) } } +func canonicalOpaqueFixture(prefix, random string) string { + digest := sha256.Sum256([]byte("supabase-self-hosted|" + prefix + random)) + checksum := base64.RawURLEncoding.EncodeToString(digest[:])[:8] + return prefix + random + "_" + checksum +} + func stringData(values map[string]string) map[string][]byte { result := make(map[string][]byte, len(values)) for key, value := range values { diff --git a/internal/resources/secrets/secrets.go b/internal/resources/secrets/secrets.go index faa74d1..5122c94 100644 --- a/internal/resources/secrets/secrets.go +++ b/internal/resources/secrets/secrets.go @@ -52,6 +52,12 @@ const ( const emailHookSecretBytes = 32 +const ( + opaqueKeyChecksumContext = "supabase-self-hosted" + opaqueKeyRandomLength = 22 + opaqueKeyChecksumLength = 8 +) + // ProjectCredentials is the validated, transient projection of the external // credential bundle. Secret values are never written to project status. type ProjectCredentials struct { @@ -170,19 +176,45 @@ func fieldError(field, reason string) error { } func validateOpaqueKey(value, prefix string) error { - if !strings.HasPrefix(value, prefix) || len(value) == len(prefix) { - return fmt.Errorf("must start with %s and include a non-empty suffix", prefix) - } - for _, r := range value[len(prefix):] { - isLetter := (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') - isDigit := r >= '0' && r <= '9' - if !isLetter && !isDigit && !strings.ContainsRune("._-", r) { - return fmt.Errorf("contains an invalid character") + if !strings.HasPrefix(value, prefix) { + return fmt.Errorf("must start with the canonical prefix") + } + + expectedLength := len(prefix) + opaqueKeyRandomLength + 1 + opaqueKeyChecksumLength + if len(value) != expectedLength { + return fmt.Errorf("must be exactly %d characters", expectedLength) + } + + randomStart := len(prefix) + randomEnd := randomStart + opaqueKeyRandomLength + if value[randomEnd] != '_' { + return fmt.Errorf("must contain a separator after the 22-character random segment") + } + for _, r := range value[randomStart:randomEnd] { + if !isBase64URLCharacter(r) { + return fmt.Errorf("random segment contains an invalid Base64URL character") + } + } + checksum := value[randomEnd+1:] + for _, r := range checksum { + if !isBase64URLCharacter(r) { + return fmt.Errorf("checksum contains an invalid Base64URL character") } } + + digest := sha256.Sum256([]byte(opaqueKeyChecksumContext + "|" + value[:randomEnd])) + expectedChecksum := base64.RawURLEncoding.EncodeToString(digest[:])[:opaqueKeyChecksumLength] + if checksum != expectedChecksum { + return fmt.Errorf("checksum does not match the canonical key") + } return nil } +func isBase64URLCharacter(r rune) bool { + return (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') || + (r >= '0' && r <= '9') || r == '-' || r == '_' +} + // GenerateGoTrueFallbackSecret creates the independent random fallback value. // It is intentionally not included in the project credential bundle. func GenerateGoTrueFallbackSecret(project *supabasev1alpha1.SupabaseProject) (*corev1.Secret, string, error) {