feat: Implement end-to-end encrypted chat with new API routes, crypto utilities, and UI components.
This commit is contained in:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user