From dc37b4f6d46ead5dced0110591cde860ee79a738 Mon Sep 17 00:00:00 2001 From: "@mrubens" <2600+mrubens@users.noreply.github.com> Date: Tue, 25 Aug 2026 21:46:09 +0000 Subject: [PATCH] fix: support encrypted Snowflake private keys --- .../mcp/__tests__/snowflake-auth.test.ts | 160 +++++++++++++++++- .../src/handlers/mcp/snowflake/connection.ts | 59 ++++++- apps/docs/integrations/snowflake.mdx | 12 ++ .../components/settings/Integrations.test.tsx | 3 + .../src/components/settings/Integrations.tsx | 1 + 5 files changed, 222 insertions(+), 13 deletions(-) diff --git a/apps/api/src/handlers/mcp/__tests__/snowflake-auth.test.ts b/apps/api/src/handlers/mcp/__tests__/snowflake-auth.test.ts index cc9a5eab2..a337240f5 100644 --- a/apps/api/src/handlers/mcp/__tests__/snowflake-auth.test.ts +++ b/apps/api/src/handlers/mcp/__tests__/snowflake-auth.test.ts @@ -1,3 +1,5 @@ +import { createPrivateKey, generateKeyPairSync } from 'node:crypto'; + import { Hono } from 'hono'; import type { RunTokenContext } from '@roomote/types'; @@ -75,6 +77,25 @@ vi.mock('snowflake-sdk', () => ({ import { db } from '@roomote/db/server'; import { snowflakeMcp } from '../snowflake'; +const PRIVATE_KEY_PASSPHRASE = 'test-pem-passphrase'; +const { privateKey: encryptedPrivateKey } = generateKeyPairSync('rsa', { + modulusLength: 2048, + privateKeyEncoding: { + type: 'pkcs8', + format: 'pem', + cipher: 'aes-256-cbc', + passphrase: PRIVATE_KEY_PASSPHRASE, + }, + publicKeyEncoding: { type: 'spki', format: 'pem' }, +}); +const unencryptedPrivateKey = createPrivateKey({ + key: encryptedPrivateKey, + format: 'pem', + passphrase: PRIVATE_KEY_PASSPHRASE, +}) + .export({ type: 'pkcs8', format: 'pem' }) + .toString(); + function createInitializeRequest(id: number) { return { jsonrpc: '2.0', @@ -309,9 +330,8 @@ describe('snowflake MCP auth and tool handling', () => { mockFindConnection.mockResolvedValue( mockConnectionRow({ encryptedPassword: 'enc:legacy-password', - encryptedPrivateKey: - 'enc:-----BEGIN PRIVATE KEY-----\\nabc\\n-----END PRIVATE KEY-----', - encryptedPrivateKeyPassphrase: 'enc:pem-passphrase', + encryptedPrivateKey: `enc:${encryptedPrivateKey}`, + encryptedPrivateKeyPassphrase: `enc:${PRIVATE_KEY_PASSPHRASE}`, }), ); @@ -334,17 +354,145 @@ describe('snowflake MCP auth and tool handling', () => { | undefined; expect(lastCreateConnectionCall).toBeDefined(); const [connectionConfig] = lastCreateConnectionCall ?? []; + if (!connectionConfig) { + throw new Error('Expected Snowflake connection options'); + } expect(connectionConfig).toEqual( expect.objectContaining({ account: 'xy12345.us-east-1', username: 'roomote', authenticator: 'SNOWFLAKE_JWT', - privateKey: - '-----BEGIN PRIVATE KEY-----\\nabc\\n-----END PRIVATE KEY-----', - privateKeyPass: 'pem-passphrase', + privateKey: expect.stringContaining('-----BEGIN PRIVATE KEY-----'), }), ); expect(connectionConfig).not.toHaveProperty('password'); + expect(connectionConfig).not.toHaveProperty('privateKeyPass'); + expect(connectionConfig.privateKey).not.toBe(encryptedPrivateKey); + expect(() => + createPrivateKey({ + key: connectionConfig.privateKey as string, + format: 'pem', + }), + ).not.toThrow(); + }); + + it('continues to accept unencrypted PKCS8 private keys', async () => { + mockFindConnection.mockResolvedValue( + mockConnectionRow({ + encryptedPassword: undefined, + encryptedPrivateKey: `enc:${unencryptedPrivateKey}`, + encryptedPrivateKeyPassphrase: undefined, + }), + ); + + const response = await postMcp( + createApp(createRunToken()), + createInitializeRequest(77), + ); + + expect(response.status).toBe(200); + expect(mockSnowflakeCreateConnection).not.toHaveBeenCalled(); + }); + + it('rejects an incorrect private key passphrase without exposing secrets', async () => { + mockFindConnection.mockResolvedValue( + mockConnectionRow({ + encryptedPassword: undefined, + encryptedPrivateKey: `enc:${encryptedPrivateKey}`, + encryptedPrivateKeyPassphrase: 'enc:wrong-secret-passphrase', + }), + ); + + const response = await postMcp( + createApp(createRunToken()), + createInitializeRequest(73), + ); + const body = await response.text(); + + expect(response.status).toBe(500); + expect(body).toContain('Snowflake private key or passphrase is invalid'); + expect(body).not.toContain('wrong-secret-passphrase'); + expect(body).not.toContain(encryptedPrivateKey); + expect(mockSnowflakeCreateConnection).not.toHaveBeenCalled(); + }); + + it('rejects non-RSA PKCS8 private keys', async () => { + const { privateKey: encryptedEcPrivateKey } = generateKeyPairSync('ec', { + namedCurve: 'P-256', + privateKeyEncoding: { + type: 'pkcs8', + format: 'pem', + cipher: 'aes-256-cbc', + passphrase: PRIVATE_KEY_PASSPHRASE, + }, + publicKeyEncoding: { type: 'spki', format: 'pem' }, + }); + mockFindConnection.mockResolvedValue( + mockConnectionRow({ + encryptedPassword: undefined, + encryptedPrivateKey: `enc:${encryptedEcPrivateKey}`, + encryptedPrivateKeyPassphrase: `enc:${PRIVATE_KEY_PASSPHRASE}`, + }), + ); + + const response = await postMcp( + createApp(createRunToken()), + createInitializeRequest(74), + ); + + expect(response.status).toBe(500); + await expect(response.json()).resolves.toMatchObject({ + error: { message: 'Snowflake private key or passphrase is invalid' }, + }); + expect(mockSnowflakeCreateConnection).not.toHaveBeenCalled(); + }); + + it('rejects RSA private keys smaller than 2048 bits', async () => { + const { privateKey: shortPrivateKey } = generateKeyPairSync('rsa', { + modulusLength: 1024, + privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, + publicKeyEncoding: { type: 'spki', format: 'pem' }, + }); + mockFindConnection.mockResolvedValue( + mockConnectionRow({ + encryptedPassword: undefined, + encryptedPrivateKey: `enc:${shortPrivateKey}`, + encryptedPrivateKeyPassphrase: undefined, + }), + ); + + const response = await postMcp( + createApp(createRunToken()), + createInitializeRequest(75), + ); + + expect(response.status).toBe(500); + await expect(response.json()).resolves.toMatchObject({ + error: { message: 'Snowflake private key or passphrase is invalid' }, + }); + expect(mockSnowflakeCreateConnection).not.toHaveBeenCalled(); + }); + + it('redacts Snowflake SDK connection errors', async () => { + mockSnowflakeConnect.mockImplementationOnce((callback) => { + callback?.(new Error(`Login failed for ${PRIVATE_KEY_PASSPHRASE}`)); + return {} as never; + }); + + const response = await postMcp(createApp(createRunToken()), { + jsonrpc: '2.0', + id: 76, + method: 'tools/call', + params: { + name: 'execute_sql', + arguments: { sql: 'SELECT 1 AS RESULT' }, + }, + }); + const body = await response.text(); + + expect(response.status).toBe(200); + expect(body).toContain('Snowflake connection failed'); + expect(body).not.toContain(PRIVATE_KEY_PASSPHRASE); }); it('returns normalized table descriptions', async () => { diff --git a/apps/api/src/handlers/mcp/snowflake/connection.ts b/apps/api/src/handlers/mcp/snowflake/connection.ts index 73b3cc598..1c0313d55 100644 --- a/apps/api/src/handlers/mcp/snowflake/connection.ts +++ b/apps/api/src/handlers/mcp/snowflake/connection.ts @@ -1,3 +1,5 @@ +import { createPrivateKey } from 'node:crypto'; + import { decrypt } from '@roomote/db/encryption'; import type { McpConnectionSnowflakeConfig } from '@roomote/types'; import snowflakeSdk from 'snowflake-sdk'; @@ -19,6 +21,41 @@ class SnowflakeConfigError extends Error { } } +function normalizePrivateKey( + privateKeyPem: string, + passphrase: string | undefined, +): string { + try { + const trimmedPrivateKey = privateKeyPem.trim(); + if ( + !/^-----BEGIN (?:ENCRYPTED )?PRIVATE KEY-----/.test(trimmedPrivateKey) + ) { + throw new Error('Unsupported private key format'); + } + + const privateKey = createPrivateKey({ + key: trimmedPrivateKey, + format: 'pem', + passphrase, + }); + const modulusLength = privateKey.asymmetricKeyDetails?.modulusLength; + + if ( + privateKey.asymmetricKeyType !== 'rsa' || + !modulusLength || + modulusLength < 2048 + ) { + throw new Error('Unsupported private key parameters'); + } + + return privateKey.export({ format: 'pem', type: 'pkcs8' }).toString(); + } catch { + throw new SnowflakeConfigError( + 'Snowflake private key or passphrase is invalid', + ); + } +} + function maybeDecryptSecret(value: string | undefined): string | undefined { if (!value) { return undefined; @@ -46,14 +83,17 @@ export function resolveSnowflakeConnectionConfig( ); } + const authentication = privateKey + ? { + authenticator: 'SNOWFLAKE_JWT' as const, + privateKey: normalizePrivateKey(privateKey, privateKeyPass), + } + : { password }; + return { account: config.account, username: config.username, - ...(privateKey - ? { authenticator: 'SNOWFLAKE_JWT' as const } - : { password }), - privateKey, - privateKeyPass, + ...authentication, role: config.role, ...(config.warehouse ? { warehouse: config.warehouse } : {}), database: config.database, @@ -110,9 +150,14 @@ export async function withSnowflakeConnection( config: ResolvedSnowflakeConnectionConfig, callback: (connection: Connection) => Promise, ): Promise { - const connection = snowflakeSdk.createConnection(config); + let connection: Connection; - await connect(connection); + try { + connection = snowflakeSdk.createConnection(config); + await connect(connection); + } catch { + throw new Error('Snowflake connection failed'); + } try { return await callback(connection); diff --git a/apps/docs/integrations/snowflake.mdx b/apps/docs/integrations/snowflake.mdx index dfc1bc6be..66492250f 100644 --- a/apps/docs/integrations/snowflake.mdx +++ b/apps/docs/integrations/snowflake.mdx @@ -20,6 +20,10 @@ identifier, username, role, and PKCS8 PEM-encoded private key. Add the matching public key to the Snowflake user first. Supply the private-key passphrase too when the key is encrypted. +Generate a dedicated encrypted RSA key on a secure operator machine. Keep the +private key out of shell arguments, repositories, chat, and logs. For example, +run `umask 077`, then use `openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:3072 -aes-256-cbc -out roomote_snowflake_key.p8` and enter the passphrase interactively. Export only the public key with `openssl pkey -in roomote_snowflake_key.p8 -pubout -out roomote_snowflake_key.pub`. + The `execute_sql` tool can run any statement permitted by the configured Snowflake role, including statements that change data or schema. Use a @@ -27,6 +31,14 @@ when the key is encrypted. warehouse context. +## Rotate from an existing credential + +1. Install the new public key in Snowflake's unused `RSA_PUBLIC_KEY_2` slot and verify its fingerprint before changing Roomote. +2. Enter the encrypted PKCS8 private key and passphrase in **Settings > Integrations > Snowflake**. Leave both fields blank on later edits to keep the stored key. +3. Run a Roomote task that calls `list_databases`, `list_schemas`, and `execute_sql` with `SELECT CURRENT_USER(), CURRENT_ROLE(), CURRENT_WAREHOUSE()`. Confirm the configured role can read only the intended data. +4. Review Roomote and Snowflake login logs for a successful JWT login without credential material. A saved connection is not proof that Snowflake accepted it. +5. After an observation window, revoke the previous password, programmatic access token, or public-key slot and verify a fresh Roomote task still connects. + ## What to expect Snowflake provides shared data warehouse context inside Roomote tasks. diff --git a/apps/web/src/components/settings/Integrations.test.tsx b/apps/web/src/components/settings/Integrations.test.tsx index 7565e3db4..e1f2322bb 100644 --- a/apps/web/src/components/settings/Integrations.test.tsx +++ b/apps/web/src/components/settings/Integrations.test.tsx @@ -2019,6 +2019,9 @@ describe('Integrations settings', () => { target: { value: 'pem-passphrase' }, }, ); + expect( + screen.getByLabelText('Private Key Passphrase (optional)'), + ).toHaveAttribute('type', 'password'); fireEvent.change(screen.getByLabelText('Role'), { target: { value: 'ANALYST' }, }); diff --git a/apps/web/src/components/settings/Integrations.tsx b/apps/web/src/components/settings/Integrations.tsx index f24b52882..71db3a97a 100644 --- a/apps/web/src/components/settings/Integrations.tsx +++ b/apps/web/src/components/settings/Integrations.tsx @@ -777,6 +777,7 @@ function SnowflakeConnectionFields({ onFieldChange('privateKeyPassphrase', event.target.value)