feat: Implement end-to-end encrypted chat with new API routes, crypto utilities, and UI components.

This commit is contained in:
Christopher
2026-01-27 17:59:08 -08:00
parent 5903022f8a
commit 9ee0cbbb9a
20 changed files with 1715 additions and 1525 deletions
+97
View File
@@ -955,6 +955,103 @@ export const chatMessagesRelations = relations(chatMessages, ({ one }) => ({
}),
}));
/**
* V2.1 E2EE: Device Bundles
* Stores the identity and signed prekeys for each user device.
* Verified by the root ECDSA identity key (signature).
*/
export const chatDeviceBundles = pgTable('chat_device_bundles', {
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
did: text('did').notNull(),
// The device identifier (UUID generated by client)
deviceId: text('device_id').notNull(),
// X25519 Identity Key (Base64)
identityKey: text('identity_key').notNull(),
// Signed PreKey (JSON: { id, key, sig })
signedPreKey: text('signed_pre_key').notNull(),
// One-Time Keys (JSON array of { id, key }) - cached/uploaded batch
// Note: Individual keys are usually stored in a separate table for atomic consumption,
// but initial upload can be here or we strictly use chat_one_time_keys.
// We will use the separate table for consumption tracking.
// The ECDSA signature covering (did, deviceId, identityKey, signedPreKey)
// Signed by the user's Root Identity Key (P-256)
signature: text('signature').notNull(),
lastSeenAt: timestamp('last_seen_at').defaultNow().notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
}, (table) => [
index('chat_bundles_user_idx').on(table.userId),
index('chat_bundles_did_idx').on(table.did),
// compound index for fast lookup of a specific device
uniqueIndex('chat_bundles_device_unique').on(table.userId, table.deviceId),
]);
export const chatDeviceBundlesRelations = relations(chatDeviceBundles, ({ one, many }) => ({
user: one(users, {
fields: [chatDeviceBundles.userId],
references: [users.id],
}),
oneTimeKeys: many(chatOneTimeKeys),
}));
/**
* V2.1 E2EE: One-Time Prekeys
* Pool of disposable keys for X3DH.
*/
export const chatOneTimeKeys = pgTable('chat_one_time_keys', {
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
bundleId: uuid('bundle_id').notNull().references(() => chatDeviceBundles.id, { onDelete: 'cascade' }),
keyId: integer('key_id').notNull(),
publicKey: text('public_key').notNull(), // X25519 Base64
createdAt: timestamp('created_at').defaultNow().notNull(),
}, (table) => [
index('chat_otk_bundle_idx').on(table.bundleId),
// Ensure (bundleId, keyId) is unique
uniqueIndex('chat_otk_unique').on(table.bundleId, table.keyId),
]);
export const chatOneTimeKeysRelations = relations(chatOneTimeKeys, ({ one }) => ({
bundle: one(chatDeviceBundles, {
fields: [chatOneTimeKeys.bundleId],
references: [chatDeviceBundles.id],
}),
}));
/**
* V2.1 E2EE: Chat Inbox
* Stores encrypted envelopes waiting for delivery to a specific device.
*/
export const chatInbox = pgTable('chat_inbox', {
id: uuid('id').primaryKey().defaultRandom(),
// Recipient info
recipientDid: text('recipient_did').notNull(),
recipientDeviceId: text('recipient_device_id'), // Null means "all devices" or "not yet routed"
// Sender info
senderDid: text('sender_did').notNull(),
// The Envelope (SignedAction format)
// Contains: { action: "chat.deliver", did, handle, ts, nonce, data: { ciphertext, header... }, sig }
envelope: text('envelope_json').notNull(),
isRead: boolean('is_read').default(false).notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
expiresAt: timestamp('expires_at').notNull(), // TTL (e.g. 30 days)
}, (table) => [
index('chat_inbox_recipient_idx').on(table.recipientDid, table.recipientDeviceId),
index('chat_inbox_created_idx').on(table.createdAt),
]);
/**
* Typing indicators for real-time chat UX.
* Short-lived records that expire after 10 seconds.