Compare commits

11 Commits

12 changed files with 115 additions and 189 deletions
+3
View File
@@ -44,6 +44,9 @@
// Public read-only request budget per IP per minute. // Public read-only request budget per IP per minute.
// 公开只读接口每 IP 每分钟请求配额。 // 公开只读接口每 IP 每分钟请求配额。
publicReadRequestsPerMinute: 120, publicReadRequestsPerMinute: 120,
// Public website icon proxy budget per IP per minute.
// 公开网站图标代理每 IP 每分钟请求配额。
publicIconRequestsPerMinute: 500,
// Sensitive public/auth request budget per IP per minute. // Sensitive public/auth request budget per IP per minute.
// 敏感公开/认证接口每 IP 每分钟请求配额。 // 敏感公开/认证接口每 IP 每分钟请求配额。
sensitivePublicRequestsPerMinute: 30, sensitivePublicRequestsPerMinute: 30,
+1 -1
View File
@@ -731,7 +731,7 @@ export async function handleRecoverTwoFactor(request: Request, env: Env): Promis
if (!clientIdentifier) { if (!clientIdentifier) {
return errorResponse('Client IP is required', 403); return errorResponse('Client IP is required', 403);
} }
const recoverLimitKey = `${clientIdentifier}:recover-2fa:${email || 'unknown'}`; const recoverLimitKey = `${clientIdentifier}:recover-2fa`;
const recoverAttemptCheck = await rateLimit.checkLoginAttempt(recoverLimitKey); const recoverAttemptCheck = await rateLimit.checkLoginAttempt(recoverLimitKey);
if (!recoverAttemptCheck.allowed) { if (!recoverAttemptCheck.allowed) {
+2 -2
View File
@@ -227,7 +227,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
const twoFactorToken = body.twoFactorToken; const twoFactorToken = body.twoFactorToken;
const twoFactorProvider = body.twoFactorProvider; const twoFactorProvider = body.twoFactorProvider;
const twoFactorRemember = body.twoFactorRemember; const twoFactorRemember = body.twoFactorRemember;
const loginIdentifier = `${clientIdentifier}:${email}`; const loginIdentifier = clientIdentifier;
const deviceInfo = readAuthRequestDeviceInfo(body, request); const deviceInfo = readAuthRequestDeviceInfo(body, request);
if (!email || !passwordHash) { if (!email || !passwordHash) {
@@ -430,7 +430,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
const scope = body.scope; const scope = body.scope;
const deviceInfo = readAuthRequestDeviceInfo(body, request); const deviceInfo = readAuthRequestDeviceInfo(body, request);
const loginIdentifier = `${clientIdentifier}:${clientId}`; const loginIdentifier = clientIdentifier;
const parmValid = checkClientCredentialsParam(clientId, clientSecret, scope); const parmValid = checkClientCredentialsParam(clientId, clientSecret, scope);
if (!parmValid) { if (!parmValid) {
return identityErrorResponse('Parameter error', 'invalid_request', 400); return identityErrorResponse('Parameter error', 'invalid_request', 400);
+62 -92
View File
@@ -77,82 +77,6 @@ function handleMissingWebsiteIcon(): Response {
}); });
} }
function isPrivateIpv4(hostname: string): boolean {
const parts = hostname.split('.').map((part) => Number(part));
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) return false;
const [a, b] = parts;
return (
a === 10 ||
a === 127 ||
(a === 169 && b === 254) ||
(a === 172 && b >= 16 && b <= 31) ||
(a === 192 && b === 168) ||
a === 0
);
}
function isBlockedChangePasswordHost(hostname: string): boolean {
const normalized = hostname.toLowerCase().replace(/\.+$/, '');
return (
normalized === 'localhost' ||
normalized.endsWith('.localhost') ||
normalized.endsWith('.local') ||
normalized === '::1' ||
normalized.startsWith('[') ||
isPrivateIpv4(normalized)
);
}
function parsePublicHttpUrl(rawUri: string | null): URL | null {
if (!rawUri) return null;
try {
const url = new URL(rawUri);
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null;
if (isBlockedChangePasswordHost(url.hostname)) return null;
return url;
} catch {
return null;
}
}
async function handleChangePasswordUri(request: Request): Promise<Response> {
const sourceUrl = parsePublicHttpUrl(new URL(request.url).searchParams.get('uri'));
if (!sourceUrl) {
return jsonResponse({ uri: null });
}
const wellKnownUrl = new URL('/.well-known/change-password', sourceUrl.origin);
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), ICON_UPSTREAM_TIMEOUT_MS);
try {
const response = await fetch(wellKnownUrl.toString(), {
method: 'GET',
redirect: 'manual',
signal: controller.signal,
cf: {
cacheEverything: true,
cacheTtl: LIMITS.cache.iconTtlSeconds,
},
} as RequestInit & { cf: { cacheEverything: boolean; cacheTtl: number } });
if (response.status < 300 || response.status >= 400) {
return jsonResponse({ uri: null });
}
const location = response.headers.get('Location');
if (!location) return jsonResponse({ uri: null });
const targetUrl = parsePublicHttpUrl(new URL(location, wellKnownUrl).toString());
if (!targetUrl) return jsonResponse({ uri: null });
return jsonResponse({ uri: targetUrl.toString() });
} catch {
return jsonResponse({ uri: null });
} finally {
clearTimeout(timeout);
}
}
function buildIconServiceBase(origin: string): string { function buildIconServiceBase(origin: string): string {
return `${origin}/icons`; return `${origin}/icons`;
} }
@@ -220,6 +144,7 @@ function normalizeIconHost(rawHost: string): string | null {
} }
const ICON_UPSTREAM_TIMEOUT_MS = 2500; const ICON_UPSTREAM_TIMEOUT_MS = 2500;
const ICON_MAX_BUFFER_BYTES = 256 * 1024;
const BITWARDEN_DEFAULT_GLOBE_ICON_BYTES = 500; const BITWARDEN_DEFAULT_GLOBE_ICON_BYTES = 500;
const BITWARDEN_DEFAULT_GLOBE_ICON_SHA256 = 'aaa64871332ad5b7d28fe8874efb19c2d9cc2f1e6de75d52b080b438225a0783'; const BITWARDEN_DEFAULT_GLOBE_ICON_SHA256 = 'aaa64871332ad5b7d28fe8874efb19c2d9cc2f1e6de75d52b080b438225a0783';
@@ -255,6 +180,55 @@ async function sha256Hex(bytes: ArrayBuffer): Promise<string> {
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join(''); return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join('');
} }
function getPositiveContentLength(headers: Headers): number | null {
const raw = headers.get('Content-Length');
if (!raw) return null;
const value = Number(raw);
return Number.isFinite(value) && value > 0 ? value : null;
}
async function readIconBytes(response: Response, maxBytes: number): Promise<ArrayBuffer | null> {
if (!response.body) return null;
const reader = response.body.getReader();
const chunks: Uint8Array[] = [];
let totalBytes = 0;
let timedOut = false;
const timeout = setTimeout(() => {
timedOut = true;
void reader.cancel().catch(() => undefined);
}, ICON_UPSTREAM_TIMEOUT_MS);
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (!value) continue;
totalBytes += value.byteLength;
if (totalBytes > maxBytes) {
await reader.cancel().catch(() => undefined);
return null;
}
chunks.push(value);
}
} catch {
return null;
} finally {
clearTimeout(timeout);
}
if (timedOut || totalBytes === 0) return null;
const output = new ArrayBuffer(totalBytes);
const bytes = new Uint8Array(output);
let offset = 0;
for (const chunk of chunks) {
bytes.set(chunk, offset);
offset += chunk.byteLength;
}
return output;
}
function iconResponse(body: BodyInit | null, contentType: string | null): Response { function iconResponse(body: BodyInit | null, contentType: string | null): Response {
return new Response(body, { return new Response(body, {
status: 200, status: 200,
@@ -294,19 +268,19 @@ async function handleWebsiteIcon(host: string, fallbackMode: 'default' | 'not-fo
const contentType = String(resp.headers.get('Content-Type') || '').toLowerCase(); const contentType = String(resp.headers.get('Content-Type') || '').toLowerCase();
if (!contentType.startsWith('image/')) continue; if (!contentType.startsWith('image/')) continue;
if (!source.rejectImage) { const contentLength = getPositiveContentLength(resp.headers);
return iconResponse(resp.body, resp.headers.get('Content-Type')); if (contentLength !== null && contentLength > ICON_MAX_BUFFER_BYTES) continue;
}
const contentLength = Number(resp.headers.get('Content-Length') || ''); const bytes = await readIconBytes(resp, ICON_MAX_BUFFER_BYTES);
if (Number.isFinite(contentLength) && contentLength > 0 && contentLength !== source.rejectImage.byteLength) { if (!bytes) continue;
return iconResponse(resp.body, resp.headers.get('Content-Type')); if (
source.rejectImage &&
bytes.byteLength === source.rejectImage.byteLength &&
(await sha256Hex(bytes)) === source.rejectImage.sha256
) {
continue;
} }
const bytes = await resp.arrayBuffer();
if (bytes.byteLength === 0) continue;
if (bytes.byteLength === source.rejectImage.byteLength && (await sha256Hex(bytes)) === source.rejectImage.sha256) continue;
return iconResponse(bytes, resp.headers.get('Content-Type')); return iconResponse(bytes, resp.headers.get('Content-Type'));
} catch { } catch {
continue; continue;
@@ -360,14 +334,10 @@ export async function handlePublicRoute(
return jsonResponse(await buildWebBootstrapResponse(env)); return jsonResponse(await buildWebBootstrapResponse(env));
} }
if (path === '/icons/change-password-uri' && method === 'GET') {
const blocked = await enforcePublicRateLimit('public-read', LIMITS.rateLimit.publicReadRequestsPerMinute);
if (blocked) return blocked;
return handleChangePasswordUri(request);
}
const iconMatch = path.match(/^\/icons\/([^/]+)\/icon\.png$/i); const iconMatch = path.match(/^\/icons\/([^/]+)\/icon\.png$/i);
if (iconMatch && method === 'GET') { if (iconMatch && method === 'GET') {
const blocked = await enforcePublicRateLimit('public-icon', LIMITS.rateLimit.publicIconRequestsPerMinute);
if (blocked) return blocked;
const fallbackMode = new URL(request.url).searchParams.get('fallback') === '404' ? 'not-found' : 'default'; const fallbackMode = new URL(request.url).searchParams.get('fallback') === '404' ? 'not-found' : 'default';
return handleWebsiteIcon(iconMatch[1], fallbackMode); return handleWebsiteIcon(iconMatch[1], fallbackMode);
} }
+22 -21
View File
@@ -6,6 +6,7 @@ import { StorageService } from './storage';
// The client already does heavy PBKDF2 (600k iterations). // The client already does heavy PBKDF2 (600k iterations).
// This second layer only needs to be non-trivial, not expensive. // This second layer only needs to be non-trivial, not expensive.
const SERVER_HASH_ITERATIONS = 100_000; const SERVER_HASH_ITERATIONS = 100_000;
const SERVER_HASH_PREFIX = '$s$';
const AUTH_CONTEXT_CACHE_TTL_MS = 15 * 1000; const AUTH_CONTEXT_CACHE_TTL_MS = 15 * 1000;
interface CachedUserEntry { interface CachedUserEntry {
@@ -133,7 +134,7 @@ export class AuthService {
// Second-layer hash: PBKDF2-SHA256(clientHash, email-salt, iterations). // Second-layer hash: PBKDF2-SHA256(clientHash, email-salt, iterations).
// Ensures database contents alone cannot be used to authenticate (pass-the-hash defense). // Ensures database contents alone cannot be used to authenticate (pass-the-hash defense).
// Result is prefixed with "$s$" to distinguish from legacy raw client hashes. // Result is prefixed to distinguish server-hashed credentials from invalid legacy rows.
async hashPasswordServer(clientHash: string, email: string): Promise<string> { async hashPasswordServer(clientHash: string, email: string): Promise<string> {
const keyMaterial = await crypto.subtle.importKey( const keyMaterial = await crypto.subtle.importKey(
'raw', 'raw',
@@ -151,19 +152,16 @@ export class AuthService {
const bytes = new Uint8Array(bits); const bytes = new Uint8Array(bits);
let binary = ''; let binary = '';
for (const b of bytes) binary += String.fromCharCode(b); for (const b of bytes) binary += String.fromCharCode(b);
return '$s$' + btoa(binary); return SERVER_HASH_PREFIX + btoa(binary);
} }
// Verify password: hash the input the same way, then constant-time compare. // Verify password: hash the input the same way, then constant-time compare.
async verifyPassword(inputHash: string, storedHash: string, email?: string): Promise<boolean> { async verifyPassword(inputHash: string, storedHash: string, email: string): Promise<boolean> {
// New server-hashed passwords are prefixed with "$s$". if (!storedHash.startsWith(SERVER_HASH_PREFIX)) {
// Legacy accounts (created before the upgrade) store raw client hashes without prefix. return false;
if (email && storedHash.startsWith('$s$')) {
const serverHash = await this.hashPasswordServer(inputHash, email);
return this.constantTimeEquals(serverHash, storedHash);
} }
// Legacy path: direct constant-time comparison of raw client hashes. const serverHash = await this.hashPasswordServer(inputHash, email);
return this.constantTimeEquals(inputHash, storedHash); return this.constantTimeEquals(serverHash, storedHash);
} }
private constantTimeEquals(a: string, b: string): boolean { private constantTimeEquals(a: string, b: string): boolean {
@@ -254,19 +252,22 @@ export class AuthService {
} }
let device: { identifier: string; sessionStamp: string } | null = null; let device: { identifier: string; sessionStamp: string } | null = null;
if (record.deviceIdentifier) { if (!record.deviceIdentifier || !record.deviceSessionStamp) {
const boundDevice = await this.storage.getDevice(user.id, record.deviceIdentifier); await this.storage.deleteRefreshToken(refreshToken);
if (!boundDevice) { return { ok: false, reason: 'device_missing', userId: user.id, deviceIdentifier: record.deviceIdentifier };
await this.storage.deleteRefreshToken(refreshToken);
return { ok: false, reason: 'device_missing', userId: user.id, deviceIdentifier: record.deviceIdentifier };
}
if (!record.deviceSessionStamp || boundDevice.sessionStamp !== record.deviceSessionStamp) {
await this.storage.deleteRefreshToken(refreshToken);
return { ok: false, reason: 'device_session_mismatch', userId: user.id, deviceIdentifier: record.deviceIdentifier };
}
device = { identifier: boundDevice.deviceIdentifier, sessionStamp: boundDevice.sessionStamp };
} }
const boundDevice = await this.storage.getDevice(user.id, record.deviceIdentifier);
if (!boundDevice) {
await this.storage.deleteRefreshToken(refreshToken);
return { ok: false, reason: 'device_missing', userId: user.id, deviceIdentifier: record.deviceIdentifier };
}
if (boundDevice.sessionStamp !== record.deviceSessionStamp) {
await this.storage.deleteRefreshToken(refreshToken);
return { ok: false, reason: 'device_session_mismatch', userId: user.id, deviceIdentifier: record.deviceIdentifier };
}
device = { identifier: boundDevice.deviceIdentifier, sessionStamp: boundDevice.sessionStamp };
const accessToken = await this.generateAccessToken(user, device); const accessToken = await this.generateAccessToken(user, device);
return { ok: true, accessToken, user, device }; return { ok: true, accessToken, user, device };
} }
-19
View File
@@ -409,13 +409,6 @@ export async function loadBackupSettings(storage: StorageService, env: Env, fall
export async function saveBackupSettings(storage: StorageService, env: Env, settings: BackupSettings): Promise<void> { export async function saveBackupSettings(storage: StorageService, env: Env, settings: BackupSettings): Promise<void> {
const users = await storage.getAllUsers(); const users = await storage.getAllUsers();
const hasPortableAdmins = users.some(
(user) => user.role === 'admin' && user.status === 'active' && typeof user.publicKey === 'string' && user.publicKey.trim().length > 0
);
if (!hasPortableAdmins) {
await storage.setConfigValue(BACKUP_SETTINGS_CONFIG_KEY, serializeBackupSettings(settings));
return;
}
const encrypted = await encryptBackupSettingsEnvelope(serializeBackupSettings(settings), env, users); const encrypted = await encryptBackupSettingsEnvelope(serializeBackupSettings(settings), env, users);
await storage.setConfigValue(BACKUP_SETTINGS_CONFIG_KEY, encrypted); await storage.setConfigValue(BACKUP_SETTINGS_CONFIG_KEY, encrypted);
} }
@@ -442,12 +435,6 @@ export async function normalizeImportedBackupSettingsValue(
try { try {
const decrypted = await decryptBackupSettingsRuntime(raw, env); const decrypted = await decryptBackupSettingsRuntime(raw, env);
const settings = parseBackupSettings(decrypted, fallbackTimezone); const settings = parseBackupSettings(decrypted, fallbackTimezone);
const hasPortableAdmins = users.some(
(user) => user.role === 'admin' && user.status === 'active' && typeof user.publicKey === 'string' && user.publicKey.trim().length > 0
);
if (!hasPortableAdmins) {
return serializeBackupSettings(settings);
}
return encryptBackupSettingsEnvelope(serializeBackupSettings(settings), env, users); return encryptBackupSettingsEnvelope(serializeBackupSettings(settings), env, users);
} catch { } catch {
// Keep imported portable recovery data intact until an admin signs in and repairs it. // Keep imported portable recovery data intact until an admin signs in and repairs it.
@@ -455,12 +442,6 @@ export async function normalizeImportedBackupSettingsValue(
} }
} }
const settings = parseBackupSettings(raw, fallbackTimezone); const settings = parseBackupSettings(raw, fallbackTimezone);
const hasPortableAdmins = users.some(
(user) => user.role === 'admin' && user.status === 'active' && typeof user.publicKey === 'string' && user.publicKey.trim().length > 0
);
if (!hasPortableAdmins) {
return serializeBackupSettings(settings);
}
return encryptBackupSettingsEnvelope(serializeBackupSettings(settings), env, users); return encryptBackupSettingsEnvelope(serializeBackupSettings(settings), env, users);
} }
+18 -15
View File
@@ -6,6 +6,8 @@ import type { Env, User } from '../types';
// server's scheduled backup runner. // server's scheduled backup runner.
// - portable: AES-GCM encrypted with a random DEK; that DEK is RSA-wrapped for // - portable: AES-GCM encrypted with a random DEK; that DEK is RSA-wrapped for
// active admin public keys so settings can be repaired after restore/migration. // active admin public keys so settings can be repaired after restore/migration.
// Historical/imported databases may not have usable admin public keys; in that
// case portable.wraps is empty but the runtime ciphertext is still encrypted.
// //
// New admin-entered provider secrets, such as mail API keys, should use this // New admin-entered provider secrets, such as mail API keys, should use this
// pattern or a deliberately documented replacement. Do not store provider // pattern or a deliberately documented replacement. Do not store provider
@@ -186,9 +188,6 @@ export async function encryptBackupSettingsEnvelope(
): Promise<string> { ): Promise<string> {
const encoder = new TextEncoder(); const encoder = new TextEncoder();
const eligibleUsers = getEligiblePortableUsers(users); const eligibleUsers = getEligiblePortableUsers(users);
if (!eligibleUsers.length) {
throw new Error('No active administrator public keys are available for backup settings recovery');
}
const runtimeKey = await deriveRuntimeKey(env.JWT_SECRET); const runtimeKey = await deriveRuntimeKey(env.JWT_SECRET);
const runtime = await encryptAesGcm(encoder.encode(plaintext), runtimeKey); const runtime = await encryptAesGcm(encoder.encode(plaintext), runtimeKey);
@@ -205,18 +204,22 @@ export async function encryptBackupSettingsEnvelope(
const wraps: BackupSettingsPortableWrap[] = []; const wraps: BackupSettingsPortableWrap[] = [];
for (const user of eligibleUsers) { for (const user of eligibleUsers) {
const publicKey = await importPortablePublicKey(user.publicKey!); try {
const wrappedKey = new Uint8Array( const publicKey = await importPortablePublicKey(user.publicKey!);
await crypto.subtle.encrypt( const wrappedKey = new Uint8Array(
{ name: PORTABLE_ALGORITHM }, await crypto.subtle.encrypt(
publicKey, { name: PORTABLE_ALGORITHM },
portableDek publicKey,
) portableDek
); )
wraps.push({ );
userId: user.id, wraps.push({
wrappedKey: bytesToBase64(wrappedKey), userId: user.id,
}); wrappedKey: bytesToBase64(wrappedKey),
});
} catch {
// Keep runtime settings usable even if an imported admin key is malformed.
}
} }
const envelope: BackupSettingsEnvelopeV2 = { const envelope: BackupSettingsEnvelopeV2 = {
+1 -36
View File
@@ -28,13 +28,6 @@ export async function getRefreshTokenRecord(
db: D1Database, db: D1Database,
refreshTokenKey: RefreshTokenKeyFn, refreshTokenKey: RefreshTokenKeyFn,
maybeCleanupExpiredRefreshTokens: CleanupExpiredFn, maybeCleanupExpiredRefreshTokens: CleanupExpiredFn,
saveRefreshTokenRecord: (
token: string,
userId: string,
expiresAtMs?: number,
deviceIdentifier?: string | null,
deviceSessionStamp?: string | null
) => Promise<void>,
deleteRefreshTokenRecord: (token: string) => Promise<void>, deleteRefreshTokenRecord: (token: string) => Promise<void>,
token: string token: string
): Promise<RefreshTokenRecord | null> { ): Promise<RefreshTokenRecord | null> {
@@ -42,39 +35,11 @@ export async function getRefreshTokenRecord(
await maybeCleanupExpiredRefreshTokens(now); await maybeCleanupExpiredRefreshTokens(now);
const tokenKey = await refreshTokenKey(token); const tokenKey = await refreshTokenKey(token);
let row = await db const row = await db
.prepare('SELECT user_id, expires_at, device_identifier, device_session_stamp FROM refresh_tokens WHERE token = ?') .prepare('SELECT user_id, expires_at, device_identifier, device_session_stamp FROM refresh_tokens WHERE token = ?')
.bind(tokenKey) .bind(tokenKey)
.first<{ user_id: string; expires_at: number; device_identifier: string | null; device_session_stamp: string | null }>(); .first<{ user_id: string; expires_at: number; device_identifier: string | null; device_session_stamp: string | null }>();
if (!row) {
const legacyRow = await db
.prepare('SELECT user_id, expires_at, device_identifier, device_session_stamp FROM refresh_tokens WHERE token = ?')
.bind(token)
.first<{ user_id: string; expires_at: number; device_identifier: string | null; device_session_stamp: string | null }>();
if (legacyRow) {
if (legacyRow.expires_at && legacyRow.expires_at < now) {
await deleteRefreshTokenRecord(token);
return null;
}
await saveRefreshTokenRecord(
token,
legacyRow.user_id,
legacyRow.expires_at,
legacyRow.device_identifier ?? null,
legacyRow.device_session_stamp ?? null
);
await db.prepare('DELETE FROM refresh_tokens WHERE token = ?').bind(token).run();
return {
userId: legacyRow.user_id,
expiresAt: legacyRow.expires_at,
deviceIdentifier: legacyRow.device_identifier ?? null,
deviceSessionStamp: legacyRow.device_session_stamp ?? null,
};
}
}
if (!row) return null; if (!row) return null;
if (row.expires_at && row.expires_at < now) { if (row.expires_at && row.expires_at < now) {
await deleteRefreshTokenRecord(token); await deleteRefreshTokenRecord(token);
-1
View File
@@ -485,7 +485,6 @@ export class StorageService {
this.db, this.db,
this.refreshTokenKey.bind(this), this.refreshTokenKey.bind(this),
this.maybeCleanupExpiredRefreshTokens.bind(this), this.maybeCleanupExpiredRefreshTokens.bind(this),
this.saveRefreshToken.bind(this),
this.deleteRefreshToken.bind(this), this.deleteRefreshToken.bind(this),
token token
); );
-1
View File
@@ -10,7 +10,6 @@ export interface Env {
// Optional fallback for attachment/send file storage (no credit card required). // Optional fallback for attachment/send file storage (no credit card required).
ATTACHMENTS_KV?: KVNamespace; ATTACHMENTS_KV?: KVNamespace;
JWT_SECRET: string; JWT_SECRET: string;
TOTP_SECRET?: string;
} }
export type UserRole = 'admin' | 'user'; export type UserRole = 'admin' | 'user';
+3 -1
View File
@@ -146,7 +146,9 @@ function resolveSystemTheme(): 'light' | 'dark' {
function readLockTimeoutMinutes(): LockTimeoutMinutes { function readLockTimeoutMinutes(): LockTimeoutMinutes {
if (typeof window === 'undefined') return 15; if (typeof window === 'undefined') return 15;
const value = Number(window.localStorage.getItem(LOCK_TIMEOUT_STORAGE_KEY)); const stored = window.localStorage.getItem(LOCK_TIMEOUT_STORAGE_KEY);
if (stored === null || stored.trim() === '') return 15;
const value = Number(stored);
return LOCK_TIMEOUT_VALUES.has(value as LockTimeoutMinutes) ? (value as LockTimeoutMinutes) : 15; return LOCK_TIMEOUT_VALUES.has(value as LockTimeoutMinutes) ? (value as LockTimeoutMinutes) : 15;
} }
+3
View File
@@ -841,6 +841,9 @@ async function buildCipherPayload(
cipher?.login && typeof cipher.login === 'object' cipher?.login && typeof cipher.login === 'object'
? { ...(cipher.login as Record<string, unknown>) } ? { ...(cipher.login as Record<string, unknown>) }
: {}; : {};
delete existingLogin.decUsername;
delete existingLogin.decPassword;
delete existingLogin.decTotp;
payload.login = { payload.login = {
...existingLogin, ...existingLogin,
username: await encryptTextValue(draft.loginUsername, keys.enc, keys.mac), username: await encryptTextValue(draft.loginUsername, keys.enc, keys.mac),