Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
40e59bc55c | ||
|
|
94f0d1cb41 | ||
|
|
d37cc1cf4d | ||
|
|
7346d20224 | ||
|
|
0cc0e5d287 | ||
|
|
d01674a4c6 | ||
|
|
d4621c3ea8 | ||
|
|
94d32906f1 | ||
|
|
606fea044a | ||
|
|
18d019d8b3 |
@@ -1,4 +1,3 @@
|
||||
const crypto = require('crypto');
|
||||
const fetch = require('node-fetch');
|
||||
const {
|
||||
supportsBalanceCheck,
|
||||
@@ -9,7 +8,7 @@ const {
|
||||
ErrorTypes,
|
||||
Constants,
|
||||
} = require('librechat-data-provider');
|
||||
const { getMessages, saveMessage, updateMessage, saveConvo, getConvo } = require('~/models');
|
||||
const { getMessages, saveMessage, updateMessage, saveConvo, getConvo, getUserById } = require('~/models');
|
||||
const { addSpaceIfNeeded, isEnabled } = require('~/server/utils');
|
||||
const { truncateToolCallOutputs } = require('./prompts');
|
||||
const checkBalance = require('~/models/checkBalance');
|
||||
@@ -17,6 +16,48 @@ const { getFiles } = require('~/models/File');
|
||||
const TextStream = require('./TextStream');
|
||||
const { logger } = require('~/config');
|
||||
|
||||
let crypto;
|
||||
try {
|
||||
crypto = require('crypto');
|
||||
} catch (err) {
|
||||
logger.error('[AskController] crypto support is disabled!', err);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to encrypt plaintext using AES-256-GCM and then RSA-encrypt the AES key.
|
||||
* @param {string} plainText - The plaintext to encrypt.
|
||||
* @param {string} pemPublicKey - The RSA public key in PEM format.
|
||||
* @returns {Object} An object containing the ciphertext, iv, authTag, and encryptedKey.
|
||||
*/
|
||||
function encryptText(plainText, pemPublicKey) {
|
||||
// Generate a random 256-bit AES key and a 12-byte IV.
|
||||
const aesKey = crypto.randomBytes(32);
|
||||
const iv = crypto.randomBytes(12);
|
||||
|
||||
// Encrypt the plaintext using AES-256-GCM.
|
||||
const cipher = crypto.createCipheriv('aes-256-gcm', aesKey, iv);
|
||||
let ciphertext = cipher.update(plainText, 'utf8', 'base64');
|
||||
ciphertext += cipher.final('base64');
|
||||
const authTag = cipher.getAuthTag().toString('base64');
|
||||
|
||||
// Encrypt the AES key using the user's RSA public key.
|
||||
const encryptedKey = crypto.publicEncrypt(
|
||||
{
|
||||
key: pemPublicKey,
|
||||
padding: crypto.constants.RSA_PKCS1_OAEP_PADDING,
|
||||
oaepHash: 'sha256',
|
||||
},
|
||||
aesKey,
|
||||
).toString('base64');
|
||||
|
||||
return {
|
||||
ciphertext,
|
||||
iv: iv.toString('base64'),
|
||||
authTag,
|
||||
encryptedKey,
|
||||
};
|
||||
}
|
||||
|
||||
class BaseClient {
|
||||
constructor(apiKey, options = {}) {
|
||||
this.apiKey = apiKey;
|
||||
@@ -849,18 +890,64 @@ class BaseClient {
|
||||
* @param {string | null} user
|
||||
*/
|
||||
async saveMessageToDatabase(message, endpointOptions, user = null) {
|
||||
if (this.user && user !== this.user) {
|
||||
// Normalize the user information:
|
||||
// If "user" is an object, use it; otherwise, if a string is passed use req.user (if available)
|
||||
const currentUser =
|
||||
user && typeof user === 'object'
|
||||
? user
|
||||
: (this.options.req && this.options.req.user
|
||||
? this.options.req.user
|
||||
: { id: user });
|
||||
const currentUserId = currentUser.id || currentUser;
|
||||
|
||||
// Check if the client’s stored user matches the current user.
|
||||
// (this.user might have been set earlier in setMessageOptions)
|
||||
const storedUserId =
|
||||
this.user && typeof this.user === 'object' ? this.user.id : this.user;
|
||||
if (storedUserId && currentUserId && storedUserId !== currentUserId) {
|
||||
throw new Error('User mismatch.');
|
||||
}
|
||||
|
||||
// console.log('User ID:', currentUserId);
|
||||
|
||||
const dbUser = await getUserById(currentUserId, 'encryptionPublicKey');
|
||||
|
||||
// --- NEW ENCRYPTION BLOCK: Encrypt AI response if encryptionPublicKey exists ---
|
||||
if (dbUser.encryptionPublicKey && message && message.text) {
|
||||
try {
|
||||
// Rebuild the PEM format if necessary.
|
||||
const pemPublicKey = `-----BEGIN PUBLIC KEY-----\n${dbUser.encryptionPublicKey
|
||||
.match(/.{1,64}/g)
|
||||
.join('\n')}\n-----END PUBLIC KEY-----`;
|
||||
const { ciphertext, iv, authTag, encryptedKey } = encryptText(
|
||||
message.text,
|
||||
pemPublicKey,
|
||||
);
|
||||
message.text = ciphertext;
|
||||
message.iv = iv;
|
||||
message.authTag = authTag;
|
||||
message.encryptedKey = encryptedKey;
|
||||
logger.debug('[BaseClient.saveMessageToDatabase] Encrypted message text');
|
||||
} catch (err) {
|
||||
logger.error('[BaseClient.saveMessageToDatabase] Error encrypting message text', err);
|
||||
}
|
||||
}
|
||||
// --- End Encryption Block ---
|
||||
|
||||
// Build update parameters including encryption fields.
|
||||
const updateParams = {
|
||||
...message,
|
||||
endpoint: this.options.endpoint,
|
||||
unfinished: false,
|
||||
user: currentUserId, // store the user id (ensured to be a string)
|
||||
iv: message.iv ?? null,
|
||||
authTag: message.authTag ?? null,
|
||||
encryptedKey: message.encryptedKey ?? null,
|
||||
};
|
||||
|
||||
const savedMessage = await saveMessage(
|
||||
this.options.req,
|
||||
{
|
||||
...message,
|
||||
endpoint: this.options.endpoint,
|
||||
unfinished: false,
|
||||
user,
|
||||
},
|
||||
updateParams,
|
||||
{ context: 'api/app/clients/BaseClient.js - saveMessageToDatabase #saveMessage' },
|
||||
);
|
||||
|
||||
@@ -1149,4 +1236,4 @@ class BaseClient {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = BaseClient;
|
||||
module.exports = BaseClient;
|
||||
@@ -2,6 +2,7 @@ const { z } = require('zod');
|
||||
const Message = require('./schema/messageSchema');
|
||||
const { logger } = require('~/config');
|
||||
|
||||
// Validate conversation ID as a UUID (if your conversation IDs follow UUID format)
|
||||
const idSchema = z.string().uuid();
|
||||
|
||||
/**
|
||||
@@ -28,8 +29,11 @@ const idSchema = z.string().uuid();
|
||||
* @param {string} [params.plugin] - Plugin associated with the message.
|
||||
* @param {string[]} [params.plugins] - An array of plugins associated with the message.
|
||||
* @param {string} [params.model] - The model used to generate the message.
|
||||
* @param {Object} [metadata] - Additional metadata for this operation
|
||||
* @param {string} [metadata.context] - The context of the operation
|
||||
* @param {string} [params.iv] - (Optional) Base64-encoded initialization vector for encryption.
|
||||
* @param {string} [params.authTag] - (Optional) Base64-encoded authentication tag from AES-GCM.
|
||||
* @param {string} [params.encryptedKey] - (Optional) Base64-encoded AES key encrypted with RSA.
|
||||
* @param {Object} [metadata] - Additional metadata for this operation.
|
||||
* @param {string} [metadata.context] - The context of the operation.
|
||||
* @returns {Promise<TMessage>} The updated or newly inserted message document.
|
||||
* @throws {Error} If there is an error in saving the message.
|
||||
*/
|
||||
@@ -51,6 +55,9 @@ async function saveMessage(req, params, metadata) {
|
||||
...params,
|
||||
user: req.user.id,
|
||||
messageId: params.newMessageId || params.messageId,
|
||||
iv: params.iv ?? null,
|
||||
authTag: params.authTag ?? null,
|
||||
encryptedKey: params.encryptedKey ?? null,
|
||||
};
|
||||
|
||||
if (req?.body?.isTemporary) {
|
||||
@@ -90,7 +97,12 @@ async function bulkSaveMessages(messages, overrideTimestamp = false) {
|
||||
const bulkOps = messages.map((message) => ({
|
||||
updateOne: {
|
||||
filter: { messageId: message.messageId },
|
||||
update: message,
|
||||
update: {
|
||||
...message,
|
||||
iv: message.iv ?? null,
|
||||
authTag: message.authTag ?? null,
|
||||
encryptedKey: message.encryptedKey ?? null,
|
||||
},
|
||||
timestamps: !overrideTimestamp,
|
||||
upsert: true,
|
||||
},
|
||||
@@ -119,14 +131,7 @@ async function bulkSaveMessages(messages, overrideTimestamp = false) {
|
||||
* @returns {Promise<Object>} The updated or newly inserted message document.
|
||||
* @throws {Error} If there is an error in saving the message.
|
||||
*/
|
||||
async function recordMessage({
|
||||
user,
|
||||
endpoint,
|
||||
messageId,
|
||||
conversationId,
|
||||
parentMessageId,
|
||||
...rest
|
||||
}) {
|
||||
async function recordMessage({ user, endpoint, messageId, conversationId, parentMessageId, ...rest }) {
|
||||
try {
|
||||
// No parsing of convoId as may use threadId
|
||||
const message = {
|
||||
@@ -136,6 +141,9 @@ async function recordMessage({
|
||||
conversationId,
|
||||
parentMessageId,
|
||||
...rest,
|
||||
iv: rest.iv ?? null,
|
||||
authTag: rest.authTag ?? null,
|
||||
encryptedKey: rest.encryptedKey ?? null,
|
||||
};
|
||||
|
||||
return await Message.findOneAndUpdate({ user, messageId }, message, {
|
||||
@@ -190,12 +198,15 @@ async function updateMessageText(req, { messageId, text }) {
|
||||
async function updateMessage(req, message, metadata) {
|
||||
try {
|
||||
const { messageId, ...update } = message;
|
||||
// Ensure encryption fields are explicitly updated (if provided)
|
||||
update.iv = update.iv ?? null;
|
||||
update.authTag = update.authTag ?? null;
|
||||
update.encryptedKey = update.encryptedKey ?? null;
|
||||
|
||||
const updatedMessage = await Message.findOneAndUpdate(
|
||||
{ messageId, user: req.user.id },
|
||||
update,
|
||||
{
|
||||
new: true,
|
||||
},
|
||||
{ new: true },
|
||||
);
|
||||
|
||||
if (!updatedMessage) {
|
||||
@@ -225,11 +236,11 @@ async function updateMessage(req, message, metadata) {
|
||||
*
|
||||
* @async
|
||||
* @function deleteMessagesSince
|
||||
* @param {Object} params - The parameters object.
|
||||
* @param {Object} req - The request object.
|
||||
* @param {Object} params - The parameters object.
|
||||
* @param {string} params.messageId - The unique identifier for the message.
|
||||
* @param {string} params.conversationId - The identifier of the conversation.
|
||||
* @returns {Promise<Number>} The number of deleted messages.
|
||||
* @returns {Promise<number>} The number of deleted messages.
|
||||
* @throws {Error} If there is an error in deleting messages.
|
||||
*/
|
||||
async function deleteMessagesSince(req, { messageId, conversationId }) {
|
||||
@@ -263,7 +274,6 @@ async function getMessages(filter, select) {
|
||||
if (select) {
|
||||
return await Message.find(filter).select(select).sort({ createdAt: 1 }).lean();
|
||||
}
|
||||
|
||||
return await Message.find(filter).sort({ createdAt: 1 }).lean();
|
||||
} catch (err) {
|
||||
logger.error('Error getting messages:', err);
|
||||
@@ -281,10 +291,7 @@ async function getMessages(filter, select) {
|
||||
*/
|
||||
async function getMessage({ user, messageId }) {
|
||||
try {
|
||||
return await Message.findOne({
|
||||
user,
|
||||
messageId,
|
||||
}).lean();
|
||||
return await Message.findOne({ user, messageId }).lean();
|
||||
} catch (err) {
|
||||
logger.error('Error getting message:', err);
|
||||
throw err;
|
||||
|
||||
@@ -137,6 +137,18 @@ const messageSchema = mongoose.Schema(
|
||||
expiredAt: {
|
||||
type: Date,
|
||||
},
|
||||
iv: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
authTag: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
encryptedKey: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
{ timestamps: true },
|
||||
);
|
||||
|
||||
@@ -27,6 +27,10 @@ const { SystemRoles } = require('librechat-data-provider');
|
||||
* @property {Array} [plugins=[]] - List of plugins used by the user
|
||||
* @property {Array.<MongoSession>} [refreshToken] - List of sessions with refresh tokens
|
||||
* @property {Date} [expiresAt] - Optional expiration date of the file
|
||||
* @property {string} [encryptionPublicKey] - The user's encryption public key
|
||||
* @property {string} [encryptedPrivateKey] - The user's encrypted private key
|
||||
* @property {string} [encryptionSalt] - The salt used for key derivation (e.g., PBKDF2)
|
||||
* @property {string} [encryptionIV] - The IV used for encrypting the private key
|
||||
* @property {Date} [createdAt] - Date when the user was created (added by timestamps)
|
||||
* @property {Date} [updatedAt] - Date when the user was last updated (added by timestamps)
|
||||
*/
|
||||
@@ -143,6 +147,22 @@ const userSchema = mongoose.Schema(
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
encryptionPublicKey: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
encryptedPrivateKey: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
encryptionSalt: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
encryptionIV: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
|
||||
{ timestamps: true },
|
||||
|
||||
@@ -1,9 +1,59 @@
|
||||
const { getResponseSender, Constants } = require('librechat-data-provider');
|
||||
const { createAbortController, handleAbortError } = require('~/server/middleware');
|
||||
const { sendMessage, createOnProgress } = require('~/server/utils');
|
||||
const { saveMessage } = require('~/models');
|
||||
const { saveMessage, getUserById } = require('~/models');
|
||||
const { logger } = require('~/config');
|
||||
|
||||
let crypto;
|
||||
try {
|
||||
crypto = require('crypto');
|
||||
} catch (err) {
|
||||
logger.error('[AskController] crypto support is disabled!', err);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to encrypt plaintext using AES-256-GCM and then RSA-encrypt the AES key.
|
||||
* @param {string} plainText - The plaintext to encrypt.
|
||||
* @param {string} pemPublicKey - The RSA public key in PEM format.
|
||||
* @returns {Object} An object containing the ciphertext, iv, authTag, and encryptedKey.
|
||||
*/
|
||||
function encryptText(plainText, pemPublicKey) {
|
||||
// Generate a random 256-bit AES key and a 12-byte IV.
|
||||
const aesKey = crypto.randomBytes(32);
|
||||
const iv = crypto.randomBytes(12);
|
||||
|
||||
// Encrypt the plaintext using AES-256-GCM.
|
||||
const cipher = crypto.createCipheriv('aes-256-gcm', aesKey, iv);
|
||||
let ciphertext = cipher.update(plainText, 'utf8', 'base64');
|
||||
ciphertext += cipher.final('base64');
|
||||
const authTag = cipher.getAuthTag().toString('base64');
|
||||
|
||||
// Encrypt the AES key using the user's RSA public key.
|
||||
const encryptedKey = crypto.publicEncrypt(
|
||||
{
|
||||
key: pemPublicKey,
|
||||
padding: crypto.constants.RSA_PKCS1_OAEP_PADDING,
|
||||
oaepHash: 'sha256',
|
||||
},
|
||||
aesKey,
|
||||
).toString('base64');
|
||||
|
||||
return {
|
||||
ciphertext,
|
||||
iv: iv.toString('base64'),
|
||||
authTag,
|
||||
encryptedKey,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* AskController
|
||||
* - Initializes the client.
|
||||
* - Obtains the response from the language model.
|
||||
* - Retrieves the full user record (to get encryption parameters).
|
||||
* - If the user has encryption enabled (i.e. encryptionPublicKey is provided),
|
||||
* encrypts both the request (userMessage) and the response before saving.
|
||||
*/
|
||||
const AskController = async (req, res, next, initializeClient, addTitle) => {
|
||||
let {
|
||||
text,
|
||||
@@ -32,7 +82,22 @@ const AskController = async (req, res, next, initializeClient, addTitle) => {
|
||||
modelDisplayLabel,
|
||||
});
|
||||
const newConvo = !conversationId;
|
||||
const user = req.user.id;
|
||||
const userId = req.user.id; // User ID from authentication
|
||||
|
||||
// Retrieve full user record from DB (including encryption parameters)
|
||||
const dbUser = await getUserById(userId, 'encryptionPublicKey encryptedPrivateKey encryptionSalt encryptionIV');
|
||||
|
||||
// Build clientOptions including the encryptionPublicKey (if available)
|
||||
const clientOptions = {
|
||||
encryptionPublicKey: dbUser?.encryptionPublicKey,
|
||||
};
|
||||
|
||||
// Rebuild PEM format if encryptionPublicKey is available
|
||||
let pemPublicKey = null;
|
||||
if (clientOptions.encryptionPublicKey && clientOptions.encryptionPublicKey.trim() !== '') {
|
||||
const pubKeyBase64 = clientOptions.encryptionPublicKey;
|
||||
pemPublicKey = `-----BEGIN PUBLIC KEY-----\n${pubKeyBase64.match(/.{1,64}/g).join('\n')}\n-----END PUBLIC KEY-----`;
|
||||
}
|
||||
|
||||
const getReqData = (data = {}) => {
|
||||
for (let key in data) {
|
||||
@@ -52,11 +117,10 @@ const AskController = async (req, res, next, initializeClient, addTitle) => {
|
||||
};
|
||||
|
||||
let getText;
|
||||
|
||||
try {
|
||||
const { client } = await initializeClient({ req, res, endpointOption });
|
||||
// Pass clientOptions (which includes encryptionPublicKey) along with other parameters to initializeClient
|
||||
const { client } = await initializeClient({ req, res, endpointOption, ...clientOptions });
|
||||
const { onProgress: progressCallback, getPartialText } = createOnProgress();
|
||||
|
||||
getText = client.getStreamText != null ? client.getStreamText.bind(client) : getPartialText;
|
||||
|
||||
const getAbortData = () => ({
|
||||
@@ -74,20 +138,14 @@ const AskController = async (req, res, next, initializeClient, addTitle) => {
|
||||
|
||||
res.on('close', () => {
|
||||
logger.debug('[AskController] Request closed');
|
||||
if (!abortController) {
|
||||
return;
|
||||
} else if (abortController.signal.aborted) {
|
||||
return;
|
||||
} else if (abortController.requestCompleted) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!abortController) { return; }
|
||||
if (abortController.signal.aborted || abortController.requestCompleted) { return; }
|
||||
abortController.abort();
|
||||
logger.debug('[AskController] Request aborted on close');
|
||||
});
|
||||
|
||||
const messageOptions = {
|
||||
user,
|
||||
user: userId,
|
||||
parentMessageId,
|
||||
conversationId,
|
||||
overrideParentMessageId,
|
||||
@@ -95,16 +153,14 @@ const AskController = async (req, res, next, initializeClient, addTitle) => {
|
||||
onStart,
|
||||
abortController,
|
||||
progressCallback,
|
||||
progressOptions: {
|
||||
res,
|
||||
// parentMessageId: overrideParentMessageId || userMessageId,
|
||||
},
|
||||
progressOptions: { res },
|
||||
};
|
||||
|
||||
/** @type {TMessage} */
|
||||
// Get the response from the language model client.
|
||||
let response = await client.sendMessage(text, messageOptions);
|
||||
response.endpoint = endpointOption.endpoint;
|
||||
|
||||
// Ensure the conversation has a title.
|
||||
const { conversation = {} } = await client.responsePromise;
|
||||
conversation.title =
|
||||
conversation && !conversation.title ? null : conversation?.title || 'New Chat';
|
||||
@@ -115,6 +171,35 @@ const AskController = async (req, res, next, initializeClient, addTitle) => {
|
||||
delete userMessage.image_urls;
|
||||
}
|
||||
|
||||
// --- Encrypt the user message if encryption is enabled ---
|
||||
if (pemPublicKey && userMessage && userMessage.text) {
|
||||
try {
|
||||
const { ciphertext, iv, authTag, encryptedKey } = encryptText(userMessage.text, pemPublicKey);
|
||||
userMessage.text = ciphertext;
|
||||
userMessage.iv = iv;
|
||||
userMessage.authTag = authTag;
|
||||
userMessage.encryptedKey = encryptedKey;
|
||||
logger.debug('[AskController] User message encrypted.');
|
||||
} catch (encError) {
|
||||
logger.error('[AskController] Error encrypting user message:', encError);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Encrypt the AI response if encryption is enabled ---
|
||||
if (pemPublicKey && response.text) {
|
||||
try {
|
||||
const { ciphertext, iv, authTag, encryptedKey } = encryptText(response.text, pemPublicKey);
|
||||
response.text = ciphertext;
|
||||
response.iv = iv;
|
||||
response.authTag = authTag;
|
||||
response.encryptedKey = encryptedKey;
|
||||
logger.debug('[AskController] Response message encrypted.');
|
||||
} catch (encError) {
|
||||
logger.error('[AskController] Error encrypting response message:', encError);
|
||||
}
|
||||
}
|
||||
// --- End Encryption Branch ---
|
||||
|
||||
if (!abortController.signal.aborted) {
|
||||
sendMessage(res, {
|
||||
final: true,
|
||||
@@ -128,15 +213,15 @@ const AskController = async (req, res, next, initializeClient, addTitle) => {
|
||||
if (!client.savedMessageIds.has(response.messageId)) {
|
||||
await saveMessage(
|
||||
req,
|
||||
{ ...response, user },
|
||||
{ context: 'api/server/controllers/AskController.js - response end' },
|
||||
{ ...response, user: userId },
|
||||
{ context: 'AskController - response end' },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!client.skipSaveUserMessage) {
|
||||
await saveMessage(req, userMessage, {
|
||||
context: 'api/server/controllers/AskController.js - don\'t skip saving user message',
|
||||
context: 'AskController - save user message',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -156,9 +241,9 @@ const AskController = async (req, res, next, initializeClient, addTitle) => {
|
||||
messageId: responseMessageId,
|
||||
parentMessageId: overrideParentMessageId ?? userMessageId ?? parentMessageId,
|
||||
}).catch((err) => {
|
||||
logger.error('[AskController] Error in `handleAbortError`', err);
|
||||
logger.error('[AskController] Error in handleAbortError', err);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = AskController;
|
||||
module.exports = AskController;
|
||||
@@ -7,6 +7,7 @@ const {
|
||||
deleteMessages,
|
||||
deleteUserById,
|
||||
deleteAllUserSessions,
|
||||
updateUser,
|
||||
} = require('~/models');
|
||||
const User = require('~/models/User');
|
||||
const { updateUserPluginAuth, deleteUserPluginAuth } = require('~/server/services/PluginService');
|
||||
@@ -164,6 +165,37 @@ const resendVerificationController = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
const updateUserEncryptionController = async (req, res) => {
|
||||
try {
|
||||
const { encryptionPublicKey, encryptedPrivateKey, encryptionSalt, encryptionIV } = req.body;
|
||||
|
||||
// Allow disabling encryption by passing null for all fields.
|
||||
const allNull = encryptionPublicKey === null && encryptedPrivateKey === null && encryptionSalt === null && encryptionIV === null;
|
||||
const allPresent = encryptionPublicKey && encryptedPrivateKey && encryptionSalt && encryptionIV;
|
||||
|
||||
if (!allNull && !allPresent) {
|
||||
return res.status(400).json({ message: 'Missing encryption parameters.' });
|
||||
}
|
||||
|
||||
// Update the user record with the provided encryption parameters (or null to disable)
|
||||
const updatedUser = await updateUser(req.user.id, {
|
||||
encryptionPublicKey: encryptionPublicKey || null,
|
||||
encryptedPrivateKey: encryptedPrivateKey || null,
|
||||
encryptionSalt: encryptionSalt || null,
|
||||
encryptionIV: encryptionIV || null,
|
||||
});
|
||||
|
||||
if (!updatedUser) {
|
||||
return res.status(404).json({ message: 'User not found.' });
|
||||
}
|
||||
|
||||
res.status(200).json({ success: true });
|
||||
} catch (error) {
|
||||
logger.error('[updateUserEncryptionController]', error);
|
||||
res.status(500).json({ message: 'Something went wrong updating encryption keys.' });
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
getUserController,
|
||||
getTermsStatusController,
|
||||
@@ -172,4 +204,5 @@ module.exports = {
|
||||
verifyEmailController,
|
||||
updateUserPluginsController,
|
||||
resendVerificationController,
|
||||
updateUserEncryptionController,
|
||||
};
|
||||
|
||||
@@ -8,12 +8,14 @@ const {
|
||||
resendVerificationController,
|
||||
getTermsStatusController,
|
||||
acceptTermsController,
|
||||
updateUserEncryptionController,
|
||||
} = require('~/server/controllers/UserController');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/', requireJwtAuth, getUserController);
|
||||
router.get('/terms', requireJwtAuth, getTermsStatusController);
|
||||
router.put('/encryption', requireJwtAuth, updateUserEncryptionController);
|
||||
router.post('/terms/accept', requireJwtAuth, acceptTermsController);
|
||||
router.post('/plugins', requireJwtAuth, updateUserPluginsController);
|
||||
router.delete('/delete', requireJwtAuth, canDeleteAccount, deleteUserController);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { memo, Suspense, useMemo } from 'react';
|
||||
import React, { memo, Suspense, useMemo, useEffect, useState } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import type { TMessage } from 'librechat-data-provider';
|
||||
import type { TMessageContentProps, TDisplayProps } from '~/common';
|
||||
@@ -13,6 +13,77 @@ import Container from './Container';
|
||||
import Markdown from './Markdown';
|
||||
import { cn } from '~/utils';
|
||||
import store from '~/store';
|
||||
import { useAuthContext } from '~/hooks/AuthContext';
|
||||
|
||||
/**
|
||||
* Helper: Converts a base64 string to an ArrayBuffer.
|
||||
*/
|
||||
const base64ToArrayBuffer = (base64: string): ArrayBuffer => {
|
||||
const binaryStr = window.atob(base64);
|
||||
const len = binaryStr.length;
|
||||
const bytes = new Uint8Array(len);
|
||||
for (let i = 0; i < len; i++) {
|
||||
bytes[i] = binaryStr.charCodeAt(i);
|
||||
}
|
||||
return bytes.buffer;
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper: Decrypts an encrypted chat message using the provided RSA private key.
|
||||
* Expects the message object to have: text (ciphertext), iv, authTag, and encryptedKey.
|
||||
*/
|
||||
async function decryptChatMessage(
|
||||
msg: { text: string; iv: string; authTag: string; encryptedKey: string },
|
||||
privateKey: CryptoKey
|
||||
): Promise<string> {
|
||||
// Convert base64 values to ArrayBuffers.
|
||||
const ciphertextBuffer = base64ToArrayBuffer(msg.text);
|
||||
const ivBuffer = new Uint8Array(base64ToArrayBuffer(msg.iv));
|
||||
const authTagBuffer = new Uint8Array(base64ToArrayBuffer(msg.authTag));
|
||||
const encryptedKeyBuffer = base64ToArrayBuffer(msg.encryptedKey);
|
||||
|
||||
// Decrypt the AES key using RSA-OAEP.
|
||||
let aesKeyRaw: ArrayBuffer;
|
||||
try {
|
||||
aesKeyRaw = await window.crypto.subtle.decrypt(
|
||||
{ name: 'RSA-OAEP' },
|
||||
privateKey,
|
||||
encryptedKeyBuffer
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('Failed to decrypt AES key:', err);
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Import the AES key.
|
||||
const aesKey = await window.crypto.subtle.importKey(
|
||||
'raw',
|
||||
aesKeyRaw,
|
||||
{ name: 'AES-GCM' },
|
||||
false,
|
||||
['decrypt']
|
||||
);
|
||||
|
||||
// Combine ciphertext and auth tag (Web Crypto expects them appended).
|
||||
const ciphertextBytes = new Uint8Array(ciphertextBuffer);
|
||||
const combined = new Uint8Array(ciphertextBytes.length + authTagBuffer.length);
|
||||
combined.set(ciphertextBytes);
|
||||
combined.set(authTagBuffer, ciphertextBytes.length);
|
||||
|
||||
// Decrypt the message using AES-GCM.
|
||||
let decryptedBuffer: ArrayBuffer;
|
||||
try {
|
||||
decryptedBuffer = await window.crypto.subtle.decrypt(
|
||||
{ name: 'AES-GCM', iv: ivBuffer },
|
||||
aesKey,
|
||||
combined.buffer
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('Failed to decrypt message:', err);
|
||||
throw err;
|
||||
}
|
||||
return new TextDecoder().decode(decryptedBuffer);
|
||||
}
|
||||
|
||||
export const ErrorMessage = ({
|
||||
text,
|
||||
@@ -40,12 +111,7 @@ export const ErrorMessage = ({
|
||||
>
|
||||
<DelayedRender delay={5500}>
|
||||
<Container message={message}>
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-md border border-red-500 bg-red-500/10 px-3 py-2 text-sm text-gray-600 dark:text-gray-200',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className={cn('rounded-md border border-red-500 bg-red-500/10 px-3 py-2 text-sm text-gray-600 dark:text-gray-200', className)}>
|
||||
{localize('com_ui_error_connection')}
|
||||
</div>
|
||||
</Container>
|
||||
@@ -58,10 +124,7 @@ export const ErrorMessage = ({
|
||||
<div
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
className={cn(
|
||||
'rounded-xl border border-red-500/20 bg-red-500/5 px-3 py-2 text-sm text-gray-600 dark:text-gray-200',
|
||||
className,
|
||||
)}
|
||||
className={cn('rounded-xl border border-red-500/20 bg-red-500/5 px-3 py-2 text-sm text-gray-600 dark:text-gray-200', className)}
|
||||
>
|
||||
<Error text={text} />
|
||||
</div>
|
||||
@@ -69,41 +132,65 @@ export const ErrorMessage = ({
|
||||
);
|
||||
};
|
||||
|
||||
const DisplayMessage = ({ text, isCreatedByUser, message, showCursor }: TDisplayProps) => {
|
||||
const DisplayMessage = ({ text, isCreatedByUser, message, showCursor, className = '' }: TDisplayProps) => {
|
||||
const { isSubmitting, latestMessage } = useChatContext();
|
||||
const { user } = useAuthContext();
|
||||
const enableUserMsgMarkdown = useRecoilValue(store.enableUserMsgMarkdown);
|
||||
const showCursorState = useMemo(
|
||||
() => showCursor === true && isSubmitting,
|
||||
[showCursor, isSubmitting],
|
||||
);
|
||||
const isLatestMessage = useMemo(
|
||||
() => message.messageId === latestMessage?.messageId,
|
||||
[message.messageId, latestMessage?.messageId],
|
||||
);
|
||||
const showCursorState = useMemo(() => showCursor === true && isSubmitting, [showCursor, isSubmitting]);
|
||||
const isLatestMessage = useMemo(() => message.messageId === latestMessage?.messageId, [message.messageId, latestMessage?.messageId]);
|
||||
|
||||
// State to hold the final text to display (decrypted if needed)
|
||||
const [displayText, setDisplayText] = useState<string>(text);
|
||||
const [decryptionError, setDecryptionError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (message.encryptedKey && user?.decryptedPrivateKey) {
|
||||
// Attempt to decrypt the message using our helper.
|
||||
decryptChatMessage(
|
||||
{
|
||||
text: message.text,
|
||||
iv: message.iv,
|
||||
authTag: message.authTag,
|
||||
encryptedKey: message.encryptedKey,
|
||||
},
|
||||
user.decryptedPrivateKey
|
||||
)
|
||||
.then((plainText) => {
|
||||
setDisplayText(plainText);
|
||||
setDecryptionError(null);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('Error decrypting message:', err);
|
||||
setDecryptionError('Decryption error');
|
||||
setDisplayText('');
|
||||
});
|
||||
} else {
|
||||
// If no encryption metadata or no private key, display plain text.
|
||||
setDisplayText(text);
|
||||
setDecryptionError(null);
|
||||
}
|
||||
}, [text, message, user]);
|
||||
|
||||
let content: React.ReactElement;
|
||||
if (!isCreatedByUser) {
|
||||
content = (
|
||||
<Markdown content={text} showCursor={showCursorState} isLatestMessage={isLatestMessage} />
|
||||
);
|
||||
content = <Markdown content={displayText} showCursor={showCursorState} isLatestMessage={isLatestMessage} />;
|
||||
} else if (enableUserMsgMarkdown) {
|
||||
content = <MarkdownLite content={text} />;
|
||||
content = <MarkdownLite content={displayText} />;
|
||||
} else {
|
||||
content = <>{text}</>;
|
||||
content = <>{displayText}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Container message={message}>
|
||||
<div
|
||||
className={cn(
|
||||
isSubmitting ? 'submitting' : '',
|
||||
showCursorState && !!text.length ? 'result-streaming' : '',
|
||||
'markdown prose message-content dark:prose-invert light w-full break-words',
|
||||
isCreatedByUser && !enableUserMsgMarkdown && 'whitespace-pre-wrap',
|
||||
isCreatedByUser ? 'dark:text-gray-20' : 'dark:text-gray-100',
|
||||
)}
|
||||
>
|
||||
{content}
|
||||
<div className={cn(
|
||||
isSubmitting ? 'submitting' : '',
|
||||
showCursorState && !!displayText.length ? 'result-streaming' : '',
|
||||
'markdown prose message-content dark:prose-invert light w-full break-words',
|
||||
isCreatedByUser && !enableUserMsgMarkdown && 'whitespace-pre-wrap',
|
||||
isCreatedByUser ? 'dark:text-gray-20' : 'dark:text-gray-100',
|
||||
className
|
||||
)}>
|
||||
{decryptionError ? <span className="text-red-500">{decryptionError}</span> : content}
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
@@ -162,15 +249,10 @@ const MessageContent = ({
|
||||
{thinkingContent.length > 0 && (
|
||||
<Thinking key={`thinking-${messageId}`}>{thinkingContent}</Thinking>
|
||||
)}
|
||||
<DisplayMessage
|
||||
key={`display-${messageId}`}
|
||||
showCursor={showRegularCursor}
|
||||
text={regularContent}
|
||||
{...props}
|
||||
/>
|
||||
<DisplayMessage key={`display-${messageId}`} showCursor={showRegularCursor} text={regularContent} {...props} />
|
||||
{unfinishedMessage}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(MessageContent);
|
||||
export default memo(MessageContent);
|
||||
@@ -1,4 +1,5 @@
|
||||
import { memo } from 'react';
|
||||
import EncryptionPassphrase from './EncryptionPassphrase';
|
||||
import MaximizeChatSpace from './MaximizeChatSpace';
|
||||
import FontSizeSelector from './FontSizeSelector';
|
||||
import SendMessageKeyEnter from './EnterToSend';
|
||||
@@ -35,6 +36,9 @@ function Chat() {
|
||||
<div className="pb-3">
|
||||
<ScrollButton />
|
||||
</div>
|
||||
<div className="pb-3">
|
||||
<EncryptionPassphrase />
|
||||
</div>
|
||||
<ForkSettings />
|
||||
<div className="pb-3">
|
||||
<ModularChat />
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
import React, { useState, ChangeEvent, FC } from 'react';
|
||||
import {
|
||||
Button,
|
||||
OGDialog,
|
||||
OGDialogContent,
|
||||
OGDialogHeader,
|
||||
OGDialogTitle,
|
||||
Input,
|
||||
} from '~/components';
|
||||
import { Lock, Key } from 'lucide-react';
|
||||
import { useAuthContext, useLocalize } from '~/hooks';
|
||||
import { useSetRecoilState } from 'recoil';
|
||||
import store from '~/store';
|
||||
import type { TUser } from 'librechat-data-provider';
|
||||
import { useToastContext } from '~/Providers';
|
||||
import { useSetUserEncryptionMutation } from '~/data-provider';
|
||||
|
||||
/**
|
||||
* Helper: Convert a Uint8Array to a hex string (for debugging).
|
||||
*/
|
||||
const uint8ArrayToHex = (array: Uint8Array): string =>
|
||||
Array.from(array)
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
|
||||
/**
|
||||
* Derive an AES-GCM key from the passphrase using PBKDF2.
|
||||
*/
|
||||
const deriveKey = async (passphrase: string, salt: Uint8Array): Promise<CryptoKey> => {
|
||||
const encoder = new TextEncoder();
|
||||
const keyMaterial = await window.crypto.subtle.importKey(
|
||||
'raw',
|
||||
encoder.encode(passphrase),
|
||||
'PBKDF2',
|
||||
false,
|
||||
['deriveKey']
|
||||
);
|
||||
const derivedKey = await window.crypto.subtle.deriveKey(
|
||||
{
|
||||
name: 'PBKDF2',
|
||||
salt,
|
||||
iterations: 100000,
|
||||
hash: 'SHA-256',
|
||||
},
|
||||
keyMaterial,
|
||||
{ name: 'AES-GCM', length: 256 },
|
||||
true,
|
||||
['encrypt', 'decrypt']
|
||||
);
|
||||
// Debug: export the derived key and log it.
|
||||
const rawKey = await window.crypto.subtle.exportKey('raw', derivedKey);
|
||||
console.debug('Derived key (hex):', uint8ArrayToHex(new Uint8Array(rawKey)));
|
||||
return derivedKey;
|
||||
};
|
||||
|
||||
/**
|
||||
* Decrypts the user's encrypted private key using the provided passphrase.
|
||||
*/
|
||||
async function decryptUserPrivateKey(
|
||||
encryptedPrivateKeyBase64: string,
|
||||
saltBase64: string,
|
||||
ivBase64: string,
|
||||
passphrase: string
|
||||
): Promise<CryptoKey> {
|
||||
// Convert salt and IV to Uint8Array.
|
||||
const salt = new Uint8Array(window.atob(saltBase64).split('').map(c => c.charCodeAt(0)));
|
||||
const iv = new Uint8Array(window.atob(ivBase64).split('').map(c => c.charCodeAt(0)));
|
||||
|
||||
// Derive symmetric key from passphrase.
|
||||
const encoder = new TextEncoder();
|
||||
const keyMaterial = await window.crypto.subtle.importKey(
|
||||
'raw',
|
||||
encoder.encode(passphrase),
|
||||
'PBKDF2',
|
||||
false,
|
||||
['deriveKey']
|
||||
);
|
||||
const symmetricKey = await window.crypto.subtle.deriveKey(
|
||||
{
|
||||
name: 'PBKDF2',
|
||||
salt,
|
||||
iterations: 100000,
|
||||
hash: 'SHA-256',
|
||||
},
|
||||
keyMaterial,
|
||||
{ name: 'AES-GCM', length: 256 },
|
||||
true,
|
||||
['decrypt']
|
||||
);
|
||||
|
||||
// Decrypt the encrypted private key.
|
||||
const encryptedPrivateKeyBuffer = new Uint8Array(
|
||||
window.atob(encryptedPrivateKeyBase64)
|
||||
.split('')
|
||||
.map(c => c.charCodeAt(0))
|
||||
);
|
||||
const decryptedBuffer = await window.crypto.subtle.decrypt(
|
||||
{ name: 'AES-GCM', iv },
|
||||
symmetricKey,
|
||||
encryptedPrivateKeyBuffer
|
||||
);
|
||||
// Import the decrypted key as a CryptoKey.
|
||||
return await window.crypto.subtle.importKey(
|
||||
'pkcs8',
|
||||
decryptedBuffer,
|
||||
{ name: 'RSA-OAEP', hash: 'SHA-256' },
|
||||
true,
|
||||
['decrypt']
|
||||
);
|
||||
}
|
||||
|
||||
const UserKeysSettings: FC = () => {
|
||||
const localize = useLocalize();
|
||||
const { user } = useAuthContext();
|
||||
const setUser = useSetRecoilState(store.user);
|
||||
const setDecryptedPrivateKey = useSetRecoilState(store.decryptedPrivateKey);
|
||||
const { showToast } = useToastContext();
|
||||
const [dialogOpen, setDialogOpen] = useState<boolean>(false);
|
||||
const [passphrase, setPassphrase] = useState<string>('');
|
||||
|
||||
// Mutation hook for updating user encryption keys.
|
||||
const { mutateAsync: setEncryption } = useSetUserEncryptionMutation({
|
||||
onError: (error) => {
|
||||
console.error('Error updating encryption keys:', error);
|
||||
showToast({ message: localize('com_ui_upload_error'), status: 'error' });
|
||||
},
|
||||
});
|
||||
|
||||
const activateEncryption = async (): Promise<{
|
||||
encryptionPublicKey: string;
|
||||
encryptedPrivateKey: string;
|
||||
encryptionSalt: string;
|
||||
encryptionIV: string;
|
||||
} | void> => {
|
||||
if (!passphrase) {
|
||||
console.error('Passphrase is empty.');
|
||||
return;
|
||||
}
|
||||
if (!user) {
|
||||
console.error('User object is missing.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
console.debug('[Debug] Activating E2EE encryption...');
|
||||
|
||||
// Generate a new RSA-OAEP key pair.
|
||||
const keyPair = await window.crypto.subtle.generateKey(
|
||||
{
|
||||
name: 'RSA-OAEP',
|
||||
modulusLength: 2048,
|
||||
publicExponent: new Uint8Array([1, 0, 1]),
|
||||
hash: 'SHA-256',
|
||||
},
|
||||
true,
|
||||
['encrypt', 'decrypt']
|
||||
);
|
||||
|
||||
// Export the public and private keys.
|
||||
const publicKeyBuffer = await window.crypto.subtle.exportKey('spki', keyPair.publicKey);
|
||||
const privateKeyBuffer = await window.crypto.subtle.exportKey('pkcs8', keyPair.privateKey);
|
||||
const publicKeyBase64 = window.btoa(String.fromCharCode(...new Uint8Array(publicKeyBuffer)));
|
||||
const privateKeyBase64 = window.btoa(String.fromCharCode(...new Uint8Array(privateKeyBuffer)));
|
||||
console.debug('New public key:', publicKeyBase64);
|
||||
console.debug('New private key (plaintext):', privateKeyBase64);
|
||||
|
||||
// Generate a salt (16 bytes) and IV (12 bytes) for AES-GCM.
|
||||
const salt = window.crypto.getRandomValues(new Uint8Array(16));
|
||||
const iv = window.crypto.getRandomValues(new Uint8Array(12));
|
||||
|
||||
// Derive a symmetric key from the passphrase using PBKDF2.
|
||||
const derivedKey = await deriveKey(passphrase, salt);
|
||||
|
||||
// Encrypt the private key using AES-GCM.
|
||||
const encoder = new TextEncoder();
|
||||
const privateKeyBytes = encoder.encode(privateKeyBase64);
|
||||
const encryptedPrivateKeyBuffer = await window.crypto.subtle.encrypt(
|
||||
{ name: 'AES-GCM', iv },
|
||||
derivedKey,
|
||||
privateKeyBytes
|
||||
);
|
||||
const encryptedPrivateKeyBase64 = window.btoa(String.fromCharCode(...new Uint8Array(encryptedPrivateKeyBuffer)));
|
||||
|
||||
// Convert salt and IV to Base64 strings.
|
||||
const saltBase64 = window.btoa(String.fromCharCode(...salt));
|
||||
const ivBase64 = window.btoa(String.fromCharCode(...iv));
|
||||
|
||||
console.debug('Activation complete:');
|
||||
console.debug('Encrypted private key:', encryptedPrivateKeyBase64);
|
||||
console.debug('Salt (base64):', saltBase64);
|
||||
console.debug('IV (base64):', ivBase64);
|
||||
|
||||
return {
|
||||
encryptionPublicKey: publicKeyBase64,
|
||||
encryptedPrivateKey: encryptedPrivateKeyBase64,
|
||||
encryptionSalt: saltBase64,
|
||||
encryptionIV: ivBase64,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error during activation:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const disableEncryption = async (): Promise<void> => {
|
||||
try {
|
||||
await setEncryption({
|
||||
encryptionPublicKey: null,
|
||||
encryptedPrivateKey: null,
|
||||
encryptionSalt: null,
|
||||
encryptionIV: null,
|
||||
});
|
||||
showToast({ message: localize('com_ui_upload_success') });
|
||||
setUser((prev) => ({
|
||||
...prev,
|
||||
encryptionPublicKey: null,
|
||||
encryptedPrivateKey: null,
|
||||
encryptionSalt: null,
|
||||
encryptionIV: null,
|
||||
}) as TUser);
|
||||
setDecryptedPrivateKey(null);
|
||||
} catch (error) {
|
||||
console.error('Error disabling encryption:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (): Promise<void> => {
|
||||
const newEncryption = await activateEncryption();
|
||||
if (newEncryption) {
|
||||
try {
|
||||
await setEncryption(newEncryption);
|
||||
showToast({ message: localize('com_ui_upload_success') });
|
||||
setUser((prev) => ({
|
||||
...prev,
|
||||
...newEncryption,
|
||||
}) as TUser);
|
||||
// Decrypt the private key and store it in the atom.
|
||||
const decryptedKey = await decryptUserPrivateKey(
|
||||
newEncryption.encryptedPrivateKey,
|
||||
newEncryption.encryptionSalt,
|
||||
newEncryption.encryptionIV,
|
||||
passphrase
|
||||
);
|
||||
setDecryptedPrivateKey(decryptedKey);
|
||||
} catch (error) {
|
||||
console.error('Mutation error:', error);
|
||||
}
|
||||
}
|
||||
setDialogOpen(false);
|
||||
setPassphrase('');
|
||||
};
|
||||
|
||||
const handleInputChange = (e: ChangeEvent<HTMLInputElement>): void => {
|
||||
setPassphrase(e.target.value);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Key className="flex w-[20px] h-[20px]" />
|
||||
<span id="user-keys-label">{localize('com_nav_chat_encryption_settings')}</span>
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
aria-label="Set/Change encryption keys"
|
||||
onClick={() => setDialogOpen(true)}
|
||||
data-testid="userKeysSettings"
|
||||
>
|
||||
<Lock className="mr-2 flex w-[22px] items-center stroke-1" />
|
||||
<span>{localize('com_nav_chat_change_passphrase')}</span>
|
||||
</Button>
|
||||
{user?.encryptionPublicKey && (
|
||||
<Button
|
||||
variant="outline"
|
||||
aria-label="Disable encryption"
|
||||
onClick={disableEncryption}
|
||||
data-testid="disableEncryption"
|
||||
>
|
||||
<span>Disable Encryption</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{user?.encryptionPublicKey && (
|
||||
<div className="pt-2 text-xs text-gray-500">
|
||||
{localize('com_nav_chat_current_public_key')}: {user.encryptionPublicKey.slice(0, 30)}...
|
||||
</div>
|
||||
)}
|
||||
<OGDialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<OGDialogContent className="w-11/12 max-w-sm" style={{ borderRadius: '12px' }}>
|
||||
<OGDialogHeader>
|
||||
<OGDialogTitle>{localize('com_nav_chat_enter_your_passphrase')}</OGDialogTitle>
|
||||
</OGDialogHeader>
|
||||
<div className="p-4 flex flex-col gap-4">
|
||||
<Input
|
||||
type="password"
|
||||
value={passphrase}
|
||||
onChange={handleInputChange}
|
||||
placeholder={localize('com_nav_chat_passphrase_placeholder')}
|
||||
aria-label={localize('com_nav_chat_enter_your_passphrase')}
|
||||
/>
|
||||
<Button variant="outline" onClick={handleSubmit}>
|
||||
{localize('com_ui_submit')}
|
||||
</Button>
|
||||
</div>
|
||||
</OGDialogContent>
|
||||
</OGDialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserKeysSettings;
|
||||
@@ -897,7 +897,7 @@ export const useUploadAssistantAvatarMutation = (
|
||||
unknown // context
|
||||
> => {
|
||||
return useMutation([MutationKeys.assistantAvatarUpload], {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
|
||||
mutationFn: ({ postCreation, ...variables }: t.AssistantAvatarVariables) =>
|
||||
dataService.uploadAssistantAvatar(variables),
|
||||
...(options || {}),
|
||||
@@ -1068,3 +1068,24 @@ export const useAcceptTermsMutation = (
|
||||
onMutate: options?.onMutate,
|
||||
});
|
||||
};
|
||||
|
||||
export const useSetUserEncryptionMutation = (
|
||||
options?: {
|
||||
onSuccess?: (
|
||||
data: t.UpdateUserEncryptionResponse,
|
||||
variables: t.UpdateUserEncryptionRequest,
|
||||
context?: unknown
|
||||
) => void;
|
||||
onError?: (
|
||||
error: unknown,
|
||||
variables: t.UpdateUserEncryptionRequest,
|
||||
context?: unknown
|
||||
) => void;
|
||||
}
|
||||
): UseMutationResult<t.UpdateUserEncryptionResponse, unknown, t.UpdateUserEncryptionRequest, unknown> => {
|
||||
return useMutation([MutationKeys.updateUserEncryption], {
|
||||
mutationFn: (variables: t.UpdateUserEncryptionRequest) =>
|
||||
dataService.updateUserEncryption(variables),
|
||||
...(options || {}),
|
||||
});
|
||||
};
|
||||
@@ -234,6 +234,6 @@ export default function useSSE(
|
||||
sse.dispatchEvent(e);
|
||||
}
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
|
||||
}, [submission]);
|
||||
}
|
||||
|
||||
@@ -551,6 +551,11 @@
|
||||
"com_ui_controls": "Controls",
|
||||
"com_ui_copied": "Copied!",
|
||||
"com_ui_copied_to_clipboard": "Copied to clipboard",
|
||||
"com_nav_chat_encryption_settings": "Encryption Settings",
|
||||
"com_nav_chat_change_passphrase": "Change Passphrase",
|
||||
"com_nav_chat_enter_your_passphrase": "Enter your passphrase",
|
||||
"com_nav_chat_passphrase_placeholder": "Type your encryption passphrase here...",
|
||||
"com_nav_chat_current_public_key": "Current Public Key",
|
||||
"com_ui_copy_code": "Copy code",
|
||||
"com_ui_copy_link": "Copy link",
|
||||
"com_ui_copy_to_clipboard": "Copy to clipboard",
|
||||
|
||||
@@ -11,7 +11,14 @@ const availableTools = atom<Record<string, TPlugin>>({
|
||||
default: {},
|
||||
});
|
||||
|
||||
// New atom to hold the decrypted private key (as a CryptoKey)
|
||||
const decryptedPrivateKey = atom<CryptoKey | null>({
|
||||
key: 'decryptedPrivateKey',
|
||||
default: null,
|
||||
});
|
||||
|
||||
export default {
|
||||
user,
|
||||
availableTools,
|
||||
};
|
||||
decryptedPrivateKey,
|
||||
};
|
||||
@@ -238,10 +238,14 @@ export const userTerms = () => '/api/user/terms';
|
||||
export const acceptUserTerms = () => '/api/user/terms/accept';
|
||||
export const banner = () => '/api/banner';
|
||||
|
||||
|
||||
export const encryption = () => '/api/user/encryption';
|
||||
|
||||
// Two-Factor Endpoints
|
||||
export const enableTwoFactor = () => '/api/auth/2fa/enable';
|
||||
export const verifyTwoFactor = () => '/api/auth/2fa/verify';
|
||||
export const confirmTwoFactor = () => '/api/auth/2fa/confirm';
|
||||
export const disableTwoFactor = () => '/api/auth/2fa/disable';
|
||||
export const regenerateBackupCodes = () => '/api/auth/2fa/backup/regenerate';
|
||||
export const verifyTwoFactorTemp = () => '/api/auth/2fa/verify-temp';
|
||||
export const verifyTwoFactorTemp = () => '/api/auth/2fa/verify-temp';
|
||||
|
||||
|
||||
@@ -775,6 +775,14 @@ export function getBanner(): Promise<t.TBannerResponse> {
|
||||
return request.get(endpoints.banner());
|
||||
}
|
||||
|
||||
|
||||
export const updateUserEncryption = (
|
||||
payload: t.UpdateUserEncryptionRequest,
|
||||
): Promise<t.UpdateUserEncryptionResponse> => {
|
||||
return request.put(endpoints.encryption(), payload);
|
||||
};
|
||||
|
||||
|
||||
export function enableTwoFactor(): Promise<t.TEnable2FAResponse> {
|
||||
return request.get(endpoints.enableTwoFactor());
|
||||
}
|
||||
@@ -803,4 +811,4 @@ export function verifyTwoFactorTemp(
|
||||
payload: t.TVerify2FATempRequest,
|
||||
): Promise<t.TVerify2FATempResponse> {
|
||||
return request.post(endpoints.verifyTwoFactorTemp(), payload);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,6 +67,7 @@ export enum MutationKeys {
|
||||
deleteAgentAction = 'deleteAgentAction',
|
||||
deleteUser = 'deleteUser',
|
||||
updateRole = 'updateRole',
|
||||
updateUserEncryption = 'updateUserEncryption',
|
||||
enableTwoFactor = 'enableTwoFactor',
|
||||
verifyTwoFactor = 'verifyTwoFactor',
|
||||
}
|
||||
|
||||
@@ -496,14 +496,17 @@ export const tMessageSchema = z.object({
|
||||
thread_id: z.string().optional(),
|
||||
/* frontend components */
|
||||
iconURL: z.string().nullable().optional(),
|
||||
iv: z.string().nullable().optional(),
|
||||
authTag: z.string().nullable().optional(),
|
||||
encryptedKey: z.string().nullable().optional(),
|
||||
});
|
||||
|
||||
export type TAttachmentMetadata = { messageId: string; toolCallId: string };
|
||||
export type TAttachment =
|
||||
| (TFile & TAttachmentMetadata)
|
||||
| (Pick<TFile, 'filename' | 'filepath' | 'conversationId'> & {
|
||||
expiresAt: number;
|
||||
} & TAttachmentMetadata);
|
||||
expiresAt: number;
|
||||
} & TAttachmentMetadata);
|
||||
|
||||
export type TMessage = z.input<typeof tMessageSchema> & {
|
||||
children?: TMessage[];
|
||||
@@ -515,6 +518,7 @@ export type TMessage = z.input<typeof tMessageSchema> & {
|
||||
siblingIndex?: number;
|
||||
attachments?: TAttachment[];
|
||||
clientTimestamp?: string;
|
||||
messageEncryptionIV?: string;
|
||||
};
|
||||
|
||||
export const coerceNumber = z.union([z.number(), z.string()]).transform((val) => {
|
||||
@@ -768,11 +772,11 @@ export const googleSchema = tConversationSchema
|
||||
.catch(() => ({}));
|
||||
|
||||
/**
|
||||
* TODO: Map the following fields:
|
||||
- presence_penalty -> presencePenalty
|
||||
- frequency_penalty -> frequencyPenalty
|
||||
- stop -> stopSequences
|
||||
*/
|
||||
* TODO: Map the following fields:
|
||||
- presence_penalty -> presencePenalty
|
||||
- frequency_penalty -> frequencyPenalty
|
||||
- stop -> stopSequences
|
||||
*/
|
||||
export const googleGenConfigSchema = z
|
||||
.object({
|
||||
maxOutputTokens: coerceNumber.optional(),
|
||||
|
||||
@@ -41,11 +41,11 @@ export type TEndpointOption = {
|
||||
|
||||
export type TPayload = Partial<TMessage> &
|
||||
Partial<TEndpointOption> & {
|
||||
isContinued: boolean;
|
||||
conversationId: string | null;
|
||||
messages?: TMessages;
|
||||
isTemporary: boolean;
|
||||
};
|
||||
isContinued: boolean;
|
||||
conversationId: string | null;
|
||||
messages?: TMessages;
|
||||
isTemporary: boolean;
|
||||
};
|
||||
|
||||
export type TSubmission = {
|
||||
artifacts?: string;
|
||||
@@ -115,6 +115,7 @@ export type TUser = {
|
||||
role: string;
|
||||
provider: string;
|
||||
plugins?: string[];
|
||||
decryptedPrivateKey?: CryptoKey | string;
|
||||
backupCodes?: TBackupCode[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
@@ -530,3 +531,21 @@ export type TAcceptTermsResponse = {
|
||||
};
|
||||
|
||||
export type TBannerResponse = TBanner | null;
|
||||
|
||||
/**
|
||||
* Request type for updating user encryption keys.
|
||||
*/
|
||||
export type UpdateUserEncryptionRequest = {
|
||||
encryptionPublicKey: string | null;
|
||||
encryptedPrivateKey: string | null;
|
||||
encryptionSalt: string | null;
|
||||
encryptionIV: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Response type for updating user encryption keys.
|
||||
*/
|
||||
export type UpdateUserEncryptionResponse = {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
};
|
||||
Reference in New Issue
Block a user