Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
623 changes: 240 additions & 383 deletions bin/qryptchat-agent.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "qryptchat-web",
"private": true,
"version": "0.21.0",
"version": "0.21.1",
"type": "module",
"bin": {
"qc": "./bin/qc.js",
Expand Down
2 changes: 1 addition & 1 deletion packages/qryptchat/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@profullstack/qryptchat",
"version": "0.14.1",
"version": "0.14.2",
"description": "qc: qrypt.chat in your terminal. A full-screen end-to-end encrypted chat client (ML-KEM-1024), plus a scriptable CLI and an MCP server.",
"type": "module",
"bin": {
Expand Down
8 changes: 3 additions & 5 deletions public/sw.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,8 @@
* Basic offline functionality without ES module imports
*/

// Bumped to v1 -> v2 so clients running the old cache-first worker drop the
// stale app shell they precached on install. v3: page loads skip the HTTP
// cache's stale copies (see the fetch handler).
const CACHE_NAME = 'qryptchat-v3';
// v4 drops stale chat code that could encrypt a sender copy to an old key.
const CACHE_NAME = 'qryptchat-v4';
const OFFLINE_SHELL = '/';
const STATIC_CACHE_URLS = [
OFFLINE_SHELL,
Expand Down Expand Up @@ -186,4 +184,4 @@ self.addEventListener('message', (event) => {
}
});

console.log('Service Worker loaded');
console.log('Service Worker loaded');
61 changes: 45 additions & 16 deletions src/app/api/chat/conversations/[id]/mark-read/route.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,23 +37,52 @@ export async function POST(request, { params } = {}) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}

// Mark all unread messages in this conversation as read
const { error: updateError } = await supabase
.from('deliveries')
.update({ read_ts: new Date().toISOString() })
.eq('recipient_user_id', internalUser.id)
.is('read_ts', null)
.is('deleted_ts', null)
.in('message_id',
supabase
.from('messages')
.select('id')
.eq('conversation_id', normalizedConversationId)
);
const { data: participant, error: participantError } = await supabase
.from('conversation_participants')
.select('id')
.eq('conversation_id', normalizedConversationId)
.eq('user_id', internalUser.id)
.maybeSingle();

if (participantError || !participant) {
return NextResponse.json({ error: 'Access denied to conversation' }, { status: 403 });
}

// PostgREST `.in()` accepts an array, not a nested query builder. Page the
// conversation's IDs and update deliveries in bounded batches.
const batchSize = 500;
let offset = 0;
while (true) {
const { data: messageRows, error: messagesError } = await supabase
.from('messages')
.select('id')
.eq('conversation_id', normalizedConversationId)
.order('id')
.range(offset, offset + batchSize - 1);

if (messagesError) {
console.error('Error loading conversation messages to mark read:', messagesError);
return NextResponse.json({ error: 'Failed to mark messages as read' }, { status: 500 });
}

const messageIds = (messageRows || []).map(({ id }) => id).filter(Boolean);
if (!messageIds.length) break;

const { error: updateError } = await supabase
.from('deliveries')
.update({ read_ts: new Date().toISOString() })
.eq('recipient_user_id', internalUser.id)
.is('read_ts', null)
.is('deleted_ts', null)
.in('message_id', messageIds);

if (updateError) {
console.error('Error marking messages as read:', updateError);
return NextResponse.json({ error: 'Failed to mark messages as read' }, { status: 500 });
}

if (updateError) {
console.error('Error marking messages as read:', updateError);
return NextResponse.json({ error: 'Failed to mark messages as read' }, { status: 500 });
if (messageIds.length < batchSize) break;
offset += batchSize;
}

return NextResponse.json({
Expand Down
16 changes: 14 additions & 2 deletions src/app/api/chat/conversations/[id]/mark-read/route.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,23 @@ function createUsersQuery() {
function createMessagesQuery() {
const query = {
select: vi.fn(() => query),
eq: mocks.messagesEq
eq: mocks.messagesEq,
order: vi.fn(() => query),
range: vi.fn().mockResolvedValue({ data: [{ id: 'message-id' }], error: null })
};
mocks.messagesEq.mockReturnValue(query);
return query;
}

function createParticipantsQuery() {
const query = {
select: vi.fn(() => query),
eq: vi.fn(() => query),
maybeSingle: vi.fn().mockResolvedValue({ data: { id: 'participant-id' }, error: null })
};
return query;
}

function createDeliveriesQuery() {
const query = {
update: vi.fn(() => query),
Expand All @@ -68,6 +79,7 @@ describe('POST /api/chat/conversations/[id]/mark-read', () => {

mocks.from.mockImplementation((table) => {
if (table === 'users') return createUsersQuery();
if (table === 'conversation_participants') return createParticipantsQuery();
if (table === 'messages') return createMessagesQuery();
if (table === 'deliveries') return createDeliveriesQuery();
throw new Error(`Unexpected table: ${table}`);
Expand All @@ -82,7 +94,7 @@ describe('POST /api/chat/conversations/[id]/mark-read', () => {

expect(response.status).toBe(200);
expect(mocks.messagesEq).toHaveBeenCalledWith('conversation_id', 'conversation-1');
expect(mocks.deliveriesIn).toHaveBeenCalledWith('message_id', expect.any(Object));
expect(mocks.deliveriesIn).toHaveBeenCalledWith('message_id', ['message-id']);
});

it('rejects requests without a conversation id', async () => {
Expand Down
1 change: 1 addition & 0 deletions src/app/api/chat/conversations/[id]/participants/route.js
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,7 @@ export async function GET(request, { params } = {}) {
return NextResponse.json({
success: true,
participants: transformedParticipants,
current_user_id: user.id,
conversation_id: conversationId
});

Expand Down
7 changes: 6 additions & 1 deletion src/app/api/messages/send/route.js
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,12 @@ export const POST = withAuth(async ({ request, locals }) => {

return NextResponse.json({
success: true,
message: newMessage
// The messages row intentionally has no ciphertext. Return the sender's
// own recipient copy so the client can decrypt and render its acknowledgement.
message: {
...newMessage,
encrypted_content: encryptedContents[userId] || null
}
});
} catch (error) {
console.error('Send message error:', error);
Expand Down
14 changes: 12 additions & 2 deletions src/cli/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -257,14 +257,24 @@ export class QcClient {
const userIds = participants.map((p) => p.user_id);
if (!userIds.length) throw new QcError('This conversation has no participants');
const { public_keys: keys = {} } = await this.request('/api/crypto/public-keys', { method: 'POST', body: { user_ids: userIds } });
const ownId = this.me?.id;
const ownPublicKey = this.session.keys?.keys1024?.publicKey;
if (!ownId || !userIds.includes(ownId) || !ownPublicKey) {
throw new QcError('Could not identify this device’s own key in the conversation. Sign in again and retry.');
}
const encryptedContents = {};
const missing = [];
for (const id of userIds) {
if (!keys[id]) {
const publicKey = id === ownId ? ownPublicKey : keys[id];
if (!publicKey) {
missing.push(id);
continue;
}
encryptedContents[id] = await this.ring.encrypt(text, keys[id]);
const encrypted = await this.ring.encrypt(text, publicKey);
if (id === ownId && (await this.ring.decrypt(encrypted)) !== text) {
throw new QcError('This device could not decrypt its own encrypted message. No message was sent.');
}
encryptedContents[id] = encrypted;
}
if (!Object.keys(encryptedContents).length) throw new QcError('No participant has a public key yet');
const { message } = await this.request('/api/messages/send', {
Expand Down
22 changes: 21 additions & 1 deletion src/lib/crypto/multi-recipient-encryption.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export class MultiRecipientEncryptionService {
if (!this.isInitialized) {
await this.initialize();
}
await postQuantumEncryption.refreshUserKeysFromStorage();

// Reset error collection for this encryption attempt
this.encryptionErrors = [];
Expand Down Expand Up @@ -73,6 +74,18 @@ export class MultiRecipientEncryptionService {
throw new Error('No participant keys found for conversation');
}

// The published key can belong to a different device or a previous key
// backup. Encrypt this sender's copy to the keypair this device holds.
const ownUserId = participantKeys.currentUserId;
if (!ownUserId || !participantKeys.has(ownUserId)) {
throw new Error('Could not identify your own recipient key. Refresh the chat and try again.');
}
const ownPublicKey = await publicKeyService.getMyPublicKey();
if (!ownPublicKey) {
throw new Error('Your local encryption key is unavailable. Restore your keys in Settings.');
}
participantKeys.set(ownUserId, ownPublicKey);

console.log(`🔐 [MULTI] Found ${participantKeys.size} participants to encrypt for`);

// Encrypt the message for each participant
Expand Down Expand Up @@ -110,12 +123,19 @@ export class MultiRecipientEncryptionService {
messageContent,
publicKey
);
if (userId === ownUserId) {
const decrypted = await postQuantumEncryption.decryptFromSender(encryptedContent, '');
if (decrypted !== messageContent) {
throw new Error('Your message copy failed its local decrypt check. No message was sent.');
}
}

encryptedContents[userId] = encryptedContent;
this.successfulEncryptionCount++;
console.log(`🔐 [MULTI] ✅ Encrypted for participant: ${userId} using ML-KEM`);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
if (userId === ownUserId) throw new Error(errorMessage);
this.failedEncryptionCount++;

// Collect detailed error information
Expand Down Expand Up @@ -442,4 +462,4 @@ export class MultiRecipientEncryptionService {
}

// Create and export singleton instance
export const multiRecipientEncryption = new MultiRecipientEncryptionService();
export const multiRecipientEncryption = new MultiRecipientEncryptionService();
33 changes: 32 additions & 1 deletion src/lib/crypto/post-quantum-encryption.js
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,37 @@ export class PostQuantumEncryptionService {
console.log(`🔐 Post-quantum encryption service initialized (${this.kemName} with ${this.kemName768} backward compatibility)`);
}

/**
* Refresh in-memory keys from IndexedDB before encrypting or decrypting.
* Another open tab may have restored or rotated this device's keys while
* this singleton remained alive.
*/
async refreshUserKeysFromStorage() {
if (typeof window === 'undefined') return false;

try {
const [keyData1024, keyData768] = await Promise.all([
indexedDBManager.get(this.storageKey),
indexedDBManager.get(this.storageKey768)
]);
let refreshed = false;

if (keyData1024?.publicKey && keyData1024?.privateKey && keyData1024.algorithm === this.kemName) {
this.userKeys = { publicKey: keyData1024.publicKey, privateKey: keyData1024.privateKey };
refreshed = true;
}
if (keyData768?.publicKey && keyData768?.privateKey && keyData768.algorithm === this.kemName768) {
this.userKeys768 = { publicKey: keyData768.publicKey, privateKey: keyData768.privateKey };
refreshed = true;
}

return refreshed;
} catch (error) {
console.warn('🔐 Could not refresh encryption keys from local storage:', error);
return false;
}
}

/**
* Generate or load user's post-quantum key pair
* @returns {Promise<{publicKey: string, privateKey: string}>}
Expand Down Expand Up @@ -690,4 +721,4 @@ export class PostQuantumEncryptionService {
}

// Create and export singleton instance
export const postQuantumEncryption = new PostQuantumEncryptionService();
export const postQuantumEncryption = new PostQuantumEncryptionService();
6 changes: 4 additions & 2 deletions src/lib/crypto/public-key-service.js
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,9 @@ export class PublicKeyService {
const userIds = participants.map(/** @param {any} p */ (p) => p.user_id);
console.log(`🔑 Getting public keys for ${userIds.length} participants in conversation ${conversationId}`);

return await this.getMultipleUserPublicKeys(userIds);
const participantKeys = await this.getMultipleUserPublicKeys(userIds);
participantKeys.currentUserId = data.current_user_id || null;
return participantKeys;

} catch (error) {
console.error(`🔑 ❌ Failed to get participant keys for conversation ${conversationId}:`, error);
Expand Down Expand Up @@ -338,4 +340,4 @@ export class PublicKeyService {
}

// Create and export singleton instance
export const publicKeyService = new PublicKeyService();
export const publicKeyService = new PublicKeyService();
Loading
Loading