add option to have unencrypted direct messages

This commit is contained in:
2026-05-21 15:31:57 +02:00
parent d0a014705b
commit eb209f6d27
5 changed files with 102 additions and 44 deletions

View File

@@ -62,6 +62,7 @@
"enable_danmaku": true, "enable_danmaku": true,
"private_messages": true, "private_messages": true,
"dm_attachments": true, "dm_attachments": true,
"dm_unencrypted": false,
"dm_attachment_expiry_days": 90, "dm_attachment_expiry_days": 90,
"halls_enabled": true, "halls_enabled": true,
"userhalls_enabled": true, "userhalls_enabled": true,

View File

@@ -344,7 +344,7 @@ if (window.__dmLoaded) {
// Try to get recipient's key — but don't block if they don't have one yet // Try to get recipient's key — but don't block if they don't have one yet
currentOtherPubKey = await getRemotePublicKey(currentOtherId); currentOtherPubKey = await getRemotePublicKey(currentOtherId);
if (!currentOtherPubKey) { if (!window.f0ckSession?.dm_unencrypted && !currentOtherPubKey) {
// Recipient has no key — clearly block sending // Recipient has no key — clearly block sending
const notice = document.getElementById('dm-key-notice'); const notice = document.getElementById('dm-key-notice');
if (notice) { if (notice) {
@@ -531,24 +531,34 @@ if (window.__dmLoaded) {
async function decryptBatch(messages) { async function decryptBatch(messages) {
if (!messages.length) return []; if (!messages.length) return [];
if (!currentOtherPubKey) return messages.map(m => ({ ...m, plaintext: null }));
// Derive shared key ONCE for the whole batch — ECDH is expensive, don't repeat per message let sharedKey = null;
let sharedKey; if (_privateKey && currentOtherPubKey) {
try { try {
sharedKey = await deriveSharedKey(_privateKey, currentOtherPubKey); sharedKey = await deriveSharedKey(_privateKey, currentOtherPubKey);
} catch (e) { } catch (e) {
return messages.map(m => ({ ...m, plaintext: null })); // Key derivation failed — will treat encrypted messages as null plaintext
}
} }
const result = []; const result = [];
for (const m of messages) { for (const m of messages) {
if (m.iv === 'unencrypted' || !m.iv) {
// Unencrypted message
result.push({ ...m, plaintext: m.ciphertext });
} else {
// Encrypted message
if (sharedKey) {
try { try {
const plaintext = await decryptMessage(sharedKey, m.ciphertext, m.iv); const plaintext = await decryptMessage(sharedKey, m.ciphertext, m.iv);
result.push({ ...m, plaintext }); result.push({ ...m, plaintext });
} catch (e) { } catch (e) {
result.push({ ...m, plaintext: null }); result.push({ ...m, plaintext: null });
} }
} else {
result.push({ ...m, plaintext: null });
}
}
} }
return result; return result;
} }
@@ -906,13 +916,21 @@ if (window.__dmLoaded) {
div.dataset.editing = ''; div.dataset.editing = '';
return; return;
} }
if (!currentOtherPubKey) { showFlashMsg('Cannot encrypt: no key', 'error'); return; }
saveBtn.disabled = true; saveBtn.disabled = true;
saveBtn.textContent = '…'; saveBtn.textContent = '…';
try { try {
let newIv, newCt;
if (window.f0ckSession?.dm_unencrypted) {
newIv = 'unencrypted';
newCt = newText;
} else {
if (!currentOtherPubKey) { showFlashMsg('Cannot encrypt: no key', 'error'); saveBtn.disabled = false; saveBtn.textContent = 'Save'; return; }
const sharedKey = await deriveSharedKey(_privateKey, currentOtherPubKey); const sharedKey = await deriveSharedKey(_privateKey, currentOtherPubKey);
const { iv: newIv, ciphertext: newCt } = await encryptMessage(sharedKey, newText); const enc = await encryptMessage(sharedKey, newText);
newIv = enc.iv;
newCt = enc.ciphertext;
}
const res = await fetch(`/api/dm/message/${m.id}`, { const res = await fetch(`/api/dm/message/${m.id}`, {
method: 'PATCH', method: 'PATCH',
@@ -1107,7 +1125,12 @@ if (window.__dmLoaded) {
let iv, encryptedBuffer; let iv, encryptedBuffer;
try { try {
const raw = await file.arrayBuffer(); const raw = await file.arrayBuffer();
if (window.f0ckSession?.dm_unencrypted) {
iv = 'unencrypted';
encryptedBuffer = raw;
} else {
({ iv, encryptedBuffer } = await encryptAttachment(sharedKey, raw)); ({ iv, encryptedBuffer } = await encryptAttachment(sharedKey, raw));
}
} catch (e) { } catch (e) {
showFlashMsg('Encryption failed: ' + e.message, 'error'); showFlashMsg('Encryption failed: ' + e.message, 'error');
return null; return null;
@@ -1405,12 +1428,7 @@ if (window.__dmLoaded) {
// Shared fetch+decrypt helper — returns a Blob or null // Shared fetch+decrypt helper — returns a Blob or null
async function fetchAndDecryptAttachment(id, mime) { async function fetchAndDecryptAttachment(id, mime) {
if (!currentOtherPubKey) {
showFlashMsg('Cannot decrypt: no encryption key', 'error');
return null;
}
try { try {
const sharedKey = await deriveSharedKey(_privateKey, currentOtherPubKey);
const res = await fetch(`/api/dm/attachment/${id}`, { const res = await fetch(`/api/dm/attachment/${id}`, {
headers: { 'X-CSRF-Token': csrfToken() } headers: { 'X-CSRF-Token': csrfToken() }
}); });
@@ -1420,6 +1438,15 @@ if (window.__dmLoaded) {
if (!ivHeader) { showFlashMsg('Server did not return IV — please refresh', 'error'); return null; } if (!ivHeader) { showFlashMsg('Server did not return IV — please refresh', 'error'); return null; }
const encBuf = await res.arrayBuffer(); const encBuf = await res.arrayBuffer();
if (ivHeader === 'unencrypted') {
return new Blob([encBuf], { type: mime || 'application/octet-stream' });
}
if (!currentOtherPubKey) {
showFlashMsg('Cannot decrypt: no encryption key', 'error');
return null;
}
const sharedKey = await deriveSharedKey(_privateKey, currentOtherPubKey);
const plainBuf = await decryptAttachment(sharedKey, ivHeader, new Uint8Array(encBuf)); const plainBuf = await decryptAttachment(sharedKey, ivHeader, new Uint8Array(encBuf));
return new Blob([plainBuf], { type: mime || 'application/octet-stream' }); return new Blob([plainBuf], { type: mime || 'application/octet-stream' });
} catch (e) { } catch (e) {
@@ -1489,7 +1516,7 @@ if (window.__dmLoaded) {
const attachBtn = document.createElement('button'); const attachBtn = document.createElement('button');
attachBtn.type = 'button'; attachBtn.type = 'button';
attachBtn.title = 'Send encrypted attachment'; attachBtn.title = window.f0ckSession?.dm_unencrypted ? 'Send attachment' : 'Send encrypted attachment';
attachBtn.className = 'dm-attach-btn'; attachBtn.className = 'dm-attach-btn';
attachBtn.innerHTML = '<i class="fa-solid fa-paperclip"></i>'; attachBtn.innerHTML = '<i class="fa-solid fa-paperclip"></i>';
actions.prepend(attachBtn); actions.prepend(attachBtn);
@@ -1504,6 +1531,7 @@ if (window.__dmLoaded) {
const file = attachInput.files[0]; const file = attachInput.files[0];
if (!file) return; if (!file) return;
if (!window.f0ckSession?.dm_unencrypted) {
if (!currentOtherPubKey) { if (!currentOtherPubKey) {
showFlashMsg('Cannot attach: recipient has no encryption key', 'error'); showFlashMsg('Cannot attach: recipient has no encryption key', 'error');
return; return;
@@ -1512,6 +1540,7 @@ if (window.__dmLoaded) {
showFlashMsg('Cannot attach: your encryption key is not loaded', 'error'); showFlashMsg('Cannot attach: your encryption key is not loaded', 'error');
return; return;
} }
}
// Show progress state on button // Show progress state on button
const origHtml = attachBtn.innerHTML; const origHtml = attachBtn.innerHTML;
@@ -1524,7 +1553,10 @@ if (window.__dmLoaded) {
}; };
try { try {
const sharedKey = await deriveSharedKey(_privateKey, currentOtherPubKey); let sharedKey = null;
if (!window.f0ckSession?.dm_unencrypted) {
sharedKey = await deriveSharedKey(_privateKey, currentOtherPubKey);
}
const sentinel = await uploadDmAttachment(file, sharedKey, currentOtherId, onProgress); const sentinel = await uploadDmAttachment(file, sharedKey, currentOtherId, onProgress);
if (sentinel) { if (sentinel) {
// Append sentinel to the textarea (with a newline separator) // Append sentinel to the textarea (with a newline separator)
@@ -1540,7 +1572,7 @@ if (window.__dmLoaded) {
} finally { } finally {
attachBtn.disabled = false; attachBtn.disabled = false;
attachBtn.innerHTML = origHtml; attachBtn.innerHTML = origHtml;
attachBtn.title = 'Send encrypted attachment'; attachBtn.title = window.f0ckSession?.dm_unencrypted ? 'Send attachment' : 'Send encrypted attachment';
attachInput.value = ''; attachInput.value = '';
} }
}); });
@@ -1830,6 +1862,7 @@ if (window.__dmLoaded) {
? `${activeReply.quoteLines.join('\n')}\n\n${rawText}` ? `${activeReply.quoteLines.join('\n')}\n\n${rawText}`
: rawText; : rawText;
if (!window.f0ckSession?.dm_unencrypted) {
// If recipient has no key, block sending — we cannot encrypt for them // If recipient has no key, block sending — we cannot encrypt for them
if (!currentOtherPubKey) { if (!currentOtherPubKey) {
// Re-check in case they registered since page load // Re-check in case they registered since page load
@@ -1839,12 +1872,21 @@ if (window.__dmLoaded) {
showFlashMsg('This user hasn\'t set up their encryption key yet. Sending is not possible until they do.', 'error'); showFlashMsg('This user hasn\'t set up their encryption key yet. Sending is not possible until they do.', 'error');
return; return;
} }
}
sendInFlight = true; sendInFlight = true;
btn.disabled = true; btn.disabled = true;
try { try {
let ciphertext, iv;
if (window.f0ckSession?.dm_unencrypted) {
ciphertext = text;
iv = 'unencrypted';
} else {
const sharedKey = await deriveSharedKey(_privateKey, currentOtherPubKey); const sharedKey = await deriveSharedKey(_privateKey, currentOtherPubKey);
const { ciphertext, iv } = await encryptMessage(sharedKey, text); const enc = await encryptMessage(sharedKey, text);
ciphertext = enc.ciphertext;
iv = enc.iv;
}
const res = await dmFetch('POST', `/api/dm/send/${currentOtherId}`, { ciphertext, iv }); const res = await dmFetch('POST', `/api/dm/send/${currentOtherId}`, { ciphertext, iv });
if (res.success) { if (res.success) {
input.value = ''; input.value = '';
@@ -1880,6 +1922,11 @@ if (window.__dmLoaded) {
// ───────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────
async function ensureKeyReady() { async function ensureKeyReady() {
if (window.f0ckSession?.dm_unencrypted) {
const btns = document.querySelectorAll('.dm-manage-keys-btn');
btns.forEach(btn => btn.style.display = 'none');
return;
}
const status = await loadOrCreateKeyPair(); const status = await loadOrCreateKeyPair();
// Always re-upload public key silently (on every page, so recipients can find us) // Always re-upload public key silently (on every page, so recipients can find us)
uploadPublicKey().catch(e => console.warn('[DM] pubkey upload failed:', e)); uploadPublicKey().catch(e => console.warn('[DM] pubkey upload failed:', e));

View File

@@ -8,6 +8,7 @@ let bypass_duplicate_check = false;
let protect_files = false; let protect_files = false;
let private_messages = true; let private_messages = true;
let dm_attachments = true; let dm_attachments = true;
let dm_unencrypted = false;
let default_layout = 'modern'; let default_layout = 'modern';
let enable_pdf = false; let enable_pdf = false;
let enable_cleanup = false; let enable_cleanup = false;
@@ -63,6 +64,9 @@ export const setPrivateMessages = (val) => private_messages = !!val;
export const getDmAttachments = () => dm_attachments; export const getDmAttachments = () => dm_attachments;
export const setDmAttachments = (val) => dm_attachments = !!val; export const setDmAttachments = (val) => dm_attachments = !!val;
export const getDmUnencrypted = () => dm_unencrypted;
export const setDmUnencrypted = (val) => dm_unencrypted = !!val;
export const getDmAttachmentExpiryDays = () => { export const getDmAttachmentExpiryDays = () => {
const v = parseInt(cfg.websrv.dm_attachment_expiry_days); const v = parseInt(cfg.websrv.dm_attachment_expiry_days);
return (Number.isFinite(v) && v > 0) ? v : 90; return (Number.isFinite(v) && v > 0) ? v : 90;

View File

@@ -18,7 +18,7 @@ import { handleMetaExtract } from "./meta_extract_handler.mjs";
import { handleMetaStrip } from "./meta_strip_handler.mjs"; import { handleMetaStrip } from "./meta_strip_handler.mjs";
import { handleCommentUpload } from "./comment_upload_handler.mjs"; import { handleCommentUpload } from "./comment_upload_handler.mjs";
import { handleDmAttachmentUpload, handleDmAttachmentDownload, handleDmAttachmentDelete } from "./dm_attachment_handler.mjs"; import { handleDmAttachmentUpload, handleDmAttachmentDownload, handleDmAttachmentDelete } from "./dm_attachment_handler.mjs";
import { getManualApproval, setManualApproval, getMinTags, setMinTags, getRegistrationOpen, setRegistrationOpen, getTrustedUploads, setTrustedUploads, getBypassDuplicateCheck, setBypassDuplicateCheck, getProtectFiles, setProtectFiles, getPrivateMessages, setPrivateMessages, getDmAttachments, setDmAttachments, getDefaultLayout, setDefaultLayout, getEnablePdf, setEnablePdf, getEnableCleanup, setEnableCleanup, getCleanupStartDate, setCleanupStartDate, getCleanupEndDate, setCleanupEndDate, getLogUserIps, setLogUserIps, getHashUserIps, setHashUserIps, getShitpostMode, setShitpostMode } from "./inc/settings.mjs"; import { getManualApproval, setManualApproval, getMinTags, setMinTags, getRegistrationOpen, setRegistrationOpen, getTrustedUploads, setTrustedUploads, getBypassDuplicateCheck, setBypassDuplicateCheck, getProtectFiles, setProtectFiles, getPrivateMessages, setPrivateMessages, getDmAttachments, setDmAttachments, getDmUnencrypted, setDmUnencrypted, getDefaultLayout, setDefaultLayout, getEnablePdf, setEnablePdf, getEnableCleanup, setEnableCleanup, getCleanupStartDate, setCleanupStartDate, getCleanupEndDate, setCleanupEndDate, getLogUserIps, setLogUserIps, getHashUserIps, setHashUserIps, getShitpostMode, setShitpostMode } from "./inc/settings.mjs";
import { updateHallsCache, getHalls } from "./inc/halls_cache.mjs"; import { updateHallsCache, getHalls } from "./inc/halls_cache.mjs";
import { createI18n } from "./inc/i18n.mjs"; import { createI18n } from "./inc/i18n.mjs";
import security from "./inc/security.mjs"; import security from "./inc/security.mjs";
@@ -1007,6 +1007,10 @@ process.on('uncaughtException', err => {
// Default is true; requires private_messages to also be enabled // Default is true; requires private_messages to also be enabled
setDmAttachments(cfg.websrv.dm_attachments !== false); setDmAttachments(cfg.websrv.dm_attachments !== false);
console.log(`[BOOT] DM attachments: ${cfg.websrv.dm_attachments !== false ? 'ENABLED' : 'DISABLED'}`); console.log(`[BOOT] DM attachments: ${cfg.websrv.dm_attachments !== false ? 'ENABLED' : 'DISABLED'}`);
// Load dm_unencrypted from config.json (static — not a DB setting)
setDmUnencrypted(!!cfg.websrv.dm_unencrypted);
console.log(`[BOOT] DM unencrypted: ${cfg.websrv.dm_unencrypted ? 'ENABLED' : 'DISABLED'}`);
// Load default_layout from config.json (static) // Load default_layout from config.json (static)
if (cfg.websrv.default_layout) { if (cfg.websrv.default_layout) {
setDefaultLayout(cfg.websrv.default_layout); setDefaultLayout(cfg.websrv.default_layout);
@@ -1094,6 +1098,7 @@ process.on('uncaughtException', err => {
enable_profile_description: !!cfg.websrv.enable_profile_description, enable_profile_description: !!cfg.websrv.enable_profile_description,
get private_messages() { return getPrivateMessages(); }, get private_messages() { return getPrivateMessages(); },
get dm_attachments() { return getDmAttachments(); }, get dm_attachments() { return getDmAttachments(); },
get dm_unencrypted() { return getDmUnencrypted(); },
get enable_pdf() { return getEnablePdf(); }, get enable_pdf() { return getEnablePdf(); },
get enable_cleanup() { return getEnableCleanup(); }, get enable_cleanup() { return getEnableCleanup(); },
get cleanup_start_date() { return getCleanupStartDate(); }, get cleanup_start_date() { return getCleanupStartDate(); },

View File

@@ -412,7 +412,8 @@
fileupload_comments_size: {{ fileupload_comments_size }}, fileupload_comments_size: {{ fileupload_comments_size }},
fileupload_comments_max: {{ fileupload_comments_max }}, fileupload_comments_max: {{ fileupload_comments_max }},
fileupload_comments_mode: "{{ fileupload_comments_mode }}", fileupload_comments_mode: "{{ fileupload_comments_mode }}",
dm_attachments: @if(dm_attachments) true @else false @endif dm_attachments: @if(dm_attachments) true @else false @endif,
dm_unencrypted: @if(dm_unencrypted) true @else false @endif
}; };
window.f0ckDebug = window.f0ckSession.development ? console.log.bind(console) : () => {}; window.f0ckDebug = window.f0ckSession.development ? console.log.bind(console) : () => {};
window.f0ckI18n = { window.f0ckI18n = {