This commit is contained in:
payacom
2026-08-31 20:45:41 +03:30
parent fa9f23c61d
commit 3f416a7a60
367 changed files with 621895 additions and 6443 deletions

View File

@@ -0,0 +1,162 @@
const mongoose = require('mongoose')
const UserModel = require('../../../models/UserModel')
const DeviceLinkSessionModel = require('../../../models/DeviceLinkSessionModel')
const DEVICE_LINK_TTL_MS = 5 * 60 * 1000
function findOwnSession (linkId, userId) {
if (!mongoose.Types.ObjectId.isValid(linkId)) return null
return DeviceLinkSessionModel.findOne({ _id: linkId, user: userId })
}
const putMyPublicKey = async (req, res, next) => {
try {
const userId = String(req.user._id)
const { publicKey } = req.body
if (!publicKey || typeof publicKey !== 'string' || publicKey.length < 32 || publicKey.length > 4096) {
return res.status(422).json({ error: 'publicKey is required' })
}
const trimmed = String(publicKey).trim()
const existing = await UserModel.findById(userId).select('e2ee_public_key').lean()
// کلید عمومی همیشه یکسان می‌مونه مگر کاربر واقعاً هویتِ E2EE‌ش رو عوض کنه (دستگاه
// جدید بدون لینک‌کردن، پاک‌شدن دیتای اپ و...). اگه از قبل یه کلید ثبت‌شده و مقدار
// تازه باهاش فرق داره، یعنی یا کلاینت واقعاً هویتش رو عوض کرده یا یه باگ کلاینتی
// (مثلاً race توی ساختن کلید) داره جایگزینش می‌کنه — فقط لاگ می‌کنیم تا قابل‌ردیابی
// باشه، بدون این‌که خودِ درخواست رو رد کنیم (چون سرور نمی‌تونه تشخیص بده کدوم درسته)
if (existing?.e2ee_public_key && existing.e2ee_public_key !== trimmed) {
console.warn(`[e2ee] public key rotated for user ${userId} (${existing.e2ee_public_key.length} -> ${trimmed.length} chars)`)
}
if (existing?.e2ee_public_key !== trimmed) {
await UserModel.findByIdAndUpdate(userId, { e2ee_public_key: trimmed })
}
res.json({ ok: true })
} catch (error) {
next(error)
}
}
const getPublicKeyByUserId = async (req, res, next) => {
try {
const { userId } = req.params
if (!userId) {
return res.status(400).json({ error: 'userId is required' })
}
const user = await UserModel.findById(userId).select('e2ee_public_key').lean()
if (!user) {
return res.status(404).json({ error: 'User not found' })
}
res.json({
userId: String(user._id),
publicKey: user.e2ee_public_key || null
})
} catch (error) {
next(error)
}
}
/**
* New device (no E2EE identity yet) starts a link session with its own ephemeral
* ECDH public key. Returns a linkId to encode as a QR — the already-linked device
* scans it, wraps its identity private key against this ephemeral key, and posts
* it back via `approveDeviceLink`. Server only ever stores/relays ciphertext.
*/
const startDeviceLink = async (req, res, next) => {
try {
const { ephemeralPublicKey } = req.body
if (
!ephemeralPublicKey ||
typeof ephemeralPublicKey !== 'string' ||
ephemeralPublicKey.length < 32 ||
ephemeralPublicKey.length > 2048
) {
return res.status(422).json({ error: 'ephemeralPublicKey is required' })
}
const session = await DeviceLinkSessionModel.create({
user: req.user._id,
ephemeralPublicKeyB64: ephemeralPublicKey.trim(),
expiresAt: new Date(Date.now() + DEVICE_LINK_TTL_MS)
})
res.json({ linkId: String(session._id), expiresAt: session.expiresAt })
} catch (error) {
next(error)
}
}
/** Already-linked device fetches the new device's ephemeral public key to wrap the identity key against. */
const getDeviceLinkSession = async (req, res, next) => {
try {
const session = await findOwnSession(req.params.linkId, req.user._id)
if (!session) {
return res.status(404).json({ error: 'Link not found or expired' })
}
res.json({
ephemeralPublicKey: session.ephemeralPublicKeyB64,
status: session.status
})
} catch (error) {
next(error)
}
}
/** Already-linked device submits the identity private key wrapped for the new device's ephemeral key. */
const approveDeviceLink = async (req, res, next) => {
try {
const { wrappedIdentityKey } = req.body
if (!wrappedIdentityKey || typeof wrappedIdentityKey !== 'string') {
return res.status(422).json({ error: 'wrappedIdentityKey is required' })
}
const session = await findOwnSession(req.params.linkId, req.user._id)
if (!session) {
return res.status(404).json({ error: 'Link not found or expired' })
}
session.wrappedIdentityKey = wrappedIdentityKey
session.status = 'fulfilled'
await session.save()
res.json({ ok: true })
} catch (error) {
next(error)
}
}
/** New device polls until the wrapped key arrives. Single-use: the session is deleted once consumed. */
const pollDeviceLink = async (req, res, next) => {
try {
const session = await findOwnSession(req.params.linkId, req.user._id)
if (!session) {
return res.status(404).json({ error: 'Link not found or expired' })
}
if (session.status !== 'fulfilled' || !session.wrappedIdentityKey) {
return res.json({ status: 'pending' })
}
const wrappedIdentityKey = session.wrappedIdentityKey
await DeviceLinkSessionModel.deleteOne({ _id: session._id })
res.json({ status: 'fulfilled', wrappedIdentityKey })
} catch (error) {
next(error)
}
}
module.exports = {
putMyPublicKey,
getPublicKeyByUserId,
startDeviceLink,
getDeviceLinkSession,
approveDeviceLink,
pollDeviceLink
}