Skip to content
Merged
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
0bbab6d
feat(contacts): add encrypted desktop device sync
zqchris Jul 31, 2026
1fa2b4d
Merge remote-tracking branch 'upstream/main' into contacts-device-sync
zqchris Jul 31, 2026
49fd4ec
fix(contacts): defer sync reconciliation until send
zqchris Jul 31, 2026
ead6855
Merge remote-tracking branch 'upstream/main' into contacts-device-sync
zqchris Jul 31, 2026
432468f
fix(contacts): align sync plural keys
zqchris Jul 31, 2026
d8dd46e
fix(contacts): harden device sync boundaries
zqchris Jul 31, 2026
655ef41
fix: harden contacts sync state and notifications
zqchris Jul 31, 2026
fb2ff05
Merge remote-tracking branch 'upstream/main' into contacts-device-sync
zqchris Jul 31, 2026
a66f2fb
fix: address contacts sync review feedback
zqchris Jul 31, 2026
746ce8c
fix: close contacts sync handshake gaps
zqchris Jul 31, 2026
a13857d
fix: harden contacts sync activation and fanout
zqchris Jul 31, 2026
2055fcf
Merge remote-tracking branch 'upstream/main' into contacts-device-sync
zqchris Jul 31, 2026
d487f98
fix: address review — drain relay backpressure
zqchris Jul 31, 2026
70dbbf0
fix: preserve contacts settings and FTS recovery
zqchris Jul 31, 2026
6c50f4e
Merge remote-tracking branch 'upstream/main' into contacts-device-sync
zqchris Jul 31, 2026
33d5311
fix: address review — serialize keys and repair projections
zqchris Jul 31, 2026
ac62db9
fix: address review — batch FTS initialization
zqchris Jul 31, 2026
68e1ae4
fix: address review — sync passive instance status
zqchris Jul 31, 2026
37d8eec
fix: address review — avoid blocking key lock
zqchris Jul 31, 2026
860c60d
test: fix key preparation mock type
zqchris Jul 31, 2026
9df8e1d
fix: address contacts sync review feedback
zqchris Jul 31, 2026
95c45f9
fix(contacts): harden device sync worker boundaries
zqchris Jul 31, 2026
cbfc67f
fix(contacts): align normalized identity limits
zqchris Jul 31, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/desktop/forge.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1190,6 +1190,8 @@ const config: ForgeConfig = {
'Cindy uses Apple Events to read Contacts you import and to add or update Contacts you explicitly export.',
NSContactsUsageDescription:
'Cindy accesses Contacts only when you import them or explicitly export additions or updates.',
NSLocalNetworkUsageDescription:
'Cindy uses your local network to sync end-to-end encrypted Smart Contacts directly between your online desktop devices.',
CFBundleDocumentTypes: [
{
CFBundleTypeName: 'Folder',
Expand Down
127 changes: 127 additions & 0 deletions apps/desktop/src/main/contacts-sync/__tests__/crypto-wire.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import { describe, expect, it } from 'vitest';
import { randomBytes } from 'node:crypto';

import {
decryptContactsSyncBytes,
encryptContactsSyncBytes,
generateContactsSyncIdentity,
publicKeyFromPrivate,
} from '../crypto.js';
import {
ContactsSyncWireDecoder,
encodeContactsSyncMessage,
isContactsSyncWireFrame,
} from '../wire.js';

describe('contacts sync crypto and wire', () => {
it('两台设备派生同一共享密钥,服务端拿到公钥仍不能解密', () => {
const a = generateContactsSyncIdentity();
const b = generateContactsSyncIdentity();
const context = {
srcDeviceId: 'device-a',
dstDeviceId: 'device-b',
transferId: 'transfer-1',
totalChunks: 1,
};
const encrypted = encryptContactsSyncBytes(
Buffer.from('private contacts'),
a.privateKey,
b.publicKey,
context,
);

expect(
decryptContactsSyncBytes(encrypted, b.privateKey, a.publicKey, context).toString('utf8'),
).toBe('private contacts');
expect(() =>
decryptContactsSyncBytes(
encrypted,
generateContactsSyncIdentity().privateKey,
a.publicKey,
context,
),
).toThrow();
});

it('私钥导出的公钥可用于检测落盘密钥损坏', () => {
const identity = generateContactsSyncIdentity();
expect(publicKeyFromPrivate(identity.privateKey)).toBe(identity.publicKey);
});

it('大状态会分片、乱序抵达后仍可完整解密', () => {
const a = generateContactsSyncIdentity();
const b = generateContactsSyncIdentity();
const state = { opaqueTestData: randomBytes(600_000).toString('base64') };
const frames = encodeContactsSyncMessage({
message: { version: 1, type: 'state', state, requestReply: true },
ownPrivateKey: a.privateKey,
ownPublicKey: a.publicKey,
peerPublicKey: b.publicKey,
srcDeviceId: 'device-a',
dstDeviceId: 'device-b',
});
expect(frames.length).toBeGreaterThan(1);
expect(frames.every(isContactsSyncWireFrame)).toBe(true);

const decoder = new ContactsSyncWireDecoder();
let result: ReturnType<typeof decoder.accept> = null;
for (const frame of [...frames].reverse()) {
result =
decoder.accept({
srcDeviceId: 'device-a',
dstDeviceId: 'device-b',
frame,
ownPrivateKey: b.privateKey,
expectedPeerPublicKey: a.publicKey,
}) ?? result;
}
expect(result).toEqual({ version: 1, type: 'state', state, requestReply: true });
});

it('篡改目标设备、分片元数据或密文都会认证失败', () => {
const a = generateContactsSyncIdentity();
const b = generateContactsSyncIdentity();
const frames = encodeContactsSyncMessage({
message: { version: 1, type: 'state', state: { contacts: [] } },
ownPrivateKey: a.privateKey,
ownPublicKey: a.publicKey,
peerPublicKey: b.publicKey,
srcDeviceId: 'device-a',
dstDeviceId: 'device-b',
});
const changed = {
...frames[0]!,
data: Buffer.from('tampered').toString('base64'),
};
const decoder = new ContactsSyncWireDecoder();
expect(() =>
decoder.accept({
srcDeviceId: 'device-a',
dstDeviceId: 'device-c',
frame: changed,
ownPrivateKey: b.privateKey,
expectedPeerPublicKey: a.publicKey,
}),
).toThrow();
});

it('拒绝伪造或超界的 wire 帧', () => {
expect(isContactsSyncWireFrame({ version: 1, type: 'key', publicKey: 'not-a-key' })).toBe(
false,
);
expect(
isContactsSyncWireFrame({
version: 1,
type: 'cipher-chunk',
senderPublicKey: generateContactsSyncIdentity().publicKey,
transferId: 'x',
index: 2,
total: 2,
iv: 'x',
tag: 'x',
compression: 'gzip',
data: 'eA==',
}),
).toBe(false);
});
});
Loading
Loading