Skip to content

Commit 84006cc

Browse files
ralyodioclaude
andauthored
fix(crypto): stop republishing the public key on every login (#256)
needsKeySync() never found the stored key, so autoSyncOnLogin() re-uploaded the browser's local public key on every single login. Two bugs stacked: - /api/crypto/public-keys/all returns a bare array of {user_id, public_key}, but the check read `data.public_keys[currentUserId]` off it. `data.public_keys` is undefined on an array, so the lookup never resolved. - Even with the right shape it would still have missed: user_public_keys.user_id holds the auth user id, while getCurrentUserId() returns the internal users.id. Same identity domain drift as IA-040, which is what made five RLS policies dead. The published key is what everyone else encrypts to. Re-uploading it from a browser whose keypair had been regenerated silently replaced the good key, and every message sent afterwards was encrypted to a key the recipient could no longer decrypt -- surfacing as "ChaCha20-Poly1305 decryption failed: invalid tag" on load, with the odd message decrypting fine because it predated the swap. Now the service asks for its own key by internal id and lets the server resolve the identity domain, so there is one id space and no list to mis-index. Both failure paths also fail closed: publishing is the direction with consequences, so an unreadable or errored check leaves the stored key alone instead of assuming the database has nothing. This stops the ongoing damage. It does not recover history already encrypted to a lost keypair -- that needs the key-backup restore path, which is written but has no callers. 505 tests pass; build clean. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent b3f1c05 commit 84006cc

2 files changed

Lines changed: 118 additions & 21 deletions

File tree

src/lib/crypto/key-sync-service.js

Lines changed: 32 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -128,31 +128,40 @@ export class KeySyncService {
128128
return false;
129129
}
130130

131-
// Check if key exists in database by trying to fetch it
132-
// This is a simple check - if the API returns our own key, it's synced
133-
const response = await fetch('/api/crypto/public-keys/all', {
134-
method: 'GET',
135-
credentials: 'include'
136-
});
137-
138-
if (!response.ok) {
139-
console.log('🔑 Cannot check database keys, assuming sync needed');
140-
return true;
141-
}
142-
143-
const data = await response.json();
144131
const currentUserId = this.getCurrentUserId();
145-
132+
146133
if (!currentUserId) {
147134
console.log('🔑 No current user ID, cannot determine sync status');
148135
return false;
149136
}
150137

151-
// Check if our key exists in the database AND matches the local key.
152-
// Presence alone isn't enough: after switching browsers / rotating keys
153-
// the DB still holds the OLD public key, so a presence-only check would
154-
// skip the sync and leave everyone encrypting to a dead key.
155-
const dbKey = data.public_keys && data.public_keys[currentUserId];
138+
// Ask for our own key specifically rather than scanning the all-keys list.
139+
//
140+
// The list endpoint returns a bare array of {user_id, public_key} keyed by the
141+
// *auth* user id, while getCurrentUserId() returns the internal `users.id`. The
142+
// previous code read `data.public_keys[currentUserId]` off that array, which is
143+
// undefined twice over -- wrong shape and wrong identity domain -- so this check
144+
// reported "not found" on every single login and re-uploaded the local key each
145+
// time. On a browser whose keypair had been regenerated that silently replaced
146+
// the good published key, and every message anyone sent afterwards was encrypted
147+
// to a key the recipient could no longer decrypt with.
148+
//
149+
// This endpoint takes the internal id and resolves it to auth_user_id server-side,
150+
// so there is one identity domain and no list to mis-index.
151+
const response = await fetch(
152+
`/api/crypto/public-keys?user_id=${encodeURIComponent(currentUserId)}`,
153+
{ method: 'GET', credentials: 'include' }
154+
);
155+
156+
if (!response.ok) {
157+
// Fail closed: re-uploading is the destructive direction, so an unreadable
158+
// answer must not be treated as "the database has nothing".
159+
console.log('🔑 Cannot check database keys, leaving the published key alone');
160+
return false;
161+
}
162+
163+
const data = await response.json();
164+
const dbKey = data?.public_key ?? null;
156165

157166
if (!dbKey) {
158167
console.log('🔑 Public key not found in database, sync needed');
@@ -169,8 +178,10 @@ export class KeySyncService {
169178

170179
} catch (error) {
171180
console.error('🔑 Error checking key sync status:', error);
172-
// If we can't check, assume sync is needed to be safe
173-
return true;
181+
// Publishing is the side with consequences -- it replaces the key everyone
182+
// encrypts to. Not knowing the answer is a reason to leave it alone, not to
183+
// overwrite it.
184+
return false;
174185
}
175186
}
176187

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2+
3+
const mocks = vi.hoisted(() => ({
4+
getPublicKey: vi.fn(),
5+
initialize: vi.fn()
6+
}));
7+
8+
vi.mock('./post-quantum-encryption.js', () => ({
9+
postQuantumEncryption: {
10+
get isInitialized() {
11+
return true;
12+
},
13+
initialize: mocks.initialize,
14+
getPublicKey: mocks.getPublicKey
15+
}
16+
}));
17+
18+
const LOCAL_KEY = 'local-public-key-aaaa';
19+
const INTERNAL_ID = '4826dea7-225a-45df-a56f-6f380bd74ecf';
20+
21+
describe('keySyncService.needsKeySync', () => {
22+
let keySyncService;
23+
24+
beforeEach(async () => {
25+
vi.resetModules();
26+
vi.clearAllMocks();
27+
mocks.getPublicKey.mockResolvedValue(LOCAL_KEY);
28+
// tests/setup.js replaces localStorage with a mock that does not actually store,
29+
// so the value has to be handed back through getItem rather than written.
30+
window.localStorage.getItem.mockReturnValue(JSON.stringify({ id: INTERNAL_ID }));
31+
({ keySyncService } = await import('./key-sync-service.js'));
32+
});
33+
34+
afterEach(() => {
35+
vi.unstubAllGlobals();
36+
});
37+
38+
/** @param {{ok?: boolean, body?: any}} res */
39+
function stubFetch(res) {
40+
const fetchMock = vi.fn().mockResolvedValue({
41+
ok: res.ok ?? true,
42+
json: async () => res.body
43+
});
44+
vi.stubGlobal('fetch', fetchMock);
45+
return fetchMock;
46+
}
47+
48+
// The bug: the old code read `data.public_keys[internalId]` off an endpoint that
49+
// returns an array keyed by auth id, so it reported "not found" every login and
50+
// re-published the local key over whatever was already there.
51+
it('does not re-publish when the stored key already matches', async () => {
52+
const fetchMock = stubFetch({ body: { public_key: LOCAL_KEY } });
53+
54+
await expect(keySyncService.needsKeySync()).resolves.toBe(false);
55+
56+
// Asks for its own key by internal id; the server resolves the identity domain.
57+
expect(fetchMock).toHaveBeenCalledTimes(1);
58+
expect(fetchMock.mock.calls[0][0]).toContain(`user_id=${INTERNAL_ID}`);
59+
expect(fetchMock.mock.calls[0][0]).not.toContain('/all');
60+
});
61+
62+
it('syncs when the database holds no key', async () => {
63+
stubFetch({ body: { public_key: null } });
64+
await expect(keySyncService.needsKeySync()).resolves.toBe(true);
65+
});
66+
67+
it('syncs when the stored key belongs to a different keypair', async () => {
68+
stubFetch({ body: { public_key: 'some-other-key-bbbb' } });
69+
await expect(keySyncService.needsKeySync()).resolves.toBe(true);
70+
});
71+
72+
// Publishing replaces the key everyone encrypts to, so an unknown answer must not
73+
// be treated as "the database has nothing".
74+
it('leaves the published key alone when the check cannot be completed', async () => {
75+
stubFetch({ ok: false, body: {} });
76+
await expect(keySyncService.needsKeySync()).resolves.toBe(false);
77+
78+
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('offline')));
79+
await expect(keySyncService.needsKeySync()).resolves.toBe(false);
80+
});
81+
82+
it('does nothing without local keys', async () => {
83+
mocks.getPublicKey.mockResolvedValue(null);
84+
await expect(keySyncService.needsKeySync()).resolves.toBe(false);
85+
});
86+
});

0 commit comments

Comments
 (0)