mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-01-23 02:36:05 +00:00
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:
parent
262f8f10b1
commit
c03d00b0ff
12 changed files with 52 additions and 25 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -113,3 +113,4 @@ playwright-report/
|
|||
|
||||
electron-builder-appx.yaml
|
||||
test-results/
|
||||
data/
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue