diff --git a/.gitignore b/.gitignore index 73b355351..f5cda658e 100644 --- a/.gitignore +++ b/.gitignore @@ -113,3 +113,4 @@ playwright-report/ electron-builder-appx.yaml test-results/ +data/ diff --git a/e2e/tests/sync/supersync-encryption.spec.ts b/e2e/tests/sync/supersync-encryption.spec.ts index be27671b1..114f50b5a 100644 --- a/e2e/tests/sync/supersync-encryption.spec.ts +++ b/e2e/tests/sync/supersync-encryption.spec.ts @@ -60,7 +60,7 @@ base.describe('@supersync SuperSync Encryption', () => { testInfo.skip(!serverHealthy, 'SuperSync server not running'); }); - base.skip( + base( 'Encrypted data syncs correctly with valid password', async ({ browser, baseURL }, testInfo) => { const testRunId = generateTestRunId(testInfo.workerIndex); @@ -107,7 +107,7 @@ base.describe('@supersync SuperSync Encryption', () => { }, ); - base.skip( + base( 'Encrypted data fails to sync with wrong password', async ({ browser, baseURL }, testInfo) => { const testRunId = generateTestRunId(testInfo.workerIndex); diff --git a/packages/super-sync-server/prisma/migrations/20251212000000_add_is_payload_encrypted/migration.sql b/packages/super-sync-server/prisma/migrations/20251212000000_add_is_payload_encrypted/migration.sql new file mode 100644 index 000000000..3130d3298 --- /dev/null +++ b/packages/super-sync-server/prisma/migrations/20251212000000_add_is_payload_encrypted/migration.sql @@ -0,0 +1,2 @@ +-- Add is_payload_encrypted column to operations table +ALTER TABLE "operations" ADD COLUMN "is_payload_encrypted" BOOLEAN NOT NULL DEFAULT false; diff --git a/packages/super-sync-server/prisma/schema.prisma b/packages/super-sync-server/prisma/schema.prisma index 394b08b1f..66659e097 100644 --- a/packages/super-sync-server/prisma/schema.prisma +++ b/packages/super-sync-server/prisma/schema.prisma @@ -34,20 +34,21 @@ model User { } model Operation { - id String @id - userId Int @map("user_id") - clientId String @map("client_id") - serverSeq Int @map("server_seq") - actionType String @map("action_type") - opType String @map("op_type") - entityType String @map("entity_type") - entityId String? @map("entity_id") - payload Json - vectorClock Json @map("vector_clock") - schemaVersion Int @map("schema_version") - clientTimestamp BigInt @map("client_timestamp") - receivedAt BigInt @map("received_at") - parentOpId String? @map("parent_op_id") + id String @id + userId Int @map("user_id") + clientId String @map("client_id") + serverSeq Int @map("server_seq") + actionType String @map("action_type") + opType String @map("op_type") + entityType String @map("entity_type") + entityId String? @map("entity_id") + payload Json + vectorClock Json @map("vector_clock") + schemaVersion Int @map("schema_version") + clientTimestamp BigInt @map("client_timestamp") + receivedAt BigInt @map("received_at") + parentOpId String? @map("parent_op_id") + isPayloadEncrypted Boolean @default(false) @map("is_payload_encrypted") user User @relation(fields: [userId], references: [id], onDelete: Cascade) diff --git a/packages/super-sync-server/src/sync/sync.routes.ts b/packages/super-sync-server/src/sync/sync.routes.ts index ac8a20813..c8eb97a29 100644 --- a/packages/super-sync-server/src/sync/sync.routes.ts +++ b/packages/super-sync-server/src/sync/sync.routes.ts @@ -44,6 +44,7 @@ const OperationSchema = z.object({ timestamp: z.number(), schemaVersion: z.number(), parentOpId: z.string().max(255).optional(), // For conflict resolution chain tracking + isPayloadEncrypted: z.boolean().optional(), // True if payload is E2E encrypted }); const UploadOpsSchema = z.object({ diff --git a/packages/super-sync-server/src/sync/sync.service.ts b/packages/super-sync-server/src/sync/sync.service.ts index c610606cd..25eaa3197 100644 --- a/packages/super-sync-server/src/sync/sync.service.ts +++ b/packages/super-sync-server/src/sync/sync.service.ts @@ -323,7 +323,6 @@ export class SyncService { }); const serverSeq = updatedState.lastSeq; - // Insert operation await tx.operation.create({ data: { id: op.id, @@ -340,6 +339,7 @@ export class SyncService { clientTimestamp: BigInt(op.timestamp), receivedAt: BigInt(now), parentOpId: op.parentOpId ?? null, + isPayloadEncrypted: op.isPayloadEncrypted ?? false, }, }); @@ -452,6 +452,7 @@ export class SyncService { schemaVersion: row.schemaVersion, timestamp: Number(row.clientTimestamp), parentOpId: row.parentOpId ?? undefined, + isPayloadEncrypted: row.isPayloadEncrypted, }, receivedAt: Number(row.receivedAt), })); @@ -544,6 +545,7 @@ export class SyncService { schemaVersion: row.schemaVersion, timestamp: Number(row.clientTimestamp), parentOpId: row.parentOpId ?? undefined, + isPayloadEncrypted: row.isPayloadEncrypted, }, receivedAt: Number(row.receivedAt), })); diff --git a/packages/super-sync-server/src/sync/sync.types.ts b/packages/super-sync-server/src/sync/sync.types.ts index 971be8a49..a4d96642e 100644 --- a/packages/super-sync-server/src/sync/sync.types.ts +++ b/packages/super-sync-server/src/sync/sync.types.ts @@ -145,6 +145,7 @@ export interface Operation { timestamp: number; schemaVersion: number; parentOpId?: string; // For conflict resolution chain tracking + isPayloadEncrypted?: boolean; // True if payload is E2E encrypted } export interface ServerOperation { diff --git a/src/app/core/persistence/operation-log/operation-converter.util.ts b/src/app/core/persistence/operation-log/operation-converter.util.ts index f4f462aa9..7d3f6d229 100644 --- a/src/app/core/persistence/operation-log/operation-converter.util.ts +++ b/src/app/core/persistence/operation-log/operation-converter.util.ts @@ -30,11 +30,26 @@ const FULL_STATE_OP_TYPES = new Set([ * Handles both multi-entity payloads (new format) and legacy payloads. */ const extractActionPayload = (payload: unknown): Record => { + // Debug logging for encryption issues + console.log( + '[DEBUG extractActionPayload] payload type:', + typeof payload, + 'isMultiEntity:', + isMultiEntityPayload(payload), + ); if (isMultiEntityPayload(payload)) { + console.log( + '[DEBUG extractActionPayload] actionPayload:', + JSON.stringify(payload.actionPayload)?.substring(0, 500), + ); // Multi-entity payload: extract the original action payload return payload.actionPayload; } // Legacy format: payload is directly the action payload + console.log( + '[DEBUG extractActionPayload] legacy payload:', + JSON.stringify(payload)?.substring(0, 500), + ); return payload as Record; }; diff --git a/src/app/core/persistence/operation-log/sync/operation-encryption.service.ts b/src/app/core/persistence/operation-log/sync/operation-encryption.service.ts index 99b28c2e9..e07dccdb6 100644 --- a/src/app/core/persistence/operation-log/sync/operation-encryption.service.ts +++ b/src/app/core/persistence/operation-log/sync/operation-encryption.service.ts @@ -40,9 +40,10 @@ export class OperationEncryptionService { } try { const decryptedStr = await decrypt(op.payload, encryptKey); + const parsedPayload = JSON.parse(decryptedStr); return { ...op, - payload: JSON.parse(decryptedStr), + payload: parsedPayload, isPayloadEncrypted: false, }; } catch (e) { diff --git a/src/app/features/config/form-cfgs/sync-form.const.ts b/src/app/features/config/form-cfgs/sync-form.const.ts index 1e9457ce4..a829bc81a 100644 --- a/src/app/features/config/form-cfgs/sync-form.const.ts +++ b/src/app/features/config/form-cfgs/sync-form.const.ts @@ -280,7 +280,10 @@ export const SYNC_FORM: ConfigFormSection = { label: T.F.SYNC.FORM.L_ENABLE_COMPRESSION, }, }, + // Hide for SuperSync since it has dedicated E2E encryption fields above { + hideExpression: (m: any, v: any, field: any) => + field?.parent?.parent?.model.syncProvider === LegacySyncProvider.SuperSync, key: 'isEncryptionEnabled', type: 'checkbox', templateOptions: { @@ -288,7 +291,9 @@ export const SYNC_FORM: ConfigFormSection = { }, }, { - hideExpression: (model: any) => !model.isEncryptionEnabled, + hideExpression: (m: any, v: any, field: any) => + field?.parent?.parent?.model.syncProvider === LegacySyncProvider.SuperSync || + !m.isEncryptionEnabled, type: 'tpl', className: `tpl`, templateOptions: { @@ -297,7 +302,9 @@ export const SYNC_FORM: ConfigFormSection = { }, }, { - hideExpression: (model: any) => !model.isEncryptionEnabled, + hideExpression: (m: any, v: any, field: any) => + field?.parent?.parent?.model.syncProvider === LegacySyncProvider.SuperSync || + !m.isEncryptionEnabled, key: 'encryptKey', type: 'input', templateOptions: { diff --git a/src/app/pfapi/api/encryption/encryption.ts b/src/app/pfapi/api/encryption/encryption.ts index 3852ce80b..f358d0601 100644 --- a/src/app/pfapi/api/encryption/encryption.ts +++ b/src/app/pfapi/api/encryption/encryption.ts @@ -132,8 +132,7 @@ export const decrypt = async (data: string, password: string): Promise = try { return await decryptArgon(data, password); } catch (e) { - // fallback to legacy decryption - console.log('Legacy decryption fallback due to error:', e); + // Fallback to legacy decryption (pre-Argon2 format) return await decryptLegacy(data, password); } }; diff --git a/src/app/pfapi/api/sync/encrypt-and-compress-handler.service.ts b/src/app/pfapi/api/sync/encrypt-and-compress-handler.service.ts index 2d632d512..f52129836 100644 --- a/src/app/pfapi/api/sync/encrypt-and-compress-handler.service.ts +++ b/src/app/pfapi/api/sync/encrypt-and-compress-handler.service.ts @@ -10,7 +10,6 @@ import { decompressGzipFromString, } from '../compression/compression-handler'; import { EncryptAndCompressCfg } from '../pfapi.model'; -import { environment } from '../../../../environments/environment'; export class EncryptAndCompressHandlerService { private static readonly L = 'EncryptAndCompressHandlerService'; @@ -77,7 +76,6 @@ export class EncryptAndCompressHandlerService { } if (isEncrypt) { if (!encryptKey) { - PFLog.log(environment.production ? typeof encryptKey : encryptKey); throw new Error('No encryption password provided'); } @@ -108,7 +106,6 @@ export class EncryptAndCompressHandlerService { if (isEncrypted) { if (!encryptKey) { throw new DecryptNoPasswordError({ - encryptKey, dataStr, isCompressed, isEncrypted,