feat(security): opt-in recoverable gallery passwords (#1341)
* feat(security): opt-in recoverable gallery passwords Gallery passwords are bcrypt hashes, so an admin who needs to hand a password to a client a second time has to reset it, which invalidates what the client already has. This adds a security setting, security_gallery_password_recoverable, off by default, that keeps an AES-256-GCM encrypted copy of each gallery password and client PIN next to the hash. The key is derived from GALLERY_PASSWORD_ENCRYPTION_KEY or JWT_SECRET. While the setting is on: - create, publish, send-later, edit, reset and the v1 API write the copy alongside the hash; turning a gallery's password requirement off clears it - GET /api/admin/events/:id/password returns the copy to admins with events.edit and ownership, and writes a gallery_password_viewed activity entry on every real reveal - resend-email uses the stored password instead of the "set at creation" sentinel, so the client receives what already works Switching the setting off purges every stored copy. Login and hash verification are untouched; the copy is never read on the gallery side. The Security tab carries the toggle with a warning that stays visible, and the event page shows "Show password" with copy buttons only while the setting is on and the gallery has a secret. Relates to issue 1271 * fix(security): close the write-versus-switch-off race in the password vault The recoverable setting is read while an event insert is assembled and the client-PIN hash awaits after that, so a settings request that switched the feature off and purged in that gap was overtaken by the insert. Every write site now re-reads the setting right after its statement and clears its own row when the setting is off; the settings writer flips the value before it purges, so either the purge or the re-check catches the row. * fix(security): resend carries the stored client PIN and link; deterministic tamper test The creation mail includes the client-access link and PIN; a resend only sent the gallery password even when a stored PIN was available. The ciphertext tamper assertion replaced the last two characters with a constant, which was a no-op roughly once in 4096 runs. * fix(security): drop the revealed password after Send gallery email The send-later route can replace the password; the share card keys its revealed copy on the event query's refetch time, so invalidate the event after the send like the other password-changing mutations do. * fix(security): purge leftovers before the setting write when turning recovery on Switching on wrote the setting first and purged after, so a password write that read the new "on" in between stored a copy the purge then deleted. Turning on now purges before the write; turning off keeps purging after it, which together with the write-site re-check leaves the vault holding exactly what was written while the setting was on. * chore(security): drop the duplicate rateLimitService import left by the rebase * chore(usage): register the password recovery routes in the v5 coverage inventory The inventory moved from v4 to v5 on main; the entry added by this branch followed it. --------- Co-authored-by: Paul Nothaft <[email protected]>
This commit is contained in:
co-authored by
Paul Nothaft
parent
16b79ee119
commit
fb9da72f14
@@ -55,6 +55,8 @@ export interface SecuritySettings {
|
||||
enable_recaptcha: boolean;
|
||||
recaptcha_site_key: string;
|
||||
recaptcha_secret_key: string;
|
||||
// #1271 — opt-in reversible storage of gallery passwords and client PINs
|
||||
gallery_password_recoverable: boolean;
|
||||
}
|
||||
|
||||
/** The general per-IP API rate limiter (#1337). Keys match app_settings. */
|
||||
@@ -190,7 +192,8 @@ export function useSettingsState() {
|
||||
lockout_duration_minutes: 30,
|
||||
enable_recaptcha: false,
|
||||
recaptcha_site_key: '',
|
||||
recaptcha_secret_key: ''
|
||||
recaptcha_secret_key: '',
|
||||
gallery_password_recoverable: false
|
||||
});
|
||||
|
||||
// Rate limiter state. The fallbacks mirror the backend's defaults, but the
|
||||
@@ -309,7 +312,8 @@ export function useSettingsState() {
|
||||
lockout_duration_minutes: toNumber(settings.security_lockout_duration_minutes, 30),
|
||||
enable_recaptcha: toBoolean(settings.security_enable_recaptcha, false),
|
||||
recaptcha_site_key: settings.security_recaptcha_site_key ?? '',
|
||||
recaptcha_secret_key: settings.security_recaptcha_secret_key ?? ''
|
||||
recaptcha_secret_key: settings.security_recaptcha_secret_key ?? '',
|
||||
gallery_password_recoverable: toBoolean(settings.security_gallery_password_recoverable, false)
|
||||
});
|
||||
|
||||
setRateLimitSettings({
|
||||
@@ -458,6 +462,8 @@ export function useSettingsState() {
|
||||
onSuccess: () => {
|
||||
toast.success(t('toast.settingsSaved'));
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-settings'] });
|
||||
// the event page's "Show password" availability follows this tab (#1271)
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-event-password-status'] });
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
toast.error(t(error instanceof Error && error.message === 'RATE_LIMIT_INVALID'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { Save, Key, AlertCircle, ShieldCheck } from 'lucide-react';
|
||||
import { Save, Key, AlertCircle, AlertTriangle, ShieldCheck } from 'lucide-react';
|
||||
import { Button, Card, Input } from '../../../components/common';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { SecuritySettings, RateLimitSettings } from '../hooks/useSettingsState';
|
||||
@@ -214,6 +214,38 @@ export const SecurityTab: React.FC<SecurityTabProps> = ({
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card padding="md">
|
||||
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4">{t('settings.security.galleryPasswordsTitle')}</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<label className="flex items-start">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={securitySettings.gallery_password_recoverable}
|
||||
onChange={(e) => setSecuritySettings(prev => ({ ...prev, gallery_password_recoverable: e.target.checked }))}
|
||||
className="w-4 h-4 mt-0.5 text-primary-600 rounded focus:ring-primary-500"
|
||||
/>
|
||||
<span className="ml-2 text-sm text-neutral-700 dark:text-neutral-300">
|
||||
<span className="block font-medium text-neutral-900 dark:text-neutral-100">{t('settings.security.galleryPasswordRecoverable')}</span>
|
||||
<span className="block mt-1">{t('settings.security.galleryPasswordRecoverableHelp')}</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{/* #1271 — reversible storage is a deliberate trade of security for
|
||||
convenience; the warning stays visible whether or not it is on. */}
|
||||
<div className="p-4 bg-amber-50 dark:bg-amber-900/30 border border-amber-200 dark:border-amber-800 rounded-lg">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="w-5 h-5 text-amber-600 dark:text-amber-400 flex-shrink-0" />
|
||||
<div className="text-sm text-amber-800 dark:text-amber-200 space-y-1">
|
||||
<p className="font-medium">{t('settings.security.galleryPasswordRecoverableWarningTitle')}</p>
|
||||
<p>{t('settings.security.galleryPasswordRecoverableWarning')}</p>
|
||||
<p>{t('settings.security.galleryPasswordRecoverableOffNote')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card padding="md">
|
||||
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4">{t('settings.security.recaptchaSettings')}</h2>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user