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
4 changes: 2 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ ASSET_PROFILES_ENABLED=true
# SPC_CREDENTIAL_ENCRYPTION_KEY below when enabled.
PRIVATE_CHANNELS_ENABLED=false
SDP_FLAG_ASSET_PROFILES=true
PRIVY_BYOK_PROVISIONING_ENABLED=false
PRIVY_BYOK_ENABLED=false

# ─── Authentication (Clerk) — REQUIRED ───────────────────────────────────────
# Get these from https://dashboard.clerk.com → API Keys.
Expand All @@ -54,7 +54,7 @@ CLERK_JWT_TEMPLATE=sdp-api
# Generate each with: openssl rand -hex 32
# API_KEY_PEPPER hashes API keys before storage; without it keys use no pepper.
# CREDENTIAL_FINGERPRINT_PEPPER signs stored-credential request fingerprints;
# it is required when PRIVY_BYOK_PROVISIONING_ENABLED=true and must stay stable.
# it is required when PRIVY_BYOK_ENABLED=true and must stay stable.
# CUSTODY_ENCRYPTION_KEY encrypts org private keys in DB; without it keys are
# stored in plaintext.
# CUSTODY_KMS_KEY_NAME switches custody encryption to Cloud KMS envelope
Expand Down
2 changes: 1 addition & 1 deletion apps/sdp-api/.env.local.example
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ FEE_PAYMENT_PROVIDER=kora

# Privy platform signer (required for privy custody provider)
# SIGNING_PROVIDER=privy
# PRIVY_BYOK_PROVISIONING_ENABLED=false
# PRIVY_BYOK_ENABLED=false
# CREDENTIAL_FINGERPRINT_PEPPER=<openssl rand -hex 32> # required when Privy BYOK is enabled
# PRIVY_APP_ID=YOUR_PRIVY_APP_ID
# PRIVY_APP_SECRET=YOUR_PRIVY_APP_SECRET
Expand Down
173 changes: 173 additions & 0 deletions apps/sdp-api/src/db/migrations/custody-connection-ownership.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { getDb } from "@/db";
import { env } from "@/test/helpers/env";
import { clearTestDatabase, seedTestDatabase } from "@/test/mocks/db";

const ORGANIZATION_ID = "org_custody_connection_constraints";
const PROJECT_ID = "prj_custody_connection_constraints";
const USER_ID = "usr_custody_connection_constraints";
const CONFIG_ID = "cust_custody_connection_constraints";

async function seedScope(): Promise<void> {
const db = getDb(env);
await db.batch([
db
.prepare(
`INSERT INTO organizations (id, name, slug, tier, status)
VALUES (?, 'Custody connection constraints', ?, 'individual', 'active')`
)
.bind(ORGANIZATION_ID, "custody-connection-constraints"),
db
.prepare(
`INSERT INTO users (id, email, email_verified, status)
VALUES (?, 'custody-connection-constraints@example.com', 1, 'active')`
)
.bind(USER_ID),
db
.prepare(
`INSERT INTO projects
(id, organization_id, name, slug, environment, status, created_by)
VALUES (?, ?, 'Custody connection constraints', ?, 'sandbox', 'active', ?)`
)
.bind(PROJECT_ID, ORGANIZATION_ID, "custody-connection-constraints", USER_ID),
db
.prepare(
`INSERT INTO custody_configs (
id, organization_id, project_id, provider, config_encrypted,
encryption_version, status
) VALUES (?, ?, ?, 'privy', 'legacy', 'test', 'active')`
)
.bind(CONFIG_ID, ORGANIZATION_ID, PROJECT_ID),
]);
}

async function insertCredential(
id: string,
status = "pending",
ciphertext: string | null = "secret"
): Promise<void> {
await getDb(env)
.prepare(
`INSERT INTO provider_credentials (
id, organization_id, project_id, provider, label, scope, source,
storage_backend, encrypted_secret_payload, status, created_by
) VALUES (?, ?, ?, 'privy', 'Privy', 'project', 'stored',
'encrypted_db', ?, ?, ?)`
)
.bind(id, ORGANIZATION_ID, PROJECT_ID, ciphertext, status, USER_ID)
.run();
}

async function insertConnection(id: string, credentialId: string): Promise<void> {
await getDb(env)
.prepare(
`INSERT INTO custody_connections (
id, organization_id, project_id, provider, scope,
provider_credential_id, provider_credential_scope_key, status, created_by
) VALUES (?, ?, ?, 'privy', 'project', ?, ?, 'pending', ?)`
)
.bind(id, ORGANIZATION_ID, PROJECT_ID, credentialId, PROJECT_ID, USER_ID)
.run();
}

async function insertConnectionWallet(id: string, connectionId: string): Promise<void> {
await getDb(env)
.prepare(
`INSERT INTO custody_wallets (
id, custody_connection_id, wallet_id, public_key, status
) VALUES (?, ?, ?, ?, 'active')`
)
.bind(id, connectionId, `provider_${id}`, `public_${id}`)
.run();
}

describe("custody Connection ownership constraints", () => {
beforeEach(async () => {
await seedTestDatabase(env);
await seedScope();
});

afterEach(async () => {
await clearTestDatabase(env);
});

it("requires every wallet to have exactly one Config or Connection owner", async () => {
await insertCredential("pcred_wallet_owner");
await insertConnection("cconn_wallet_owner", "pcred_wallet_owner");
await insertConnectionWallet("cwlt_connection_owner", "cconn_wallet_owner");
await getDb(env)
.prepare(
`INSERT INTO custody_wallets (
id, custody_config_id, wallet_id, public_key, status
) VALUES ('cwlt_config_owner', ?, 'provider_config_owner', 'public_config_owner', 'active')`
)
.bind(CONFIG_ID)
.run();

await expect(
getDb(env)
.prepare(
`INSERT INTO custody_wallets (
id, custody_config_id, custody_connection_id, wallet_id, public_key
) VALUES (
'cwlt_two_owners', ?, 'cconn_wallet_owner', 'provider_two_owners', 'public_two_owners'
)`
)
.bind(CONFIG_ID)
.run()
).rejects.toThrow(/custody_wallets_exactly_one_owner/);

await expect(
getDb(env)
.prepare(
`INSERT INTO custody_wallets (id, wallet_id, public_key)
VALUES ('cwlt_no_owner', 'provider_no_owner', 'public_no_owner')`
)
.run()
).rejects.toThrow(/custody_wallets_exactly_one_owner/);
});

it("allows a Connection to default only to one of its own wallets", async () => {
await insertCredential("pcred_default_one");
await insertCredential("pcred_default_two");
await insertConnection("cconn_default_one", "pcred_default_one");
await insertConnection("cconn_default_two", "pcred_default_two");
await insertConnectionWallet("cwlt_default_one", "cconn_default_one");
await insertConnectionWallet("cwlt_default_two", "cconn_default_two");

await getDb(env)
.prepare(
`UPDATE custody_connections
SET default_custody_wallet_id = 'cwlt_default_one'
WHERE id = 'cconn_default_one'`
)
.run();

await expect(
getDb(env)
.prepare(
`UPDATE custody_connections
SET default_custody_wallet_id = 'cwlt_default_two'
WHERE id = 'cconn_default_one'`
)
.run()
).rejects.toThrow(/custody_connections_default_wallet_owner_fkey/);

await expect(
getDb(env).prepare("DELETE FROM custody_connections WHERE id = 'cconn_default_one'").run()
).rejects.toThrow(/custody_wallets_connection_fkey/);
expect(
await getDb(env)
.prepare("SELECT id FROM custody_wallets WHERE id = 'cwlt_default_one'")
.first()
).not.toBeNull();
});

it("allows failed encrypted credentials to retain metadata without ciphertext", async () => {
await insertCredential("pcred_failed_without_secret", "failed_validation", null);

await expect(insertCredential("pcred_pending_without_secret", "pending", null)).rejects.toThrow(
/provider_credentials_secret_location_check/
);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
ALTER TABLE custody_wallets
ADD COLUMN IF NOT EXISTS custody_connection_id TEXT,
ALTER COLUMN custody_config_id DROP NOT NULL;

ALTER TABLE custody_wallets
ADD CONSTRAINT custody_wallets_connection_fkey
FOREIGN KEY (custody_connection_id)
REFERENCES custody_connections(id)
ON DELETE RESTRICT,
ADD CONSTRAINT custody_wallets_exactly_one_owner
CHECK ((custody_config_id IS NOT NULL) <> (custody_connection_id IS NOT NULL)),
ADD CONSTRAINT custody_wallets_id_connection_unique
UNIQUE (id, custody_connection_id),
ADD CONSTRAINT custody_wallets_connection_wallet_unique
UNIQUE (custody_connection_id, wallet_id);

ALTER TABLE custody_connections
DROP CONSTRAINT IF EXISTS custody_connections_default_custody_wallet_id_fkey;

ALTER TABLE custody_connections
ADD CONSTRAINT custody_connections_default_wallet_owner_fkey
FOREIGN KEY (default_custody_wallet_id, id)
REFERENCES custody_wallets(id, custody_connection_id)
ON DELETE SET NULL (default_custody_wallet_id);

ALTER TABLE provider_credentials
DROP CONSTRAINT IF EXISTS provider_credentials_secret_location_check;

ALTER TABLE provider_credentials
ADD CONSTRAINT provider_credentials_secret_location_check
CHECK (
(
source = 'runtime'
AND storage_backend = 'runtime_env'
AND secret_ref IS NULL
AND secret_version_ref IS NULL
AND encrypted_secret_payload IS NULL
)
OR (
source = 'stored'
AND storage_backend = 'gcp_secret_manager'
AND secret_ref IS NOT NULL
AND encrypted_secret_payload IS NULL
)
OR (
source = 'stored'
AND storage_backend = 'encrypted_db'
AND secret_ref IS NULL
AND (
encrypted_secret_payload IS NOT NULL
OR status = 'failed_validation'
)
)
);
12 changes: 6 additions & 6 deletions apps/sdp-api/src/lib/feature-flags.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
import {
isAssetProfilesEnabled,
isPrivateChannelsEnabled,
isPrivyByokProvisioningEnabled,
isPrivyByokEnabled,
} from "./feature-flags";

describe("isAssetProfilesEnabled", () => {
Expand Down Expand Up @@ -71,19 +71,19 @@ describe("isPrivateChannelsEnabled", () => {
});
});

describe("isPrivyByokProvisioningEnabled", () => {
describe("isPrivyByokEnabled", () => {
it.each([undefined, "", "false", "0", "off"])("is disabled when the flag is %s", (flag) => {
expect(
isPrivyByokProvisioningEnabled({
PRIVY_BYOK_PROVISIONING_ENABLED: flag,
isPrivyByokEnabled({
PRIVY_BYOK_ENABLED: flag,
})
).toBe(false);
});

it.each(["1", "true", " TRUE ", "yes", "on"])("honors the opt-in value %s", (flag) => {
expect(
isPrivyByokProvisioningEnabled({
PRIVY_BYOK_PROVISIONING_ENABLED: flag,
isPrivyByokEnabled({
PRIVY_BYOK_ENABLED: flag,
})
).toBe(true);
});
Expand Down
6 changes: 2 additions & 4 deletions apps/sdp-api/src/lib/feature-flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,6 @@ export function isPrivateChannelsEnabled(env: Pick<Env, "PRIVATE_CHANNELS_ENABLE
return isTruthyFlag(env.PRIVATE_CHANNELS_ENABLED);
}

export function isPrivyByokProvisioningEnabled(
env: Pick<Env, "PRIVY_BYOK_PROVISIONING_ENABLED">
): boolean {
return isTruthyFlag(env.PRIVY_BYOK_PROVISIONING_ENABLED);
export function isPrivyByokEnabled(env: Pick<Env, "PRIVY_BYOK_ENABLED">): boolean {
return isTruthyFlag(env.PRIVY_BYOK_ENABLED);
}
10 changes: 5 additions & 5 deletions apps/sdp-api/src/routes/custody-privy-byok-admission.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ async function seedBlockingConnection(): Promise<void> {

describe("legacy Privy setup admission", () => {
const original = {
flag: env.PRIVY_BYOK_PROVISIONING_ENABLED,
flag: env.PRIVY_BYOK_ENABLED,
appId: env.PRIVY_APP_ID,
appSecret: env.PRIVY_APP_SECRET,
encryptionKey: env.CUSTODY_ENCRYPTION_KEY,
Expand All @@ -142,13 +142,13 @@ describe("legacy Privy setup admission", () => {
await seedTestDatabase(env);
await clearKVStores(env);
await seedActor();
env.PRIVY_BYOK_PROVISIONING_ENABLED = "true";
env.PRIVY_BYOK_ENABLED = "true";
env.PRIVY_APP_ID = undefined;
env.PRIVY_APP_SECRET = undefined;
});

afterEach(async () => {
env.PRIVY_BYOK_PROVISIONING_ENABLED = original.flag;
env.PRIVY_BYOK_ENABLED = original.flag;
env.PRIVY_APP_ID = original.appId;
env.PRIVY_APP_SECRET = original.appSecret;
env.CUSTODY_ENCRYPTION_KEY = original.encryptionKey;
Expand Down Expand Up @@ -195,7 +195,7 @@ describe("legacy Privy setup admission", () => {
"initialize",
"switch",
] as const)("returns the stored-connection conflict from /%s even after flag rollback", async (path) => {
env.PRIVY_BYOK_PROVISIONING_ENABLED = "false";
env.PRIVY_BYOK_ENABLED = "false";
await seedBlockingConnection();

const response = await request(path);
Expand Down Expand Up @@ -265,7 +265,7 @@ describe("legacy Privy setup admission", () => {
});

it("keeps fresh legacy initialization when stored setup is disabled", async () => {
env.PRIVY_BYOK_PROVISIONING_ENABLED = "false";
env.PRIVY_BYOK_ENABLED = "false";
env.PRIVY_APP_ID = "legacy-app-id";
env.PRIVY_APP_SECRET = "legacy-app-secret";
env.CUSTODY_ENCRYPTION_KEY = "BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc=";
Expand Down
4 changes: 2 additions & 2 deletions apps/sdp-api/src/routes/custody/handlers/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { SigningError } from "@sdp/custody/signing";
import { z } from "zod";
import { getDb } from "@/db";
import { AppError, badRequest, conflict, forbidden } from "@/lib/errors";
import { isPrivyByokProvisioningEnabled } from "@/lib/feature-flags";
import { isPrivyByokEnabled } from "@/lib/feature-flags";
import { created, success } from "@/lib/response";
import { getRequestTenantScope } from "@/lib/tenant-scope";
import { clearWalletCaches } from "@/routes/custody/handlers/wallets";
Expand Down Expand Up @@ -388,7 +388,7 @@ async function assertFreshPrivyLegacySetupAllowed(
}

const availability = await getProviderAvailability(c.env, getDb(c.env), organizationId);
if (availability.providers.custody.privy.entitled && isPrivyByokProvisioningEnabled(c.env)) {
if (availability.providers.custody.privy.entitled && isPrivyByokEnabled(c.env)) {
throw forbidden("New Privy setup must use stored credentials");
}
}
Expand Down
8 changes: 7 additions & 1 deletion apps/sdp-api/src/routes/internal-custody/index.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
import { Hono } from "hono";
import { z } from "zod";
import { badRequest } from "@/lib/errors";
import { created } from "@/lib/response";
import { created, success } from "@/lib/response";
import { credentialAdminAuthMiddleware } from "@/middleware/credential-admin-auth";
import { idempotencyKeyMiddleware } from "@/middleware/idempotency-key";
import { projectContextMiddleware } from "@/middleware/project-context";
import { checkProviderCredential } from "@/services/provider-credential-check.service";
import { submitProviderCredential } from "@/services/provider-credential-submission.service";
import type { Env } from "@/types/env";

const privyCredentialSubmissionSchema = z
.object({
provider: z.literal("privy"),
walletLabel: z.string().trim().min(1).max(100).optional(),
fields: z
.object({
credentialLabel: z.string().trim().min(1),
Expand Down Expand Up @@ -46,4 +48,8 @@ internalCustody.post("/provider-credentials", async (c) => {
return created(c, result);
});

internalCustody.post("/provider-credentials/:providerCredentialId/check", async (c) => {
return success(c, await checkProviderCredential(c, c.req.param("providerCredentialId")));
});

export default internalCustody;
Loading
Loading