diff --git a/apps/backend/src/routes/devices.ts b/apps/backend/src/routes/devices.ts index 39a6567..0051370 100644 --- a/apps/backend/src/routes/devices.ts +++ b/apps/backend/src/routes/devices.ts @@ -13,6 +13,9 @@ import { db } from '../db/index.js'; import { devices, signedPreKeys, oneTimePreKeys } from '../db/schema.js'; import { requireAuth, type AuthRequest } from '../middleware/auth.js'; import { validate } from '../middleware/validate.js'; +import { userDevices, conversationMembers, messages } from '../db/schema.js'; +import { getSocketServer } from '../lib/socket.js'; +import { invalidateConversationCaches } from '../lib/conversationCache.js'; import { SignedPreKeyEntrySchema, PreKeyEntrySchema, verifyEd25519Signature } from '../lib/keys.js'; export const devicesRouter: RouterType = Router(); @@ -28,6 +31,14 @@ const UploadPreKeysSchema = z.object({ oneTimePreKeys: z.array(PreKeyEntrySchema).min(1, 'At least one one-time prekey is required'), }); +const RegisterDeviceSchema = z.object({ + deviceId: z.string().min(1, 'deviceId is required'), + deviceName: z.string().min(1, 'deviceName is required'), + platform: z.enum(['web', 'ios', 'android']), + identityPublicKey: z.string().min(1, 'identityPublicKey is required'), + registrationId: z.number().int().nonnegative().optional(), +}); + /** Maximum number of stored one-time prekeys per device. */ const OTP_CAP = 200; @@ -273,3 +284,127 @@ devicesRouter.post('/:id/prekeys', validate(UploadPreKeysSchema), async (req: Au capped: trimmedBatch.length < otpBatch.length, }); }); + +// ─── POST /devices — register a new device for an existing user -------------- + +devicesRouter.post('/', validate(RegisterDeviceSchema), async (req: AuthRequest, res) => { + const body = req.body as z.infer; + const userId = req.auth!.userId; + + // Validate identityPublicKey is base64 and 32 bytes when decoded (X25519) + try { + const key = Buffer.from(body.identityPublicKey, 'base64'); + if (key.length !== 32) { + res.status(400).json({ error: 'identityPublicKey must be 32 bytes (base64-encoded)' }); + return; + } + } catch { + res.status(400).json({ error: 'identityPublicKey must be valid base64' }); + return; + } + + // Reject duplicate (userId, deviceId) + const existing = await db.query.userDevices.findFirst({ + where: eq(userDevices.deviceId, body.deviceId), + }); + + if (existing && existing.userId === userId) { + res.status(409).json({ error: 'Device already registered for this user' }); + return; + } + + try { + const [row] = await db + .insert(userDevices) + .values({ + userId, + deviceId: body.deviceId, + deviceName: body.deviceName, + platform: body.platform, + identityPublicKey: body.identityPublicKey, + registrationId: body.registrationId ?? undefined, + }) + .returning({ + id: userDevices.id, + deviceId: userDevices.deviceId, + createdAt: userDevices.createdAt, + }); + + // Emit system event to each conversation the user belongs to + void emitDeviceChangeEvent(userId, 'device_added'); + + res.status(201).json({ id: row.id, deviceId: row.deviceId, createdAt: row.createdAt }); + } catch (err) { + console.error('Failed to register device:', err); + res.status(500).json({ error: 'Failed to register device' }); + } +}); + +// ─── DELETE /devices/:id — revoke a device for the authenticated user -------- +devicesRouter.delete('/:id', async (req: AuthRequest, res) => { + const userId = req.auth!.userId; + const deviceId = req.params['id'] as string; + + try { + const result = await db + .update(userDevices) + .set({ revokedAt: new Date() }) + .where(eq(userDevices.deviceId, deviceId)) + .returning(); + + if (!result || result.length === 0) { + res.status(404).json({ error: 'Device not found' }); + return; + } + + // Only emit if the device belonged to the user (safety: check last row) + if (result[0].userId !== userId) { + res.status(403).json({ error: 'Not allowed to revoke this device' }); + return; + } + + // Emit system event to each conversation the user belongs to + void emitDeviceChangeEvent(userId, 'device_revoked'); + + res.status(200).json({ revoked: true }); + } catch (err) { + console.error('Failed to revoke device:', err); + res.status(500).json({ error: 'Failed to revoke device' }); + } +}); + +async function emitDeviceChangeEvent(userId: string, change: 'device_added' | 'device_revoked') { + try { + const memberships = await db.query.conversationMembers.findMany({ + where: eq(conversationMembers.userId, userId), + columns: { conversationId: true }, + }); + + if (memberships.length === 0) return; + + for (const m of memberships) { + const [msg] = await db + .insert(messages) + .values({ + conversationId: m.conversationId, + senderId: userId, + content: JSON.stringify({ userId, change }), + }) + .returning(); + + const io = getSocketServer(); + if (io) { + io.to(m.conversationId).emit('new_message', msg); + } + + // invalidate caches for conversation members + const members = await db.query.conversationMembers.findMany({ + where: eq(conversationMembers.conversationId, m.conversationId), + columns: { userId: true }, + }); + await invalidateConversationCaches(members.map((mm) => mm.userId)); + } + } catch (err) { + console.error('emitDeviceChangeEvent error:', err); + } +} diff --git a/apps/web/src/app/app/devices/page.tsx b/apps/web/src/app/app/devices/page.tsx index 06c9bf7..ad532ff 100644 --- a/apps/web/src/app/app/devices/page.tsx +++ b/apps/web/src/app/app/devices/page.tsx @@ -176,8 +176,8 @@ export default function DevicesPage() {

Link a new device

- Open Clicked on the new device and connect the same wallet. It registers automatically - and shows up in the list below the moment it signs in. + Open Clicked on the new device and connect the same wallet. It registers automatically and + shows up in the list below the moment it signs in.

@@ -240,10 +240,10 @@ export default function DevicesPage() { title="Revoke this device?" >

- This device will be signed out immediately. Because your identity keys change for - anyone who messages you on this account, contacts may see a key-change notice the next - time they message you — that's expected and confirms the old device can no longer - read new messages. + This device will be signed out immediately. Because your identity keys change for anyone + who messages you on this account, contacts may see a key-change notice the next time they + message you — that's expected and confirms the old device can no longer read new + messages.

{/* Expiry Countdown for pending proposals */} - {proposal.status === "pending" &&

{timeLeft}

} + {proposal.status === 'pending' &&

{timeLeft}

} {/* Collapsible content section toggle wrapper */} - {(proposal.status === "executed" || proposal.status === "rejected") && ( + {(proposal.status === 'executed' || proposal.status === 'rejected') && ( )} {!isCollapsed && (
{/* Execute button only visible to verified members when status is approved */} - {proposal.status === "approved" && isMember && ( - )} {/* Expired proposals replace approve/reject with a Finalize button */} - {proposal.status === "expired" && ( - )} @@ -92,4 +98,4 @@ export const ProposalCard: React.FC = ({ )}
); -}; \ No newline at end of file +}; diff --git a/apps/web/src/lib/x3dh.test.ts b/apps/web/src/lib/x3dh.test.ts index 2a357c9..c754bf2 100644 --- a/apps/web/src/lib/x3dh.test.ts +++ b/apps/web/src/lib/x3dh.test.ts @@ -33,7 +33,10 @@ function bundleFrom( signature: toBase64(responder.signedPreKey.signature), }, oneTimePreKey: includeOtp - ? { keyId: responder.oneTimePreKey.keyId, publicKey: toBase64(responder.oneTimePreKey.publicKey) } + ? { + keyId: responder.oneTimePreKey.keyId, + publicKey: toBase64(responder.oneTimePreKey.publicKey), + } : null, }; } diff --git a/apps/web/src/lib/x3dh.ts b/apps/web/src/lib/x3dh.ts index 2718c1b..0d3f67b 100644 --- a/apps/web/src/lib/x3dh.ts +++ b/apps/web/src/lib/x3dh.ts @@ -130,10 +130,7 @@ function concatBytes(...chunks: Uint8Array[]): Uint8Array { * Initiator side: establish a session with `bundle` fetched from * GET /devices/:id/bundle. Verifies the signed prekey before using it. */ -export function initiateSession( - bundle: PreKeyBundle, - myIdentity: IdentityKeyPair, -): X3dhSession { +export function initiateSession(bundle: PreKeyBundle, myIdentity: IdentityKeyPair): X3dhSession { const theirIdentityRawEd = spkiToRawEd25519PublicKey(fromBase64(bundle.identityPublicKey)); const theirSpkPub = fromBase64(bundle.signedPreKey.publicKey); const theirSpkSig = fromBase64(bundle.signedPreKey.signature);