Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 15 additions & 12 deletions .gitleaks.toml
Original file line number Diff line number Diff line change
@@ -1,29 +1,32 @@
# Gitleaks configuration — extends the default ruleset.
#
# The only allowlisted secrets are PUBLIC key material committed in
# src/config/environments.ts:
# - the two Supabase anon keys — anon-role JWTs, designed to be embedded in
# client code and gated by RLS (see the doc comment in that file).
# - the two KEK public keys (prod + dev) for client-side binary envelope
# encryption — base64 of the raw 32-byte X25519 PUBLIC half. They can only
# *encrypt*; the private halves live solely on the API, never in this repo.
# Both are intentionally not secret; gitleaks flags them on entropy alone
# src/config/environments.ts: the two KEK public keys (prod + dev) for
# client-side binary envelope encryption — base64 of the raw 32-byte X25519
# PUBLIC half. They can only *encrypt*; the private halves live solely on the
# API, never in this repo. gitleaks flags them on entropy alone
# (generic-api-key), not because it recognizes them as credentials.
#
# The two legacy Supabase anon keys (prod + dev) are also listed, but only
# because they remain in git history and CI scans the full history. The source
# no longer contains them: the CLI fetches its publishable key from the API's
# GET /cli-config at runtime (dcd#1112). Do not add a Supabase key back.
#
# They are allowlisted by EXACT VALUE, deliberately not by file path or by the
# whole `jwt` / `generic-api-key` rule: a Supabase service_role key is also a
# JWT, and a KEK *private* key is the same shape as its public half, so a
# path/rule allowlist would let a genuinely sensitive key pasted into the same
# file slip through. Matching exact values keeps that detection intact.
# whole `generic-api-key` rule: a KEK *private* key is the same shape as its
# public half, so a path/rule allowlist would let a genuinely sensitive key
# pasted into the same file slip through. Matching exact values keeps that
# detection intact.
#
# When a KEK is rotated, replace the corresponding value below — do not simply
# append, or the retired key stops being distinguishable from a live secret.
[extend]
useDefault = true

[allowlist]
description = "Public Supabase anon keys and KEK public keys (safe to commit)"
description = "KEK public keys, and retired Supabase anon keys left in history (safe)"
regexes = [
# retired legacy anon keys (prod, dev), history only
'''eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9\.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InBneWRucGhiaW1ldGluc2dma2JvIiwicm9sZSI6ImFub24iLCJpYXQiOjE3MDc1OTQzNDYsImV4cCI6MjAyMzE3MDM0Nn0\.hAYOMFxxwX1exkQkY9xyQJGC_GhGnyogkj2N-kBkMI8''',
'''eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9\.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImxibXNvd2VodGp3bnFsdXJwZW1iIiwicm9sZSI6ImFub24iLCJpYXQiOjE3MDkyMTg0ODcsImV4cCI6MjAyNDc5NDQ4N30\.zeLTMAuZ_WwYvGdeP0kdvL_Zrs-RQee5APPyxmWq7qQ''',
# prod kekPublicKey v1
Expand Down
2 changes: 1 addition & 1 deletion .husky/pre-commit
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
#!/usr/bin/env sh
# Scan staged changes for secrets before they ever enter git history.
# Mirrors the gitleaks CI gate (.github/workflows/cli-ci.yml) and shares the
# same allowlist (.gitleaks.toml), so the public Supabase anon keys won't trip.
# same allowlist (.gitleaks.toml), so the public KEK keys won't trip.
#
# gitleaks is NOT a dependency of this package — contributors install the binary
# themselves. If it's missing we skip (exit 0) rather than block: CI is the
Expand Down
18 changes: 10 additions & 8 deletions src/commands/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,13 @@ import { defineCommand } from 'citty';
import { spawn } from 'node:child_process';
import { createHash, randomBytes } from 'node:crypto';

import { ENVIRONMENTS, inferEnvFromApiUrl, resolveFrontendUrl } from '../config/environments.js';
import {
type DcdEnvName,
ENVIRONMENTS,
inferEnvFromApiUrl,
resolveFrontendUrl,
} from '../config/environments.js';
import { getSupabaseKey } from '../config/supabase-key.js';
import { jsonFlag } from '../config/flags/output.flags.js';
import { canPrompt } from '../utils/ci.js';
import { CliError, logger } from '../utils/cli.js';
Expand Down Expand Up @@ -200,11 +206,7 @@ export const loginCommand = defineCommand({
// rejects it, the token itself is bad; if Supabase accepts, the
// api's JwtService is misconfigured.
const iss = decodeJwtIssuer(payload.access_token);
const supabaseUser = await probeSupabaseUser(
iss,
ENVIRONMENTS[env].supabase.anonKey,
payload.access_token,
);
const supabaseUser = await probeSupabaseUser(iss, env, payload.access_token);
const parts = [
iss ? `token iss: ${iss}` : null,
`supabase /auth/v1/user: ${supabaseUser}`,
Expand Down Expand Up @@ -335,15 +337,15 @@ function base64url(buf: Buffer): string {

async function probeSupabaseUser(
iss: string | null,
anonKey: string,
env: DcdEnvName,
token: string,
): Promise<string> {
if (!iss) return 'skipped (no iss)';
try {
const res = await fetch(`${iss}/user`, {
headers: {
authorization: `Bearer ${token}`,
apikey: anonKey,
apikey: await getSupabaseKey(env),
},
});
const bodyText = await res.text().catch(() => '');
Expand Down
4 changes: 1 addition & 3 deletions src/commands/logout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
*/
import { defineCommand } from 'citty';

import { ENVIRONMENTS } from '../config/environments.js';
import { jsonFlag } from '../config/flags/output.flags.js';
import { CliAuthGateway } from '../gateways/cli-auth-gateway.js';
import { logger } from '../utils/cli.js';
Expand Down Expand Up @@ -33,8 +32,7 @@ export const logoutCommand = defineCommand({
return;
}

const { anonKey } = ENVIRONMENTS[config.env].supabase;
await CliAuthGateway.signOut(config.supabase_url, anonKey, config.session);
await CliAuthGateway.signOut(config.supabase_url, config.env, config.session);
clearConfig();
if (json) {
logger.json({ status: 'SUCCESS', loggedOut: true, user: config.session.user_email });
Expand Down
19 changes: 6 additions & 13 deletions src/config/environments.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,10 @@
/**
* Per-env devicecloud.dev coordinates — single source of truth for the URLs
* and Supabase credentials the CLI uses.
* the CLI uses.
*
* **These `anonKey` values are public.** Supabase anon keys are JWTs designed
* to be embedded in client code (web apps, mobile apps, CLIs). They only
* grant whatever the project's RLS policies expose. Don't worry about seeing
* them here — every Supabase-based product ships its anon key the same way.
* https://supabase.com/docs/guides/api/api-keys
* There is deliberately no Supabase key here. A compiled-in key can never be
* rotated without breaking every pinned CLI version, so it is fetched from the
* API at runtime instead: see `getSupabaseKey` in ./supabase-key.ts (dcd#1112).
*/

export type DcdEnvName = 'dev' | 'prod';
Expand All @@ -17,13 +15,12 @@ export interface DcdEnvironment {
supabase: {
url: string;
projectRef: string;
anonKey: string;
};
/**
* Pinned KEK public key for client-side binary envelope encryption (dcd#1138).
* `key` is base64 of the raw 32-byte X25519 public key; `version` selects which
* KEK the platform API unwraps with. **Public — safe to embed** (like the anon
* key above): it can only *encrypt*; the private half lives solely on the API.
* KEK the platform API unwraps with. **Public — safe to embed**: it can only
* *encrypt*; the private half lives solely on the API.
*
* Left `null` until the KEK is generated and provisioned. Generate a keypair
* with, e.g.:
Expand All @@ -42,8 +39,6 @@ export const ENVIRONMENTS: Record<DcdEnvName, DcdEnvironment> = {
supabase: {
url: 'https://cloud.devicecloud.dev',
projectRef: 'pgydnphbimetinsgfkbo',
anonKey:
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InBneWRucGhiaW1ldGluc2dma2JvIiwicm9sZSI6ImFub24iLCJpYXQiOjE3MDc1OTQzNDYsImV4cCI6MjAyMzE3MDM0Nn0.hAYOMFxxwX1exkQkY9xyQJGC_GhGnyogkj2N-kBkMI8',
},
kekPublicKey: { version: 1, key: 'wtfyWEwK7nJzwI4PD+9RAW8jxIR1u8kMQq2IhsrVnH4=' },
},
Expand All @@ -53,8 +48,6 @@ export const ENVIRONMENTS: Record<DcdEnvName, DcdEnvironment> = {
supabase: {
url: 'https://lbmsowehtjwnqlurpemb.supabase.co',
projectRef: 'lbmsowehtjwnqlurpemb',
anonKey:
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImxibXNvd2VodGp3bnFsdXJwZW1iIiwicm9sZSI6ImFub24iLCJpYXQiOjE3MDkyMTg0ODcsImV4cCI6MjAyNDc5NDQ4N30.zeLTMAuZ_WwYvGdeP0kdvL_Zrs-RQee5APPyxmWq7qQ',
},
kekPublicKey: { version: 1, key: 'RgcToF/OJpcQI9koYvSvtj/WLaebfcN4v5GJoqtr/00=' },
},
Expand Down
60 changes: 60 additions & 0 deletions src/config/supabase-key.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/**
* The Supabase publishable key, fetched from the dcd API at runtime (dcd#1112).
*
* Releases up to this one compiled the anon key in, so the key could never be
* rotated without breaking every CLI version pinned in customer CI. The API now
* serves it from the public `GET /cli-config`, and rotating it is an env change
* on the API.
*
* Fetched once per process and kept in memory only. It is deliberately not
* cached on disk: a stale key makes GoTrue answer a refresh with a 4xx, which
* `resolveAuth` treats as a dead session and deletes the user's login.
*
* Only the key comes from the API. The Supabase URLs stay pinned in
* `ENVIRONMENTS`: prod goes through the `cloud.devicecloud.dev` custom domain,
* which customer firewalls may allow-list, not the API's raw `supabase.co` URL.
*/
import { ENVIRONMENTS, type DcdEnvName } from './environments.js';

const FETCH_TIMEOUT_MS = 10_000;

const cache = new Map<DcdEnvName, Promise<string>>();

async function fetchSupabaseKey(env: DcdEnvName): Promise<string> {
const url = `${ENVIRONMENTS[env].apiUrl}/cli-config`;
const res = await fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
if (!res.ok) {
throw new Error(`Failed to fetch CLI config from ${url}: HTTP ${res.status}`);
}
const body = (await res.json()) as { supabase_publishable_key?: unknown };
const key = body.supabase_publishable_key;
if (typeof key !== 'string' || key.length === 0) {
throw new Error(`CLI config from ${url} has no supabase_publishable_key`);
}
return key;
}

/** The publishable key for `env`. A failed fetch is not cached, so the next call retries. */
export function getSupabaseKey(env: DcdEnvName): Promise<string> {
let pending = cache.get(env);
if (!pending) {
pending = fetchSupabaseKey(env);
cache.set(env, pending);
pending.catch(() => cache.delete(env));
}
return pending;
}

/**
* Headers that authenticate a raw Storage/Auth request with `key` alone. A
* legacy anon key is a JWT and also goes in `Authorization`; an
* `sb_publishable_` key is not a JWT and must only be sent as `apikey`.
*/
export function supabaseKeyHeaders(key: string): Record<string, string> {
return key.startsWith('sb_') ? { apikey: key } : { apikey: key, authorization: `Bearer ${key}` };
}

/** Test seam: forget fetched keys. */
export function resetSupabaseKeyCache(): void {
cache.clear();
}
25 changes: 19 additions & 6 deletions src/gateways/cli-auth-gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
*/
import { createClient, isAuthApiError } from '@supabase/supabase-js';

import type { DcdEnvName } from '../config/environments.js';
import { getSupabaseKey } from '../config/supabase-key.js';
import type { StoredSession } from '../utils/config-store.js';

/**
Expand Down Expand Up @@ -34,19 +36,30 @@ export interface RefreshedSession {
user_id: string;
}

function client(supabaseUrl: string, supabaseAnonKey: string) {
return createClient(supabaseUrl, supabaseAnonKey, {
function client(supabaseUrl: string, supabaseKey: string) {
return createClient(supabaseUrl, supabaseKey, {
auth: { persistSession: false, autoRefreshToken: false, detectSessionInUrl: false },
});
}

export const CliAuthGateway = {
async refresh(
supabaseUrl: string,
supabaseAnonKey: string,
env: DcdEnvName,
session: StoredSession,
): Promise<RefreshedSession> {
const sb = client(supabaseUrl, supabaseAnonKey);
let supabaseKey: string;
try {
supabaseKey = await getSupabaseKey(env);
} catch (error) {
// Never definitive: the refresh token was not even tried, so the stored
// session must survive for the next attempt.
throw new SessionRefreshError(
`Failed to refresh session: ${(error as Error).message}`,
false,
);
}
const sb = client(supabaseUrl, supabaseKey);
const { data, error } = await sb.auth.refreshSession({
refresh_token: session.refresh_token,
});
Expand Down Expand Up @@ -78,16 +91,16 @@ export const CliAuthGateway = {

async signOut(
supabaseUrl: string,
supabaseAnonKey: string,
env: DcdEnvName,
session: StoredSession,
): Promise<void> {
// `scope: 'local'` clears this client's SDK state only — no server call,
// no revocation. supabase-js defaults to `scope: 'global'`, which revokes
// EVERY session for the user (browser, mobile, other CLIs), so
// `dcd logout` would kick the user out of the web app. We don't want
// that — logging out locally should be local.
const sb = client(supabaseUrl, supabaseAnonKey);
try {
const sb = client(supabaseUrl, await getSupabaseKey(env));
await sb.auth.setSession({
access_token: session.access_token,
refresh_token: session.refresh_token,
Expand Down
Loading
Loading