fix(sync): enable E2E encryption for SuperSync operations

- Add isPayloadEncrypted field to Zod OperationSchema (root cause fix)
- Add isPayloadEncrypted column to Prisma schema with migration
- Store and return isPayloadEncrypted in server sync service
- Remove debug logging from encryption services
- Remove potential key exposure from DecryptNoPasswordError
- Remove unnecessary console.log in fallback decryption
- Hide duplicate Advanced encryption fields when SuperSync selected
- Enable previously skipped encryption E2E test
- Add data/ folder to gitignore
This commit is contained in:
Johannes Millan 2025-12-12 17:14:32 +01:00
parent 262f8f10b1
commit c03d00b0ff
12 changed files with 52 additions and 25 deletions

1
.gitignore vendored
View file

@ -113,3 +113,4 @@ playwright-report/
electron-builder-appx.yaml
test-results/
data/

View file

@ -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);

View file

@ -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;

View file

@ -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)

View file

@ -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({

View file

@ -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),
}));

View file

@ -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 {

View file

@ -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<string, unknown> => {
// 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<string, unknown>;
};

View file

@ -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) {

View file

@ -280,7 +280,10 @@ export const SYNC_FORM: ConfigFormSection<SyncConfig> = {
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<SyncConfig> = {
},
},
{
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<SyncConfig> = {
},
},
{
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: {

View file

@ -132,8 +132,7 @@ export const decrypt = async (data: string, password: string): Promise<string> =
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);
}
};

View file

@ -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,