diff --git a/db/migrations/20260430022231_fix_connection_config_encryption.sql b/db/migrations/20260430022231_fix_connection_config_encryption.sql new file mode 100644 index 000000000..d50dfcf7b --- /dev/null +++ b/db/migrations/20260430022231_fix_connection_config_encryption.sql @@ -0,0 +1,69 @@ +-- migrate:up + +-- Fix connection-config encryption asymmetry in agent_connections.config. +-- +-- encryptConfig() in postgres-stores.ts historically returned raw +-- "iv:tag:ciphertext" output from @lobu/core's `encrypt()`, but +-- decryptConfig() only decrypts strings that start with "enc:v1:". So any +-- secret-named field that hit encryptConfig was stored as prefixless +-- ciphertext and round-tripped as that ciphertext literal on read. +-- +-- This migration backfills existing prefixless rows by re-prefixing them so +-- the now-aligned decryptConfig path can decrypt them. +-- +-- Identification: AES-GCM in @lobu/core uses a 12-byte IV (24 hex chars) +-- and a 16-byte auth tag (32 hex chars), joined with the ciphertext as +-- `iv:tag:ciphertext`. We match exactly that shape to avoid touching +-- arbitrary `:` separated values. +-- +-- Idempotent: jsonb_object_agg only rewrites string values that match the +-- prefixless shape AND lack the prefix. Re-running the migration is a noop. + +UPDATE public.agent_connections AS ac +SET config = sub.fixed_config +FROM ( + SELECT + id, + jsonb_object_agg( + key, + CASE + WHEN jsonb_typeof(value) = 'string' + AND value #>> '{}' ~ '^[0-9a-f]{24}:[0-9a-f]{32}:[0-9a-f]+$' + AND value #>> '{}' NOT LIKE 'enc:v1:%' + THEN to_jsonb('enc:v1:' || (value #>> '{}')) + ELSE value + END + ) AS fixed_config + FROM public.agent_connections, + LATERAL jsonb_each(config) + GROUP BY id +) AS sub +WHERE ac.id = sub.id + AND ac.config IS DISTINCT FROM sub.fixed_config; + +-- migrate:down + +-- Strip the "enc:v1:" prefix to restore the prefixless ciphertext shape. +-- Same regex: only touch strings whose remainder is `iv:tag:ciphertext`. + +UPDATE public.agent_connections AS ac +SET config = sub.fixed_config +FROM ( + SELECT + id, + jsonb_object_agg( + key, + CASE + WHEN jsonb_typeof(value) = 'string' + AND value #>> '{}' LIKE 'enc:v1:%' + AND substring(value #>> '{}' FROM 8) ~ '^[0-9a-f]{24}:[0-9a-f]{32}:[0-9a-f]+$' + THEN to_jsonb(substring(value #>> '{}' FROM 8)) + ELSE value + END + ) AS fixed_config + FROM public.agent_connections, + LATERAL jsonb_each(config) + GROUP BY id +) AS sub +WHERE ac.id = sub.id + AND ac.config IS DISTINCT FROM sub.fixed_config; diff --git a/packages/owletto-backend/src/lobu/stores/__tests__/postgres-stores.test.ts b/packages/owletto-backend/src/lobu/stores/__tests__/postgres-stores.test.ts new file mode 100644 index 000000000..247b6f637 --- /dev/null +++ b/packages/owletto-backend/src/lobu/stores/__tests__/postgres-stores.test.ts @@ -0,0 +1,97 @@ +/** + * Encrypt/decrypt round-trip tests for the connection-config helpers in + * postgres-stores.ts. Pins the fix for the prefix asymmetry: encrypt now + * tags ciphertext with `enc:v1:` and decrypt strips it before delegating + * to @lobu/core's AES-GCM `decrypt()`. + */ + +import { describe, expect, it } from 'vitest'; +import { encrypt } from '@lobu/core'; +import { decryptConfig, encryptConfig } from '../postgres-stores'; + +describe('postgres-stores connection-config encryption', () => { + it('round-trips secret fields through encrypt + decrypt', () => { + const original = { + platform: 'slack', + botToken: 'xoxb-real-secret-value', + signingSecret: 'shhhh', + allowGroups: true, + }; + + const encrypted = encryptConfig(original); + + // Secret fields are tagged with the version prefix and no longer match + // the plaintext. + expect(typeof encrypted.botToken).toBe('string'); + expect(encrypted.botToken).not.toBe(original.botToken); + expect(encrypted.botToken.startsWith('enc:v1:')).toBe(true); + expect(encrypted.signingSecret.startsWith('enc:v1:')).toBe(true); + + // Non-secret fields are untouched. + expect(encrypted.platform).toBe('slack'); + expect(encrypted.allowGroups).toBe(true); + + const decrypted = decryptConfig(encrypted); + + expect(decrypted).toEqual(original); + }); + + it('skips already-encrypted secret values on a second encryptConfig pass', () => { + const original = { token: 'plaintext-token' }; + const once = encryptConfig(original); + const twice = encryptConfig(once); + + // Idempotent: a second encryption pass leaves the already-prefixed + // ciphertext alone instead of double-encrypting. + expect(twice.token).toBe(once.token); + expect(decryptConfig(twice).token).toBe('plaintext-token'); + }); + + it('decryptConfig leaves prefixless values untouched (treated as plaintext)', () => { + // A bare `iv:tag:ciphertext` value (the legacy shape produced by the + // pre-fix encryptConfig) does NOT start with `enc:v1:`, so decryptConfig + // returns it as-is. The migration is what re-prefixes those rows; this + // assertion locks in the runtime contract that any non-prefixed string + // is treated as opaque plaintext. + const rawCipher = encrypt('would-be-plaintext'); + const result = decryptConfig({ token: rawCipher, platform: 'slack' }); + + expect(result.token).toBe(rawCipher); + expect(result.platform).toBe('slack'); + }); + + it('decryptConfig returns the original plaintext for prefixed values', () => { + const ciphertext = encrypt('super-secret'); + const result = decryptConfig({ token: `enc:v1:${ciphertext}` }); + + expect(result.token).toBe('super-secret'); + }); + + it('decryptConfig leaves an undecryptable prefixed value alone', () => { + // Garbage after the prefix shouldn't crash decryptConfig — the inner + // try/catch swallows the failure and the caller still gets a value + // back (the original prefixed string), matching the pre-fix contract. + const result = decryptConfig({ token: 'enc:v1:not-real-ciphertext' }); + expect(result.token).toBe('enc:v1:not-real-ciphertext'); + }); + + it('encryptConfig only touches secret-named fields', () => { + const input = { + platform: 'telegram', + // Not a secret-shaped key name — should pass through untouched. + label: 'team-prod', + // Secret-shaped names — should be encrypted. + botToken: 'tg-token', + apiKey: 'ak', + authorization: 'Bearer xyz', + }; + + const encrypted = encryptConfig(input); + + expect(encrypted.platform).toBe('telegram'); + expect(encrypted.label).toBe('team-prod'); + expect(encrypted.botToken.startsWith('enc:v1:')).toBe(true); + expect(encrypted.apiKey.startsWith('enc:v1:')).toBe(true); + expect(encrypted.authorization.startsWith('enc:v1:')).toBe(true); + }); +}); diff --git a/packages/owletto-backend/src/lobu/stores/postgres-stores.ts b/packages/owletto-backend/src/lobu/stores/postgres-stores.ts index 39b4728d5..34d859eb5 100644 --- a/packages/owletto-backend/src/lobu/stores/postgres-stores.ts +++ b/packages/owletto-backend/src/lobu/stores/postgres-stores.ts @@ -158,13 +158,15 @@ function isSecretField(key: string): boolean { return SECRET_PATTERN.test(key); } -function encryptConfig(config: Record): Record { +const ENC_PREFIX = 'enc:v1:'; + +export function encryptConfig(config: Record): Record { try { const { encrypt } = require('@lobu/core'); const result = { ...config }; for (const [key, value] of Object.entries(result)) { - if (isSecretField(key) && typeof value === 'string' && !value.startsWith('enc:v1:')) { - result[key] = encrypt(value); + if (isSecretField(key) && typeof value === 'string' && !value.startsWith(ENC_PREFIX)) { + result[key] = `${ENC_PREFIX}${encrypt(value)}`; } } return result; @@ -177,14 +179,14 @@ function isRedactedSecretValue(value: unknown): value is string { return typeof value === 'string' && value.startsWith('***'); } -function decryptConfig(config: Record): Record { +export function decryptConfig(config: Record): Record { try { const { decrypt } = require('@lobu/core'); const result = { ...config }; for (const [key, value] of Object.entries(result)) { - if (typeof value === 'string' && value.startsWith('enc:v1:')) { + if (typeof value === 'string' && value.startsWith(ENC_PREFIX)) { try { - result[key] = decrypt(value); + result[key] = decrypt(value.slice(ENC_PREFIX.length)); } catch { // Leave encrypted if decryption fails. }