Skip to content

Commit a25cee3

Browse files
committed
feat: implement security layer and conflict resolution
- Added ECDH P-256 based security handshake with AES-256-CBC + HMAC-SHA256 - Implemented CryptoHelper, SecureHandshakeService, ClusterKeyAuthenticator - Integrated encryption/decryption in TcpSyncClient and TcpSyncServer - Implemented conflict resolution: LastWriteWinsConflictResolver and RecursiveNodeMergeConflictResolver - Updated TcpSyncServer to use options-based constructor - Synchronized sync.proto from EntglDb.Net for cross-platform compatibility - Removed React Native support to simplify project scope Note: Minor TypeScript compilation issues remain (OplogEntry export, Buffer types)
1 parent 8362309 commit a25cee3

29 files changed

Lines changed: 2153 additions & 8343 deletions

apps/demo/demo.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,12 @@ async function main() {
2828
await db1.initialize();
2929

3030
// Start sync server on Node 1
31-
const server1 = new TcpSyncServer(store1, 'node-1', 3001, 'secret123');
31+
const server1 = new TcpSyncServer({
32+
store: store1,
33+
nodeId: 'node-1',
34+
port: 3001,
35+
authToken: 'secret123'
36+
});
3237
server1.start();
3338

3439
// Start UDP Discovery on Node 1
@@ -56,7 +61,12 @@ async function main() {
5661
await db2.initialize();
5762

5863
// Start sync server on Node 2
59-
const server2 = new TcpSyncServer(store2, 'node-2', 3002, 'secret123');
64+
const server2 = new TcpSyncServer({
65+
store: store2,
66+
nodeId: 'node-2',
67+
port: 3002,
68+
authToken: 'secret123'
69+
});
6070
server2.start();
6171

6272
// Start UDP Discovery on Node 2

packages/core/src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,5 +8,8 @@ export { HLClock } from './hlc/clock';
88
// Storage interface
99
export { IPeerStore } from './storage/interface';
1010

11+
// Sync and conflict resolution
12+
export * from './sync';
13+
1114
// Re-export protocol types for convenience
1215
export type { HLCTimestamp, Document, OplogEntry } from '@entgldb/protocol';
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { OplogEntry } from '../storage/interface';
2+
3+
/**
4+
* Result of conflict resolution between local and remote changes
5+
*/
6+
export class ConflictResolutionResult {
7+
constructor(
8+
public readonly shouldApply: boolean,
9+
public readonly mergedDocument?: any
10+
) { }
11+
12+
static apply(document: any): ConflictResolutionResult {
13+
return new ConflictResolutionResult(true, document);
14+
}
15+
16+
static ignore(): ConflictResolutionResult {
17+
return new ConflictResolutionResult(false, undefined);
18+
}
19+
}
20+
21+
/**
22+
* Interface for conflict resolution strategies
23+
*/
24+
export interface IConflictResolver {
25+
/**
26+
* Resolve conflict between local document and remote change
27+
* @param local - Local document (null if doesn't exist)
28+
* @param remote - Remote oplog entry
29+
* @returns Resolution result with merged document if should apply
30+
*/
31+
resolve(local: any | null, remote: OplogEntry): ConflictResolutionResult;
32+
}

packages/core/src/sync/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export { IConflictResolver, ConflictResolutionResult } from './conflict-resolver';
2+
export { LastWriteWinsConflictResolver } from './last-write-wins-resolver';
3+
export { RecursiveNodeMergeConflictResolver } from './recursive-merge-resolver';
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { OplogEntry } from '@entgldb/protocol';
2+
import { IConflictResolver, ConflictResolutionResult } from './conflict-resolver';
3+
4+
/**
5+
* Last-Write-Wins conflict resolution strategy
6+
* Resolves conflicts based on HLC timestamp comparison
7+
*/
8+
export class LastWriteWinsConflictResolver implements IConflictResolver {
9+
resolve(local: any | null, remote: OplogEntry): ConflictResolutionResult {
10+
// If no local document exists, always apply remote change
11+
if (local === null || local === undefined) {
12+
// Construct new document from oplog entry
13+
const content = remote.data ? JSON.parse(Buffer.from(remote.data).toString('utf-8')) : {};
14+
const newDoc = {
15+
collection: remote.collection,
16+
key: remote.key,
17+
content,
18+
updatedAt: remote.timestamp,
19+
isDeleted: remote.operation === 'delete'
20+
};
21+
return ConflictResolutionResult.apply(newDoc);
22+
}
23+
24+
// If local exists, compare timestamps
25+
if (this.compareTimestamps(remote.timestamp, local.updatedAt) > 0) {
26+
// Remote is newer, apply it
27+
const content = remote.data ? JSON.parse(Buffer.from(remote.data).toString('utf-8')) : {};
28+
const newDoc = {
29+
collection: remote.collection,
30+
key: remote.key,
31+
content,
32+
updatedAt: remote.timestamp,
33+
isDeleted: remote.operation === 'delete'
34+
};
35+
return ConflictResolutionResult.apply(newDoc);
36+
}
37+
38+
// Local is newer or equal, ignore remote
39+
return ConflictResolutionResult.ignore();
40+
}
41+
42+
private compareTimestamps(a: any, b: any): number {
43+
// Compare HLC timestamps
44+
// Format: { wallTime: bigint, counter: number, nodeId: string }
45+
if (a.wallTime > b.wallTime) return 1;
46+
if (a.wallTime < b.wallTime) return -1;
47+
48+
if (a.counter > b.counter) return 1;
49+
if (a.counter < b.counter) return -1;
50+
51+
// If timestamps are equal, compare nodeId for determinism
52+
return a.nodeId.localeCompare(b.nodeId);
53+
}
54+
}
Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
import { OplogEntry } from '@entgldb/protocol';
2+
import { IConflictResolver, ConflictResolutionResult } from './conflict-resolver';
3+
4+
/**
5+
* Recursive JSON merge conflict resolution strategy
6+
* Performs deep merge of JSON objects with intelligent array handling
7+
*/
8+
export class RecursiveNodeMergeConflictResolver implements IConflictResolver {
9+
resolve(local: any | null, remote: OplogEntry): ConflictResolutionResult {
10+
// If no local document, apply remote
11+
if (local === null || local === undefined) {
12+
const content = remote.data ? JSON.parse(Buffer.from(remote.data).toString('utf-8')) : {};
13+
const newDoc = {
14+
collection: remote.collection,
15+
key: remote.key,
16+
content,
17+
updatedAt: remote.timestamp,
18+
isDeleted: remote.operation === 'delete'
19+
};
20+
return ConflictResolutionResult.apply(newDoc);
21+
}
22+
23+
// If remote is delete, check timestamp
24+
if (remote.operation === 'delete') {
25+
if (this.compareTimestamps(remote.timestamp, local.updatedAt) > 0) {
26+
const newDoc = {
27+
collection: remote.collection,
28+
key: remote.key,
29+
content: {},
30+
updatedAt: remote.timestamp,
31+
isDeleted: true
32+
};
33+
return ConflictResolutionResult.apply(newDoc);
34+
}
35+
return ConflictResolutionResult.ignore();
36+
}
37+
38+
const localContent = local.content;
39+
const remoteContent = remote.data ? JSON.parse(Buffer.from(remote.data).toString('utf-8')) : {};
40+
const localTs = local.updatedAt;
41+
const remoteTs = remote.timestamp;
42+
43+
// If either is undefined/null, use LWW
44+
if (!localContent || !remoteContent) {
45+
if (this.compareTimestamps(remoteTs, localTs) > 0) {
46+
return ConflictResolutionResult.apply({
47+
collection: remote.collection,
48+
key: remote.key,
49+
content: remoteContent,
50+
updatedAt: remoteTs,
51+
isDeleted: false
52+
});
53+
}
54+
return ConflictResolutionResult.ignore();
55+
}
56+
57+
// Perform recursive merge
58+
const mergedContent = this.mergeJson(localContent, localTs, remoteContent, remoteTs);
59+
const maxTimestamp = this.compareTimestamps(remoteTs, localTs) > 0 ? remoteTs : localTs;
60+
61+
const mergedDoc = {
62+
collection: remote.collection,
63+
key: remote.key,
64+
content: mergedContent,
65+
updatedAt: maxTimestamp,
66+
isDeleted: false
67+
};
68+
69+
return ConflictResolutionResult.apply(mergedDoc);
70+
}
71+
72+
private mergeJson(local: any, localTs: any, remote: any, remoteTs: any): any {
73+
// If types differ, use LWW
74+
const localType = this.getType(local);
75+
const remoteType = this.getType(remote);
76+
77+
if (localType !== remoteType) {
78+
return this.compareTimestamps(remoteTs, localTs) > 0 ? remote : local;
79+
}
80+
81+
// Handle objects
82+
if (localType === 'object') {
83+
return this.mergeObjects(local, localTs, remote, remoteTs);
84+
}
85+
86+
// Handle arrays
87+
if (localType === 'array') {
88+
return this.mergeArrays(local, localTs, remote, remoteTs);
89+
}
90+
91+
// Primitives - use LWW
92+
if (local === remote) {
93+
return local;
94+
}
95+
return this.compareTimestamps(remoteTs, localTs) > 0 ? remote : local;
96+
}
97+
98+
private mergeObjects(local: any, localTs: any, remote: any, remoteTs: any): any {
99+
const result: any = {};
100+
const processedKeys = new Set<string>();
101+
102+
// Process local keys
103+
for (const key of Object.keys(local)) {
104+
processedKeys.add(key);
105+
106+
if (key in remote) {
107+
// Collision - merge recursively
108+
result[key] = this.mergeJson(local[key], localTs, remote[key], remoteTs);
109+
} else {
110+
// Only in local
111+
result[key] = local[key];
112+
}
113+
}
114+
115+
// Add remaining remote keys
116+
for (const key of Object.keys(remote)) {
117+
if (!processedKeys.has(key)) {
118+
result[key] = remote[key];
119+
}
120+
}
121+
122+
return result;
123+
}
124+
125+
private mergeArrays(local: any[], localTs: any, remote: any[], remoteTs: any): any[] {
126+
// Heuristic: check if arrays contain objects
127+
const localHasObjects = this.hasObjects(local);
128+
const remoteHasObjects = this.hasObjects(remote);
129+
130+
// If both don't have objects or mismatch, use LWW
131+
if (!localHasObjects || !remoteHasObjects || localHasObjects !== remoteHasObjects) {
132+
return this.compareTimestamps(remoteTs, localTs) > 0 ? remote : local;
133+
}
134+
135+
// Both have objects - try to merge by ID
136+
const localMap = this.mapById(local);
137+
const remoteMap = this.mapById(remote);
138+
139+
// If couldn't create ID maps, fallback to LWW
140+
if (!localMap || !remoteMap) {
141+
return this.compareTimestamps(remoteTs, localTs) > 0 ? remote : local;
142+
}
143+
144+
const result: any[] = [];
145+
const processedIds = new Set<string>();
146+
147+
// Process local items
148+
for (const [id, localItem] of localMap.entries()) {
149+
processedIds.add(id);
150+
151+
if (remoteMap.has(id)) {
152+
// Merge recursively
153+
const remoteItem = remoteMap.get(id)!;
154+
result.push(this.mergeJson(localItem, localTs, remoteItem, remoteTs));
155+
} else {
156+
// Keep local item
157+
result.push(localItem);
158+
}
159+
}
160+
161+
// Add new remote items
162+
for (const [id, remoteItem] of remoteMap.entries()) {
163+
if (!processedIds.has(id)) {
164+
result.push(remoteItem);
165+
}
166+
}
167+
168+
return result;
169+
}
170+
171+
private hasObjects(arr: any[]): boolean {
172+
if (arr.length === 0) return false;
173+
return this.getType(arr[0]) === 'object';
174+
}
175+
176+
private mapById(arr: any[]): Map<string, any> | null {
177+
const map = new Map<string, any>();
178+
179+
for (const item of arr) {
180+
if (this.getType(item) !== 'object') return null;
181+
182+
let id: string | null = null;
183+
if ('id' in item) id = String(item.id);
184+
else if ('_id' in item) id = String(item._id);
185+
186+
if (!id) return null; // Missing ID
187+
if (map.has(id)) return null; // Duplicate ID
188+
189+
map.set(id, item);
190+
}
191+
192+
return map;
193+
}
194+
195+
private getType(value: any): string {
196+
if (value === null || value === undefined) return 'null';
197+
if (Array.isArray(value)) return 'array';
198+
return typeof value;
199+
}
200+
201+
private compareTimestamps(a: any, b: any): number {
202+
if (a.wallTime > b.wallTime) return 1;
203+
if (a.wallTime < b.wallTime) return -1;
204+
205+
if (a.counter > b.counter) return 1;
206+
if (a.counter < b.counter) return -1;
207+
208+
return a.nodeId.localeCompare(b.nodeId);
209+
}
210+
}

packages/network-react-native/package.json

Lines changed: 0 additions & 28 deletions
This file was deleted.

0 commit comments

Comments
 (0)