Refactor JWT_SECRET handling and add setup warning page

This commit is contained in:
shuaiplus
2026-02-08 21:27:13 +08:00
parent f13ba90ebe
commit 5fc2436552
12 changed files with 1024 additions and 780 deletions
+3 -1
View File
@@ -1,3 +1,5 @@
# JWT Secret for signing tokens (required) # JWT Secret for signing tokens (required)
# IMPORTANT: change this value before any real deployment.
# Generate one with: openssl rand -hex 32 # Generate one with: openssl rand -hex 32
JWT_SECRET=your-secret-key-herexxs22fd2ds # (Example only, 64 hex chars = 32 bytes)
JWT_SECRET=Enter-your-JWT-key-here-at-least-32-characters
+1 -1
View File
@@ -1,7 +1,7 @@
# NodeWarden # NodeWarden
中文文档:[`README_ZH.md`](./README_ZH.md) 中文文档:[`README_ZH.md`](./README_ZH.md)
A **Bitwarden-compatible** server that runs on **Cloudflare Workers**, designed for personal use. A **Bitwarden-compatible** server that runs on **Cloudflare Workers**.
- Simple deploy (no VPS) - Simple deploy (no VPS)
- Focused feature set - Focused feature set
+1 -1
View File
@@ -2,7 +2,7 @@
# NodeWarden # NodeWarden
English[`README.md`](./README.md) English[`README.md`](./README.md)
一个运行在 **Cloudflare Workers** 上的 **Bitwarden 兼容**服务端实现,面向个人使用场景 一个运行在 **Cloudflare Workers** 上的 **Bitwarden 兼容**服务端实现。
- 部署简单(不需要 VPS - 部署简单(不需要 VPS
- 功能聚焦 - 功能聚焦
+1 -1
View File
@@ -7,7 +7,7 @@
"main": "src/index.ts", "main": "src/index.ts",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "wrangler dev -c wrangler.dev.toml", "dev": "wrangler dev -c wrangler.toml",
"deploymy": "wrangler deploy -c wrangler.my.toml", "deploymy": "wrangler deploy -c wrangler.my.toml",
"deploy": "wrangler deploy " "deploy": "wrangler deploy "
}, },
+20 -1
View File
@@ -1,13 +1,32 @@
import { Env, User, ProfileResponse } from '../types'; import { Env, User, ProfileResponse, DEFAULT_DEV_SECRET } from '../types';
import { StorageService } from '../services/storage'; import { StorageService } from '../services/storage';
import { AuthService } from '../services/auth'; import { AuthService } from '../services/auth';
import { jsonResponse, errorResponse } from '../utils/response'; import { jsonResponse, errorResponse } from '../utils/response';
import { generateUUID } from '../utils/uuid'; import { generateUUID } from '../utils/uuid';
function jwtSecretUnsafeReason(env: Env): 'missing' | 'default' | 'too_short' | null {
const secret = (env.JWT_SECRET || '').trim();
if (!secret) return 'missing';
if (secret === DEFAULT_DEV_SECRET) return 'default';
if (secret.length < 32) return 'too_short';
return null;
}
// POST /api/accounts/register (only used from setup page, not client) // POST /api/accounts/register (only used from setup page, not client)
export async function handleRegister(request: Request, env: Env): Promise<Response> { export async function handleRegister(request: Request, env: Env): Promise<Response> {
const storage = new StorageService(env.VAULT); const storage = new StorageService(env.VAULT);
// Enforce safe JWT_SECRET before allowing first registration.
const unsafe = jwtSecretUnsafeReason(env);
if (unsafe) {
const message = unsafe === 'missing'
? 'JWT_SECRET is not set'
: unsafe === 'default'
? 'JWT_SECRET is using the default/sample value. Please change it.'
: 'JWT_SECRET must be at least 32 characters';
return errorResponse(message, 400);
}
// Check if already registered // Check if already registered
const isRegistered = await storage.isRegistered(); const isRegistered = await storage.isRegistered();
if (isRegistered) { if (isRegistered) {
+21 -765
View File
@@ -1,769 +1,17 @@
import { Env } from '../types'; import { Env, DEFAULT_DEV_SECRET } from '../types';
import { StorageService } from '../services/storage'; import { StorageService } from '../services/storage';
import { jsonResponse, htmlResponse, errorResponse } from '../utils/response'; import { jsonResponse, htmlResponse, errorResponse } from '../utils/response';
import { renderJwtSecretWarningPage, JwtSecretState } from './setupPages';
// Setup page HTML (single-file, no external assets) import { handleRegisterPage } from './setupRegisterPage';
const setupPageHTML = `<!DOCTYPE html>
<html lang="en"> function getJwtSecretState(env: Env): JwtSecretState | null {
<head> const secret = (env.JWT_SECRET || '').trim();
<meta charset="UTF-8"> if (!secret) return 'missing';
<meta name="viewport" content="width=device-width, initial-scale=1.0"> // Block common "forgot to change" sample value (matches .dev.vars.example)
<title>NodeWarden</title> if (secret === DEFAULT_DEV_SECRET) return 'default';
<style> if (secret.length < 32) return 'too_short';
:root { return null;
color-scheme: light; }
--bg0: #0b0b0f;
--bg1: #0f1020;
--card: rgba(255, 255, 255, 0.08);
--card2: rgba(255, 255, 255, 0.06);
--border: rgba(255, 255, 255, 0.14);
--text: rgba(255, 255, 255, 0.92);
--muted: rgba(255, 255, 255, 0.62);
--muted2: rgba(255, 255, 255, 0.52);
--accent: #0a84ff;
--accent2: #64d2ff;
--danger: #ff453a;
--ok: #32d74b;
--shadow: 0 16px 60px rgba(0, 0, 0, 0.50);
--radius: 18px;
--radius2: 14px;
--mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
}
* { box-sizing: border-box; }
html, body { height: 100%; }
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text", "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
background:
radial-gradient(900px 600px at 15% 10%, rgba(100, 210, 255, 0.25), transparent 60%),
radial-gradient(900px 600px at 85% 20%, rgba(10, 132, 255, 0.22), transparent 60%),
radial-gradient(900px 600px at 50% 90%, rgba(50, 215, 75, 0.10), transparent 60%),
linear-gradient(180deg, var(--bg0), var(--bg1));
color: var(--text);
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
}
.shell {
width: max(500px);
}
@media (max-width: 860px) {
.shell { grid-template-columns: 1fr; }
}
.hero {
padding: 26px;
border: 1px solid var(--border);
background: linear-gradient(180deg, rgba(255,255,255,0.10), rgba(255,255,255,0.06));
border-radius: var(--radius);
box-shadow: var(--shadow);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
overflow: hidden;
position: relative;
}
.hero::before {
content: "";
position: absolute;
inset: -2px;
background: radial-gradient(500px 240px at 20% 0%, rgba(100, 210, 255, 0.18), transparent 60%);
pointer-events: none;
}
.top {
display: flex;
gap: 14px;
align-items: center;
margin-bottom: 14px;
}
.mark {
width: 46px;
height: 46px;
border-radius: 14px;
background: linear-gradient(135deg, rgba(10,132,255,0.85), rgba(100,210,255,0.55));
border: 1px solid rgba(255,255,255,0.20);
box-shadow: 0 10px 40px rgba(10, 132, 255, 0.30);
display: flex;
align-items: center;
justify-content: center;
font-weight: 800;
letter-spacing: 1px;
color: rgba(255,255,255,0.96);
text-transform: uppercase;
user-select: none;
}
.title {
display: flex;
flex-direction: column;
gap: 4px;
}
.title h1 {
font-size: 22px;
margin: 0;
letter-spacing: -0.3px;
}
.title p {
margin: 0;
color: var(--muted);
font-size: 13px;
line-height: 1.5;
}
.panel {
padding: 22px;
border: 1px solid var(--border);
background: rgba(255,255,255,0.06);
border-radius: var(--radius);
box-shadow: var(--shadow);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
}
.panel h2 {
font-size: 16px;
margin: 0 0 14px 0;
letter-spacing: -0.2px;
}
.message {
display: none;
border-radius: 14px;
padding: 12px 12px;
margin: 0 0 12px 0;
font-size: 13px;
line-height: 1.45;
border: 1px solid rgba(255,255,255,0.14);
background: rgba(255,255,255,0.06);
}
.message.error {
display: block;
border-color: rgba(255, 69, 58, 0.40);
background: rgba(255, 69, 58, 0.10);
color: rgba(255, 255, 255, 0.92);
}
.message.success {
display: block;
border-color: rgba(50, 215, 75, 0.35);
background: rgba(50, 215, 75, 0.10);
color: rgba(255, 255, 255, 0.92);
}
.grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
@media (max-width: 540px) {
.grid { grid-template-columns: 1fr; }
}
.field {
display: flex;
flex-direction: column;
gap: 7px;
}
label {
font-size: 12px;
color: var(--muted);
letter-spacing: 0.2px;
}
input {
height: 42px;
padding: 0 12px;
border-radius: 12px;
border: 1px solid rgba(255,255,255,0.18);
background: rgba(0,0,0,0.18);
color: rgba(255,255,255,0.92);
outline: none;
font-size: 14px;
transition: border-color 160ms ease, box-shadow 160ms ease;
}
input::placeholder { color: rgba(255,255,255,0.35); }
input:focus {
border-color: rgba(10, 132, 255, 0.55);
box-shadow: 0 0 0 6px rgba(10, 132, 255, 0.12);
}
.hint {
margin: 0;
color: var(--muted2);
font-size: 12px;
line-height: 1.55;
}
.actions {
margin-top: 12px;
display: flex;
gap: 10px;
align-items: center;
}
.primary {
width: 100%;
height: 44px;
border-radius: 14px;
border: 1px solid rgba(255,255,255,0.18);
background: linear-gradient(135deg, rgba(10,132,255,0.95), rgba(100,210,255,0.60));
color: rgba(255,255,255,0.96);
font-weight: 700;
letter-spacing: 0.2px;
cursor: pointer;
transition: transform 120ms ease, filter 120ms ease;
}
.primary:hover { filter: brightness(1.03); }
.primary:active { transform: translateY(1px) scale(0.99); }
.primary:disabled {
opacity: 0.55;
cursor: not-allowed;
transform: none;
}
.sideCard {
display: flex;
flex-direction: column;
gap: 12px;
}
.kv {
border-radius: var(--radius2);
border: 1px solid rgba(255,255,255,0.14);
background: rgba(255,255,255,0.05);
padding: 14px;
margin-bottom: 10px;
}
.kv h3 {
margin: 0 0 8px 0;
font-size: 13px;
color: rgba(255,255,255,0.86);
}
.kv p {
margin: 0;
font-size: 12px;
line-height: 1.55;
color: var(--muted);
}
.server {
margin-top: 10px;
font-family: var(--mono);
font-size: 12px;
padding: 10px 12px;
border-radius: 12px;
background: rgba(0,0,0,0.25);
border: 1px solid rgba(255,255,255,0.12);
word-break: break-all;
color: rgba(255,255,255,0.90);
}
a {
color: rgba(100, 210, 255, 0.92);
text-decoration: none;
}
a:hover { text-decoration: underline; }
.footer {
margin-top: 18px;
padding-top: 14px;
border-top: 1px solid rgba(255,255,255,0.10);
display: flex;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
font-size: 12px;
color: rgba(255,255,255,0.55);
}
.muted { color: var(--muted); }
</style>
</head>
<body>
<div class="shell">
<aside class="panel">
<div class="top">
<div class="mark" aria-label="NodeWarden">NW</div>
<div class="title">
<h1 id="t_app">NodeWarden</h1>
<p id="t_tag">部署在 Cloudflare Workers 上的 Bitwarden 兼容服务端(个人使用)。</p>
</div>
</div>
<div style="height: 12px"></div>
<div class="muted" id="t_intro" style="font-size: 13px; line-height: 1.7;">
创建第一个账号完成初始化,然后用任意 Bitwarden 官方客户端登录。
</div>
<div style="height: 14px"></div>
<h2 id="t_setup">初始化</h2>
<div id="message" class="message"></div>
<div id="setup-form">
<form id="form" onsubmit="handleSubmit(event)">
<div class="grid">
<div class="field">
<label for="name" id="t_name_label">Name</label>
<input type="text" id="name" name="name" required placeholder="Your name">
</div>
<div class="field">
<label for="email" id="t_email_label">Email</label>
<input type="email" id="email" name="email" required placeholder="you@example.com" autocomplete="email">
</div>
</div>
<div style="height: 10px"></div>
<div class="field">
<label for="password" id="t_pw_label">Master password</label>
<input type="password" id="password" name="password" required minlength="12" placeholder="At least 12 characters" autocomplete="new-password">
<p class="hint" id="t_pw_hint">Choose a strong password you can remember. The server cannot recover it.</p>
</div>
<div style="height: 10px"></div>
<div class="field">
<label for="confirmPassword" id="t_pw2_label">Confirm password</label>
<input type="password" id="confirmPassword" name="confirmPassword" required placeholder="Confirm password" autocomplete="new-password">
</div>
<div class="actions">
<button type="submit" id="submitBtn" class="primary">Create account</button>
</div>
</form>
</div>
<div id="registered-view" class="sideCard" style="display: none;">
<div class="kv">
<h3 id="t_done_title">Setup complete</h3>
<p id="t_done_desc">Your server is ready. Configure your Bitwarden client with this server URL:</p>
<div class="server" id="serverUrl"></div>
</div>
<div class="kv">
<h3 id="t_important">Important</h3>
<p id="t_limitations">
This project is designed for a single user. You cannot add new users. Changing the master password is not supported.
If you forget it, you must redeploy and register again.
</p>
</div>
<div class="kv">
<h3 id="t_hide_title">Hide setup page</h3>
<p id="t_hide_desc">After hiding, this setup page will return 404 for everyone. Your vault will keep working.</p>
<div class="actions">
<button type="button" id="hideBtn" class="primary" onclick="disableSetupPage()">Hide setup page</button>
</div>
</div>
</div>
<div class="footer">
<div>
<span class="muted" id="t_by">By</span>
<a href="https://shuai.plus" target="_blank" rel="noreferrer">shuaiplus</a>
</div>
<div>
<a href="https://github.com/shuaiplus/nodewarden" target="_blank" rel="noreferrer">GitHub</a>
</div>
</div>
</aside>
</div>
<script>
const AUTHOR = { name: 'shuaiplus', website: 'https://shuai.plus', github: 'https://github.com/shuaiplus/nodewarden' };
let isRegistered = false;
function isChinese() {
const lang = (navigator.language || '').toLowerCase();
return lang.startsWith('zh');
}
function t(key) {
const zh = {
app: 'NodeWarden',
tag: '部署在 Cloudflare Workers 上的 Bitwarden 兼容服务端(个人使用)。',
intro: '创建第一个账号完成初始化,然后用任意 Bitwarden 官方客户端登录。',
by: '作者',
setup: '初始化',
nameLabel: '昵称',
emailLabel: '邮箱',
pwLabel: '主密码',
pwHint: '请选择你能记住的强密码。服务器无法找回主密码。',
pw2Label: '确认主密码',
create: '创建账号',
creating: '正在创建…',
doneTitle: '初始化完成',
doneDesc: '服务已就绪。在 Bitwarden 客户端中填入以下服务器地址:',
important: '重要提示',
limitations: '本项目仅支持单用户:不能添加新用户;不支持修改主密码;如果忘记主密码,只能重新部署并重新注册。',
hideTitle: '隐藏初始化页',
hideDesc: '隐藏后,初始化页对任何人都会直接返回 404。你的密码库仍可正常使用。',
hideBtn: '隐藏初始化页',
hideWorking: '正在隐藏…',
hideDone: '已隐藏,此页面将返回 404。',
hideFailed: '隐藏失败',
hideConfirm: '确认隐藏初始化页?隐藏后页面将不可访问,但你的密码库不会受影响。',
errPwNotMatch: '两次输入的密码不一致',
errPwTooShort: '密码长度至少 12 位',
errGeneric: '发生错误:',
errRegisterFailed: '注册失败',
};
const en = {
app: 'NodeWarden',
tag: 'Minimal Bitwarden-compatible server on Cloudflare Workers (personal use).',
intro: 'Create your first account to finish setup. Then use any official Bitwarden client to sign in.',
by: 'By',
setup: 'Setup',
nameLabel: 'Name',
emailLabel: 'Email',
pwLabel: 'Master password',
pwHint: 'Choose a strong password you can remember. The server cannot recover it.',
pw2Label: 'Confirm password',
create: 'Create account',
creating: 'Creating…',
doneTitle: 'Setup complete',
doneDesc: 'Your server is ready. Configure your Bitwarden client with this server URL:',
important: 'Important',
limitations: 'Single user only: you cannot add new users. Changing the master password is not supported. If you forget it, redeploy and register again.',
hideTitle: 'Hide setup page',
hideDesc: 'After hiding, this setup page will return 404 for everyone. Your vault will keep working.',
hideBtn: 'Hide setup page',
hideWorking: 'Hiding…',
hideDone: 'Hidden. This page will now return 404.',
hideFailed: 'Failed to hide setup page',
hideConfirm: 'Hide the setup page? It will no longer be accessible, but your vault will keep working.',
errPwNotMatch: 'Passwords do not match',
errPwTooShort: 'Password must be at least 12 characters',
errGeneric: 'An error occurred: ',
errRegisterFailed: 'Registration failed',
};
return (isChinese() ? zh : en)[key];
}
function applyI18n() {
document.documentElement.lang = isChinese() ? 'zh-CN' : 'en';
document.getElementById('t_app').textContent = t('app');
document.getElementById('t_tag').textContent = t('tag');
document.getElementById('t_intro').textContent = t('intro');
document.getElementById('t_by').textContent = t('by');
document.getElementById('t_setup').textContent = t('setup');
document.getElementById('t_name_label').textContent = t('nameLabel');
document.getElementById('t_email_label').textContent = t('emailLabel');
document.getElementById('t_pw_label').textContent = t('pwLabel');
document.getElementById('t_pw_hint').textContent = t('pwHint');
document.getElementById('t_pw2_label').textContent = t('pw2Label');
document.getElementById('submitBtn').textContent = t('create');
document.getElementById('t_done_title').textContent = t('doneTitle');
document.getElementById('t_done_desc').textContent = t('doneDesc');
document.getElementById('t_important').textContent = t('important');
document.getElementById('t_limitations').textContent = t('limitations');
document.getElementById('t_hide_title').textContent = t('hideTitle');
document.getElementById('t_hide_desc').textContent = t('hideDesc');
document.getElementById('hideBtn').textContent = t('hideBtn');
}
// Check if already registered
async function checkStatus() {
try {
const res = await fetch('/setup/status');
const data = await res.json();
isRegistered = !!data.registered;
if (data.registered) {
showRegisteredView();
}
} catch (e) {
console.error('Failed to check status:', e);
}
}
function showRegisteredView() {
isRegistered = true;
document.getElementById('setup-form').style.display = 'none';
document.getElementById('registered-view').style.display = 'block';
document.getElementById('serverUrl').textContent = window.location.origin;
showMessage(t('doneTitle'), 'success');
const form = document.getElementById('form');
if (form) {
const fields = form.querySelectorAll('input, button');
fields.forEach((el) => {
el.disabled = true;
});
}
}
async function disableSetupPage() {
if (!isRegistered) return;
if (!confirm(t('hideConfirm'))) return;
const btn = document.getElementById('hideBtn');
if (btn) {
btn.disabled = true;
btn.textContent = t('hideWorking');
}
try {
const res = await fetch('/setup/disable', { method: 'POST' });
const data = await res.json();
if (res.ok && data.success) {
showMessage(t('hideDone'), 'success');
setTimeout(() => window.location.reload(), 600);
return;
}
showMessage(data.error || t('hideFailed'), 'error');
} catch (e) {
showMessage(t('hideFailed'), 'error');
}
if (btn) {
btn.disabled = false;
btn.textContent = t('hideBtn');
}
}
function showMessage(text, type) {
const msg = document.getElementById('message');
msg.textContent = text;
msg.className = 'message ' + type;
}
// PBKDF2-SHA256 key derivation (compatible with Bitwarden)
// password can be string or Uint8Array
async function pbkdf2(password, salt, iterations, keyLen) {
const encoder = new TextEncoder();
// Handle password as string or Uint8Array
const passwordBytes = (password instanceof Uint8Array)
? password
: encoder.encode(password);
// Handle salt as string or Uint8Array
const saltBytes = (salt instanceof Uint8Array)
? salt
: encoder.encode(salt);
const keyMaterial = await crypto.subtle.importKey(
'raw',
passwordBytes,
'PBKDF2',
false,
['deriveBits']
);
const derivedBits = await crypto.subtle.deriveBits(
{
name: 'PBKDF2',
salt: saltBytes,
iterations: iterations,
hash: 'SHA-256'
},
keyMaterial,
keyLen * 8
);
return new Uint8Array(derivedBits);
}
// HKDF expand
async function hkdfExpand(prk, info, length) {
const encoder = new TextEncoder();
const key = await crypto.subtle.importKey(
'raw',
prk,
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
);
const infoBytes = encoder.encode(info);
const result = new Uint8Array(length);
let prev = new Uint8Array(0);
let offset = 0;
let counter = 1;
while (offset < length) {
const input = new Uint8Array(prev.length + infoBytes.length + 1);
input.set(prev);
input.set(infoBytes, prev.length);
input[input.length - 1] = counter;
const signature = await crypto.subtle.sign('HMAC', key, input);
prev = new Uint8Array(signature);
const toCopy = Math.min(prev.length, length - offset);
result.set(prev.slice(0, toCopy), offset);
offset += toCopy;
counter++;
}
return result;
}
// Generate symmetric key
function generateSymmetricKey() {
return crypto.getRandomValues(new Uint8Array(64));
}
// Encrypt with AES-256-CBC
async function encryptAesCbc(data, key, iv) {
const cryptoKey = await crypto.subtle.importKey(
'raw',
key,
{ name: 'AES-CBC' },
false,
['encrypt']
);
const encrypted = await crypto.subtle.encrypt(
{ name: 'AES-CBC', iv: iv },
cryptoKey,
data
);
return new Uint8Array(encrypted);
}
// HMAC-SHA256
async function hmacSha256(key, data) {
const cryptoKey = await crypto.subtle.importKey(
'raw',
key,
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
);
const signature = await crypto.subtle.sign('HMAC', cryptoKey, data);
return new Uint8Array(signature);
}
// Base64 encode
function base64Encode(bytes) {
return btoa(String.fromCharCode.apply(null, bytes));
}
// Create encrypted string in Bitwarden format
async function encryptToBitwardenFormat(data, encKey, macKey) {
const iv = crypto.getRandomValues(new Uint8Array(16));
const encrypted = await encryptAesCbc(data, encKey, iv);
// Calculate MAC over IV + encrypted data
const macData = new Uint8Array(iv.length + encrypted.length);
macData.set(iv);
macData.set(encrypted, iv.length);
const mac = await hmacSha256(macKey, macData);
// Format: 2.{base64(iv)}|{base64(encrypted)}|{base64(mac)}
return '2.' + base64Encode(iv) + '|' + base64Encode(encrypted) + '|' + base64Encode(mac);
}
// Generate RSA key pair
async function generateRsaKeyPair() {
const keyPair = await crypto.subtle.generateKey(
{
name: 'RSA-OAEP',
modulusLength: 2048,
publicExponent: new Uint8Array([1, 0, 1]),
hash: 'SHA-1'
},
true,
['encrypt', 'decrypt']
);
// Export public key
const publicKeySpki = await crypto.subtle.exportKey('spki', keyPair.publicKey);
const publicKeyB64 = base64Encode(new Uint8Array(publicKeySpki));
// Export private key
const privateKeyPkcs8 = await crypto.subtle.exportKey('pkcs8', keyPair.privateKey);
const privateKeyBytes = new Uint8Array(privateKeyPkcs8);
return {
publicKey: publicKeyB64,
privateKey: privateKeyBytes
};
}
async function handleSubmit(event) {
event.preventDefault();
if (isRegistered) {
showMessage(t('doneTitle'), 'success');
return;
}
const name = document.getElementById('name').value;
const email = document.getElementById('email').value.toLowerCase();
const password = document.getElementById('password').value;
const confirmPassword = document.getElementById('confirmPassword').value;
if (password !== confirmPassword) {
showMessage(t('errPwNotMatch'), 'error');
return;
}
if (password.length < 12) {
showMessage(t('errPwTooShort'), 'error');
return;
}
const btn = document.getElementById('submitBtn');
btn.disabled = true;
btn.textContent = t('creating');
try {
// Generate master key using PBKDF2 (Bitwarden default: 600000 iterations)
const iterations = 600000;
const masterKey = await pbkdf2(password, email, iterations, 32);
// Generate master password hash (for authentication)
// Bitwarden: PBKDF2(masterKey as raw bytes, password, 1 iteration)
const masterPasswordHash = await pbkdf2(masterKey, password, 1, 32);
const masterPasswordHashB64 = base64Encode(masterPasswordHash);
// Stretch master key using HKDF
const stretchedKey = await hkdfExpand(masterKey, 'enc', 32);
const stretchedMacKey = await hkdfExpand(masterKey, 'mac', 32);
// Generate symmetric key (will be encrypted with stretched master key)
const symmetricKey = generateSymmetricKey();
// Encrypt symmetric key with stretched master key
const encryptedKey = await encryptToBitwardenFormat(symmetricKey, stretchedKey, stretchedMacKey);
// Generate RSA key pair
const rsaKeys = await generateRsaKeyPair();
// Encrypt private key with symmetric key
const encryptedPrivateKey = await encryptToBitwardenFormat(rsaKeys.privateKey, symmetricKey.slice(0, 32), symmetricKey.slice(32, 64));
// Register with server
const response = await fetch('/api/accounts/register', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
email: email,
name: name,
masterPasswordHash: masterPasswordHashB64,
key: encryptedKey,
kdf: 0,
kdfIterations: iterations,
keys: {
publicKey: rsaKeys.publicKey,
encryptedPrivateKey: encryptedPrivateKey
}
})
});
const result = await response.json();
if (response.ok && result.success) {
showRegisteredView();
} else {
showMessage(result.error || result.ErrorModel?.Message || t('errRegisterFailed'), 'error');
btn.disabled = false;
btn.textContent = t('create');
}
} catch (error) {
console.error('Registration error:', error);
showMessage(t('errGeneric') + (error && error.message ? error.message : String(error)), 'error');
btn.disabled = false;
btn.textContent = t('create');
}
}
// Check status on page load
applyI18n();
checkStatus();
</script>
</body>
</html>`;
// GET / - Setup page // GET / - Setup page
export async function handleSetupPage(request: Request, env: Env): Promise<Response> { export async function handleSetupPage(request: Request, env: Env): Promise<Response> {
@@ -772,7 +20,15 @@ export async function handleSetupPage(request: Request, env: Env): Promise<Respo
if (disabled) { if (disabled) {
return new Response(null, { status: 404 }); return new Response(null, { status: 404 });
} }
return htmlResponse(setupPageHTML);
// Guard: require a strong JWT_SECRET before allowing setup/registration.
const jwtState = getJwtSecretState(env);
if (jwtState) {
return htmlResponse(renderJwtSecretWarningPage(request, jwtState), 200);
}
// Serve the registration/setup UI (split into a dedicated module).
return handleRegisterPage(request, env);
} }
// GET /setup/status // GET /setup/status
+290
View File
@@ -0,0 +1,290 @@
import { Env } from '../types';
// NOTE: Kept as a single file with inline HTML/CSS to avoid external assets.
// This file splits the old monolithic setup page into reusable page generators.
type Lang = 'zh' | 'en';
function isChineseFromRequest(request: Request): boolean {
const acceptLang = (request.headers.get('accept-language') || '').toLowerCase();
return acceptLang.includes('zh');
}
function t(lang: Lang, key: string): string {
const zh: Record<string, string> = {
app: 'NodeWarden',
tag: '部署在 Cloudflare Workers 上的 Bitwarden 兼容服务端。',
// Config warning page
cfgTitle: '需要配置 JWT_SECRET',
cfgDescMissing: '当前服务没有配置 JWT_SECRET(用于签名登录令牌)。为了安全起见,必须先配置后才能注册/使用。',
cfgDescDefault: '检测到你正在使用示例/默认 JWT_SECRET。为了安全起见,请先修改为随机强密钥后再注册/使用。',
cfgDescTooShort: '检测到 JWT_SECRET 长度不足 32 个字符。为了安全起见,请使用至少 32 位的随机字符串。',
cfgStepsTitle: '如何在 Cloudflare 修改 JWT_SECRET',
cfgSteps: '打开 Cloudflare 控制台 → Workers 和 Pages → 选择 nodewarden → 设置 → 变量和机密 → 添加变量。\n类型:密钥\n名称:JWT_SECRET\n值:粘贴你生成的随机密钥\n保存后,等待重新部署生效。',
cfgGenTitle: '随机密钥生成器',
cfgGenHint: '建议长度:至少 32 字符(推荐 64+)。点击刷新生成新的随机值。',
cfgCopy: '复制',
cfgRefresh: '刷新',
// Shared
by: '作者',
github: 'GitHub',
};
const en: Record<string, string> = {
app: 'NodeWarden',
tag: 'Minimal Bitwarden-compatible server on Cloudflare Workers.',
// Config warning page
cfgTitle: 'JWT_SECRET is required',
cfgDescMissing: 'This server has no JWT_SECRET configured (used to sign login tokens). For safety, you must configure it before registration/usage.',
cfgDescDefault: 'You are using the sample/default JWT_SECRET. For safety, please change it to a strong random secret before registration/usage.',
cfgDescTooShort: 'JWT_SECRET is shorter than 32 characters. For safety, use a random string with at least 32 characters.',
cfgStepsTitle: 'How to set JWT_SECRET in Cloudflare',
cfgSteps: 'Open Cloudflare Dashboard → Workers & Pages → select nodewarden → Settings → Variables and Secrets → Add variable.\nType: Secret\nName: JWT_SECRET\nValue: paste a random secret\nSave, and wait for redeploy to take effect.',
cfgGenTitle: 'Random secret generator',
cfgGenHint: 'Recommended length: 32+ characters (64+ preferred). Click refresh to generate a new one.',
cfgCopy: 'Copy',
cfgRefresh: 'Refresh',
// Shared
by: 'By',
github: 'GitHub',
};
return (lang === 'zh' ? zh : en)[key] ?? key;
}
function baseStyles(): string {
// Keep consistent with existing setup page look & feel.
return `
:root {
color-scheme: light;
--bg0: #0b0b0f;
--bg1: #0f1020;
--card: rgba(255, 255, 255, 0.08);
--card2: rgba(255, 255, 255, 0.06);
--border: rgba(255, 255, 255, 0.14);
--text: rgba(255, 255, 255, 0.92);
--muted: rgba(255, 255, 255, 0.62);
--muted2: rgba(255, 255, 255, 0.52);
--accent: #0a84ff;
--accent2: #64d2ff;
--danger: #ff453a;
--ok: #32d74b;
--shadow: 0 16px 60px rgba(0, 0, 0, 0.50);
--radius: 18px;
--radius2: 14px;
--mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
}
* { box-sizing: border-box; }
html, body { height: 100%; }
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text", "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
background:
radial-gradient(900px 600px at 15% 10%, rgba(100, 210, 255, 0.25), transparent 60%),
radial-gradient(900px 600px at 85% 20%, rgba(10, 132, 255, 0.22), transparent 60%),
radial-gradient(900px 600px at 50% 90%, rgba(50, 215, 75, 0.10), transparent 60%),
linear-gradient(180deg, var(--bg0), var(--bg1));
color: var(--text);
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
}
.shell { width: max(500px); }
.panel {
padding: 22px;
border: 1px solid var(--border);
background: rgba(255,255,255,0.06);
border-radius: var(--radius);
box-shadow: var(--shadow);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
}
.top {
display: flex;
gap: 14px;
align-items: center;
margin-bottom: 14px;
}
.mark {
width: 46px;
height: 46px;
border-radius: 14px;
background: linear-gradient(135deg, rgba(10,132,255,0.85), rgba(100,210,255,0.55));
border: 1px solid rgba(255,255,255,0.20);
box-shadow: 0 10px 40px rgba(10, 132, 255, 0.30);
display: flex;
align-items: center;
justify-content: center;
font-weight: 800;
letter-spacing: 1px;
color: rgba(255,255,255,0.96);
text-transform: uppercase;
user-select: none;
}
.title { display: flex; flex-direction: column; gap: 4px; }
.title h1 { font-size: 22px; margin: 0; letter-spacing: -0.3px; }
.title p { margin: 0; color: var(--muted); font-size: 13px; line-height: 1.5; }
h2 { font-size: 16px; margin: 14px 0 10px 0; letter-spacing: -0.2px; }
.lead { font-size: 13px; line-height: 1.7; color: rgba(255,255,255,0.86); }
.kv {
border-radius: var(--radius2);
border: 1px solid rgba(255,255,255,0.14);
background: rgba(255,255,255,0.05);
padding: 14px;
margin-top: 12px;
}
.kv h3 { margin: 0 0 8px 0; font-size: 13px; color: rgba(255,255,255,0.86); }
.kv p { margin: 0; font-size: 12px; line-height: 1.55; color: var(--muted); white-space: pre-line; }
.server {
margin-top: 10px;
font-family: var(--mono);
font-size: 12px;
padding: 10px 12px;
border-radius: 12px;
background: rgba(0,0,0,0.25);
border: 1px solid rgba(255,255,255,0.12);
word-break: break-all;
color: rgba(255,255,255,0.90);
}
.row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
.btn {
height: 38px;
padding: 0 12px;
border-radius: 12px;
border: 1px solid rgba(255,255,255,0.18);
background: rgba(0,0,0,0.18);
color: rgba(255,255,255,0.92);
font-weight: 700;
cursor: pointer;
}
.btn.primary {
background: linear-gradient(135deg, rgba(10,132,255,0.95), rgba(100,210,255,0.60));
}
.btn:disabled { opacity: 0.55; cursor: not-allowed; }
a { color: rgba(100, 210, 255, 0.92); text-decoration: none; }
a:hover { text-decoration: underline; }
.footer {
margin-top: 18px;
padding-top: 14px;
border-top: 1px solid rgba(255,255,255,0.10);
display: flex;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
font-size: 12px;
color: rgba(255,255,255,0.55);
}
`;
}
export type JwtSecretState = 'missing' | 'default' | 'too_short';
export function renderJwtSecretWarningPage(request: Request, state: JwtSecretState): string {
const lang: Lang = isChineseFromRequest(request) ? 'zh' : 'en';
const descKey = state === 'missing' ? 'cfgDescMissing' : state === 'default' ? 'cfgDescDefault' : 'cfgDescTooShort';
return `<!DOCTYPE html>
<html lang="${lang === 'zh' ? 'zh-CN' : 'en'}">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>NodeWarden</title>
<style>${baseStyles()}</style>
</head>
<body>
<div class="shell">
<aside class="panel">
<div class="top">
<div class="mark" aria-label="NodeWarden">NW</div>
<div class="title">
<h1>${t(lang, 'app')}</h1>
<p>${t(lang, 'tag')}</p>
</div>
</div>
<h2>${t(lang, 'cfgTitle')}</h2>
<div class="lead">${t(lang, descKey)}</div>
<div class="kv">
<h3>${t(lang, 'cfgStepsTitle')}</h3>
<p>${t(lang, 'cfgSteps')
.replace(/^类型:密钥/m, '<b>类型:密钥</b>')
.replace(/^名称:JWT_SECRET/m, '<b>名称:JWT_SECRET</b>')
.replace(/^Type: Secret/m, '<b>Type: Secret</b>')
.replace(/^Name: JWT_SECRET/m, '<b>Name: JWT_SECRET</b>')
}</p>
</div>
<div class="kv">
<h3>${t(lang, 'cfgGenTitle')}</h3>
<p>${t(lang, 'cfgGenHint')}</p>
<div class="server" id="secret"></div>
<div style="height: 10px"></div>
<div class="row">
<button class="btn primary" type="button" onclick="refreshSecret()">${t(lang, 'cfgRefresh')}</button>
<button class="btn" type="button" onclick="copySecret()">${t(lang, 'cfgCopy')}</button>
</div>
</div>
<div class="footer">
<div>
<span>${t(lang, 'by')} </span>
<a href="https://shuai.plus" target="_blank" rel="noreferrer">shuaiplus</a>
</div>
<div>
<a href="https://github.com/shuaiplus/nodewarden" target="_blank" rel="noreferrer">${t(lang, 'github')}</a>
</div>
</div>
</aside>
</div>
<script>
// Generate a URL-safe random secret (default length: 64)
function genSecret(len) {
len = len || 50;
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';
const bytes = new Uint8Array(len);
crypto.getRandomValues(bytes);
let out = '';
for (let i = 0; i < bytes.length; i++) {
out += chars[bytes[i] % chars.length];
}
return out;
}
function refreshSecret() {
const s = genSecret(50);
document.getElementById('secret').textContent = s;
}
async function copySecret() {
const s = document.getElementById('secret').textContent || '';
try {
await navigator.clipboard.writeText(s);
} catch {
const ta = document.createElement('textarea');
ta.value = s;
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
ta.remove();
}
}
refreshSecret();
</script>
</body>
</html>`;
}
+668
View File
@@ -0,0 +1,668 @@
import { Env } from '../types';
import { StorageService } from '../services/storage';
import { htmlResponse } from '../utils/response';
// Registration/setup page HTML (single-file, no external assets)
// Split out from the old monolithic `setup.ts` as requested.
const registerPageHTML = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>NodeWarden</title>
<style>
:root {
color-scheme: light;
--bg0: #0b0b0f;
--bg1: #0f1020;
--card: rgba(255, 255, 255, 0.08);
--card2: rgba(255, 255, 255, 0.06);
--border: rgba(255, 255, 255, 0.14);
--text: rgba(255, 255, 255, 0.92);
--muted: rgba(255, 255, 255, 0.62);
--muted2: rgba(255, 255, 255, 0.52);
--accent: #0a84ff;
--accent2: #64d2ff;
--danger: #ff453a;
--ok: #32d74b;
--shadow: 0 16px 60px rgba(0, 0, 0, 0.50);
--radius: 18px;
--radius2: 14px;
--mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
}
* { box-sizing: border-box; }
html, body { height: 100%; }
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text", "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
background:
radial-gradient(900px 600px at 15% 10%, rgba(100, 210, 255, 0.25), transparent 60%),
radial-gradient(900px 600px at 85% 20%, rgba(10, 132, 255, 0.22), transparent 60%),
radial-gradient(900px 600px at 50% 90%, rgba(50, 215, 75, 0.10), transparent 60%),
linear-gradient(180deg, var(--bg0), var(--bg1));
color: var(--text);
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
}
.shell { width: max(500px); }
.panel {
padding: 22px;
border: 1px solid var(--border);
background: rgba(255,255,255,0.06);
border-radius: var(--radius);
box-shadow: var(--shadow);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
}
.top {
display: flex;
gap: 14px;
align-items: center;
margin-bottom: 14px;
}
.mark {
width: 46px;
height: 46px;
border-radius: 14px;
background: linear-gradient(135deg, rgba(10,132,255,0.85), rgba(100,210,255,0.55));
border: 1px solid rgba(255,255,255,0.20);
box-shadow: 0 10px 40px rgba(10, 132, 255, 0.30);
display: flex;
align-items: center;
justify-content: center;
font-weight: 800;
letter-spacing: 1px;
color: rgba(255,255,255,0.96);
text-transform: uppercase;
user-select: none;
}
.title { display: flex; flex-direction: column; gap: 4px; }
.title h1 { font-size: 22px; margin: 0; letter-spacing: -0.3px; }
.title p { margin: 0; color: var(--muted); font-size: 13px; line-height: 1.5; }
h2 { font-size: 16px; margin: 14px 0 10px 0; letter-spacing: -0.2px; }
.message {
display: none;
border-radius: 14px;
padding: 12px 12px;
margin: 0 0 12px 0;
font-size: 13px;
line-height: 1.45;
border: 1px solid rgba(255,255,255,0.14);
background: rgba(255,255,255,0.06);
}
.message.error {
display: block;
border-color: rgba(255, 69, 58, 0.40);
background: rgba(255, 69, 58, 0.10);
color: rgba(255, 255, 255, 0.92);
}
.message.success {
display: block;
border-color: rgba(50, 215, 75, 0.35);
background: rgba(50, 215, 75, 0.10);
color: rgba(255, 255, 255, 0.92);
}
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
@media (max-width: 540px) { .grid { grid-template-columns: 1fr; } }
.field { display: flex; flex-direction: column; gap: 7px; }
label { font-size: 12px; color: var(--muted); letter-spacing: 0.2px; }
input {
height: 42px;
padding: 0 12px;
border-radius: 12px;
border: 1px solid rgba(255,255,255,0.18);
background: rgba(0,0,0,0.18);
color: rgba(255,255,255,0.92);
outline: none;
font-size: 14px;
transition: border-color 160ms ease, box-shadow 160ms ease;
}
input::placeholder { color: rgba(255,255,255,0.35); }
input:focus {
border-color: rgba(10, 132, 255, 0.55);
box-shadow: 0 0 0 6px rgba(10, 132, 255, 0.12);
}
.hint { margin: 0; color: var(--muted2); font-size: 12px; line-height: 1.55; }
.actions { margin-top: 12px; display: flex; gap: 10px; align-items: center; }
.primary {
width: 100%;
height: 44px;
border-radius: 14px;
border: 1px solid rgba(255,255,255,0.18);
background: linear-gradient(135deg, rgba(10,132,255,0.95), rgba(100,210,255,0.60));
color: rgba(255,255,255,0.96);
font-weight: 700;
letter-spacing: 0.2px;
cursor: pointer;
transition: transform 120ms ease, filter 120ms ease;
}
.primary:hover { filter: brightness(1.03); }
.primary:active { transform: translateY(1px) scale(0.99); }
.primary:disabled { opacity: 0.55; cursor: not-allowed; transform: none; }
.sideCard { display: flex; flex-direction: column; gap: 12px; }
.kv {
border-radius: var(--radius2);
border: 1px solid rgba(255,255,255,0.14);
background: rgba(255,255,255,0.05);
padding: 14px;
margin-bottom: 10px;
}
.kv h3 { margin: 0 0 8px 0; font-size: 13px; color: rgba(255,255,255,0.86); }
.kv p { margin: 0; font-size: 12px; line-height: 1.55; color: var(--muted); }
.server {
margin-top: 10px;
font-family: var(--mono);
font-size: 12px;
padding: 10px 12px;
border-radius: 12px;
background: rgba(0,0,0,0.25);
border: 1px solid rgba(255,255,255,0.12);
word-break: break-all;
color: rgba(255,255,255,0.90);
}
a { color: rgba(100, 210, 255, 0.92); text-decoration: none; }
a:hover { text-decoration: underline; }
.footer {
margin-top: 18px;
padding-top: 14px;
border-top: 1px solid rgba(255,255,255,0.10);
display: flex;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
font-size: 12px;
color: rgba(255,255,255,0.55);
}
.muted { color: var(--muted); }
</style>
</head>
<body>
<div class="shell">
<aside class="panel">
<div class="top">
<div class="mark" aria-label="NodeWarden">NW</div>
<div class="title">
<h1 id="t_app">NodeWarden</h1>
<p id="t_tag">部署在 Cloudflare Workers 上的 Bitwarden 兼容服务端。</p>
</div>
</div>
<div class="muted" id="t_intro" style="font-size: 13px; line-height: 1.7;">
创建第一个账号完成初始化,然后用任意 Bitwarden 官方客户端登录。
</div>
<div style="height: 14px"></div>
<h2 id="t_setup">初始化</h2>
<div id="message" class="message"></div>
<div id="setup-form">
<form id="form" onsubmit="handleSubmit(event)">
<div class="grid">
<div class="field">
<label for="name" id="t_name_label">Name</label>
<input type="text" id="name" name="name" required placeholder="Your name">
</div>
<div class="field">
<label for="email" id="t_email_label">Email</label>
<input type="email" id="email" name="email" required placeholder="you@example.com" autocomplete="email">
</div>
</div>
<div style="height: 10px"></div>
<div class="field">
<label for="password" id="t_pw_label">Master password</label>
<input type="password" id="password" name="password" required minlength="12" placeholder="At least 12 characters" autocomplete="new-password">
<p class="hint" id="t_pw_hint">Choose a strong password you can remember. The server cannot recover it.</p>
</div>
<div style="height: 10px"></div>
<div class="field">
<label for="confirmPassword" id="t_pw2_label">Confirm password</label>
<input type="password" id="confirmPassword" name="confirmPassword" required placeholder="Confirm password" autocomplete="new-password">
</div>
<div class="actions">
<button type="submit" id="submitBtn" class="primary">Create account</button>
</div>
</form>
</div>
<div id="registered-view" class="sideCard" style="display: none;">
<div class="kv">
<h3 id="t_done_title">Setup complete</h3>
<p id="t_done_desc">Your server is ready. Configure your Bitwarden client with this server URL:</p>
<div class="server" id="serverUrl"></div>
</div>
<div class="kv">
<h3 id="t_important">Important</h3>
<p id="t_limitations">
This project is designed for a single user. You cannot add new users. Changing the master password is not supported.
If you forget it, you must redeploy and register again.
</p>
</div>
<div class="kv">
<h3 id="t_hide_title">Hide setup page</h3>
<p id="t_hide_desc">After hiding, this setup page will return 404 for everyone. Your vault will keep working.</p>
<div class="actions">
<button type="button" id="hideBtn" class="primary" onclick="disableSetupPage()">Hide setup page</button>
</div>
</div>
</div>
<div class="footer">
<div>
<span class="muted" id="t_by">By</span>
<a href="https://shuai.plus" target="_blank" rel="noreferrer">shuaiplus</a>
</div>
<div>
<a href="https://github.com/shuaiplus/nodewarden" target="_blank" rel="noreferrer">GitHub</a>
</div>
</div>
</aside>
</div>
<script>
let isRegistered = false;
function isChinese() {
const lang = (navigator.language || '').toLowerCase();
return lang.startsWith('zh');
}
function t(key) {
const zh = {
app: 'NodeWarden',
tag: '部署在 Cloudflare Workers 上的 Bitwarden 兼容服务端。',
intro: '创建第一个账号完成初始化,然后用任意 Bitwarden 官方客户端登录。',
by: '作者',
setup: '初始化',
nameLabel: '昵称',
emailLabel: '邮箱',
pwLabel: '主密码',
pwHint: '请选择你能记住的强密码。服务器无法找回主密码。',
pw2Label: '确认主密码',
create: '创建账号',
creating: '正在创建…',
doneTitle: '初始化完成',
doneDesc: '服务已就绪。在 Bitwarden 客户端中填入以下服务器地址:',
important: '重要提示',
limitations: '本项目仅支持单用户:不能添加新用户;不支持修改主密码;如果忘记主密码,只能重新部署并重新注册。',
hideTitle: '隐藏初始化页',
hideDesc: '隐藏后,初始化页对任何人都会直接返回 404。你的密码库仍可正常使用。',
hideBtn: '隐藏初始化页',
hideWorking: '正在隐藏…',
hideDone: '已隐藏,此页面将返回 404。',
hideFailed: '隐藏失败',
hideConfirm: '确认隐藏初始化页?隐藏后页面将不可访问,但你的密码库不会受影响。',
errPwNotMatch: '两次输入的密码不一致',
errPwTooShort: '密码长度至少 12 位',
errGeneric: '发生错误:',
errRegisterFailed: '注册失败',
};
const en = {
app: 'NodeWarden',
tag: 'Minimal Bitwarden-compatible server on Cloudflare Workers.',
intro: 'Create your first account to finish setup. Then use any official Bitwarden client to sign in.',
by: 'By',
setup: 'Setup',
nameLabel: 'Name',
emailLabel: 'Email',
pwLabel: 'Master password',
pwHint: 'Choose a strong password you can remember. The server cannot recover it.',
pw2Label: 'Confirm password',
create: 'Create account',
creating: 'Creating…',
doneTitle: 'Setup complete',
doneDesc: 'Your server is ready. Configure your Bitwarden client with this server URL:',
important: 'Important',
limitations: 'Single user only: you cannot add new users. Changing the master password is not supported. If you forget it, redeploy and register again.',
hideTitle: 'Hide setup page',
hideDesc: 'After hiding, this setup page will return 404 for everyone. Your vault will keep working.',
hideBtn: 'Hide setup page',
hideWorking: 'Hiding…',
hideDone: 'Hidden. This page will now return 404.',
hideFailed: 'Failed to hide setup page',
hideConfirm: 'Hide the setup page? It will no longer be accessible, but your vault will keep working.',
errPwNotMatch: 'Passwords do not match',
errPwTooShort: 'Password must be at least 12 characters',
errGeneric: 'An error occurred: ',
errRegisterFailed: 'Registration failed',
};
return (isChinese() ? zh : en)[key];
}
function applyI18n() {
document.documentElement.lang = isChinese() ? 'zh-CN' : 'en';
document.getElementById('t_app').textContent = t('app');
document.getElementById('t_tag').textContent = t('tag');
document.getElementById('t_intro').textContent = t('intro');
document.getElementById('t_by').textContent = t('by');
document.getElementById('t_setup').textContent = t('setup');
document.getElementById('t_name_label').textContent = t('nameLabel');
document.getElementById('t_email_label').textContent = t('emailLabel');
document.getElementById('t_pw_label').textContent = t('pwLabel');
document.getElementById('t_pw_hint').textContent = t('pwHint');
document.getElementById('t_pw2_label').textContent = t('pw2Label');
document.getElementById('submitBtn').textContent = t('create');
document.getElementById('t_done_title').textContent = t('doneTitle');
document.getElementById('t_done_desc').textContent = t('doneDesc');
document.getElementById('t_important').textContent = t('important');
document.getElementById('t_limitations').textContent = t('limitations');
document.getElementById('t_hide_title').textContent = t('hideTitle');
document.getElementById('t_hide_desc').textContent = t('hideDesc');
document.getElementById('hideBtn').textContent = t('hideBtn');
}
async function checkStatus() {
try {
const res = await fetch('/setup/status');
const data = await res.json();
isRegistered = !!data.registered;
if (data.registered) {
showRegisteredView();
}
} catch (e) {
console.error('Failed to check status:', e);
}
}
function showRegisteredView() {
isRegistered = true;
document.getElementById('setup-form').style.display = 'none';
document.getElementById('registered-view').style.display = 'block';
document.getElementById('serverUrl').textContent = window.location.origin;
showMessage(t('doneTitle'), 'success');
const form = document.getElementById('form');
if (form) {
const fields = form.querySelectorAll('input, button');
fields.forEach((el) => {
el.disabled = true;
});
}
}
async function disableSetupPage() {
if (!isRegistered) return;
if (!confirm(t('hideConfirm'))) return;
const btn = document.getElementById('hideBtn');
if (btn) {
btn.disabled = true;
btn.textContent = t('hideWorking');
}
try {
const res = await fetch('/setup/disable', { method: 'POST' });
const data = await res.json();
if (res.ok && data.success) {
showMessage(t('hideDone'), 'success');
setTimeout(() => window.location.reload(), 600);
return;
}
showMessage(data.error || t('hideFailed'), 'error');
} catch (e) {
showMessage(t('hideFailed'), 'error');
}
if (btn) {
btn.disabled = false;
btn.textContent = t('hideBtn');
}
}
function showMessage(text, type) {
const msg = document.getElementById('message');
msg.textContent = text;
msg.className = 'message ' + type;
}
async function pbkdf2(password, salt, iterations, keyLen) {
const encoder = new TextEncoder();
const passwordBytes = (password instanceof Uint8Array)
? password
: encoder.encode(password);
const saltBytes = (salt instanceof Uint8Array)
? salt
: encoder.encode(salt);
const keyMaterial = await crypto.subtle.importKey(
'raw',
passwordBytes,
'PBKDF2',
false,
['deriveBits']
);
const derivedBits = await crypto.subtle.deriveBits(
{
name: 'PBKDF2',
salt: saltBytes,
iterations: iterations,
hash: 'SHA-256'
},
keyMaterial,
keyLen * 8
);
return new Uint8Array(derivedBits);
}
async function hkdfExpand(prk, info, length) {
const encoder = new TextEncoder();
const key = await crypto.subtle.importKey(
'raw',
prk,
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
);
const infoBytes = encoder.encode(info);
const result = new Uint8Array(length);
let prev = new Uint8Array(0);
let offset = 0;
let counter = 1;
while (offset < length) {
const input = new Uint8Array(prev.length + infoBytes.length + 1);
input.set(prev);
input.set(infoBytes, prev.length);
input[input.length - 1] = counter;
const signature = await crypto.subtle.sign('HMAC', key, input);
prev = new Uint8Array(signature);
const toCopy = Math.min(prev.length, length - offset);
result.set(prev.slice(0, toCopy), offset);
offset += toCopy;
counter++;
}
return result;
}
function generateSymmetricKey() {
return crypto.getRandomValues(new Uint8Array(64));
}
async function encryptAesCbc(data, key, iv) {
const cryptoKey = await crypto.subtle.importKey(
'raw',
key,
{ name: 'AES-CBC' },
false,
['encrypt']
);
const encrypted = await crypto.subtle.encrypt(
{ name: 'AES-CBC', iv: iv },
cryptoKey,
data
);
return new Uint8Array(encrypted);
}
async function hmacSha256(key, data) {
const cryptoKey = await crypto.subtle.importKey(
'raw',
key,
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
);
const signature = await crypto.subtle.sign('HMAC', cryptoKey, data);
return new Uint8Array(signature);
}
function base64Encode(bytes) {
return btoa(String.fromCharCode.apply(null, bytes));
}
async function encryptToBitwardenFormat(data, encKey, macKey) {
const iv = crypto.getRandomValues(new Uint8Array(16));
const encrypted = await encryptAesCbc(data, encKey, iv);
const macData = new Uint8Array(iv.length + encrypted.length);
macData.set(iv);
macData.set(encrypted, iv.length);
const mac = await hmacSha256(macKey, macData);
return '2.' + base64Encode(iv) + '|' + base64Encode(encrypted) + '|' + base64Encode(mac);
}
async function generateRsaKeyPair() {
const keyPair = await crypto.subtle.generateKey(
{
name: 'RSA-OAEP',
modulusLength: 2048,
publicExponent: new Uint8Array([1, 0, 1]),
hash: 'SHA-1'
},
true,
['encrypt', 'decrypt']
);
const publicKeySpki = await crypto.subtle.exportKey('spki', keyPair.publicKey);
const publicKeyB64 = base64Encode(new Uint8Array(publicKeySpki));
const privateKeyPkcs8 = await crypto.subtle.exportKey('pkcs8', keyPair.privateKey);
const privateKeyBytes = new Uint8Array(privateKeyPkcs8);
return {
publicKey: publicKeyB64,
privateKey: privateKeyBytes
};
}
async function handleSubmit(event) {
event.preventDefault();
if (isRegistered) {
showMessage(t('doneTitle'), 'success');
return;
}
const name = document.getElementById('name').value;
const email = document.getElementById('email').value.toLowerCase();
const password = document.getElementById('password').value;
const confirmPassword = document.getElementById('confirmPassword').value;
if (password !== confirmPassword) {
showMessage(t('errPwNotMatch'), 'error');
return;
}
if (password.length < 12) {
showMessage(t('errPwTooShort'), 'error');
return;
}
const btn = document.getElementById('submitBtn');
btn.disabled = true;
btn.textContent = t('creating');
try {
const iterations = 600000;
const masterKey = await pbkdf2(password, email, iterations, 32);
const masterPasswordHash = await pbkdf2(masterKey, password, 1, 32);
const masterPasswordHashB64 = base64Encode(masterPasswordHash);
const stretchedKey = await hkdfExpand(masterKey, 'enc', 32);
const stretchedMacKey = await hkdfExpand(masterKey, 'mac', 32);
const symmetricKey = generateSymmetricKey();
const encryptedKey = await encryptToBitwardenFormat(symmetricKey, stretchedKey, stretchedMacKey);
const rsaKeys = await generateRsaKeyPair();
const encryptedPrivateKey = await encryptToBitwardenFormat(rsaKeys.privateKey, symmetricKey.slice(0, 32), symmetricKey.slice(32, 64));
const response = await fetch('/api/accounts/register', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
email: email,
name: name,
masterPasswordHash: masterPasswordHashB64,
key: encryptedKey,
kdf: 0,
kdfIterations: iterations,
keys: {
publicKey: rsaKeys.publicKey,
encryptedPrivateKey: encryptedPrivateKey
}
})
});
const result = await response.json();
if (response.ok && result.success) {
showRegisteredView();
} else {
showMessage(result.error || result.ErrorModel?.Message || t('errRegisterFailed'), 'error');
btn.disabled = false;
btn.textContent = t('create');
}
} catch (error) {
console.error('Registration error:', error);
showMessage(t('errGeneric') + (error && error.message ? error.message : String(error)), 'error');
btn.disabled = false;
btn.textContent = t('create');
}
}
applyI18n();
checkStatus();
</script>
</body>
</html>`;
export async function handleRegisterPage(request: Request, env: Env): Promise<Response> {
const storage = new StorageService(env.VAULT);
const disabled = await storage.isSetupDisabled();
if (disabled) {
return new Response(null, { status: 404 });
}
return htmlResponse(registerPageHTML);
}
-9
View File
@@ -3,15 +3,6 @@ import { handleRequest } from './router';
export default { export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> { async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
// Security check: JWT_SECRET must be set
if (!env.JWT_SECRET) {
return new Response('Server configuration error: JWT_SECRET is not set', { status: 500 });
}
// Security check: warn if JWT_SECRET is too weak
if (env.JWT_SECRET.length < 32) {
console.warn('[SECURITY WARNING] JWT_SECRET should be at least 32 characters for adequate security');
}
return handleRequest(request, env); return handleRequest(request, env);
}, },
+7
View File
@@ -89,6 +89,7 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
// Route matching // Route matching
try { try {
// Setup page (root) // Setup page (root)
if (path === '/' && method === 'GET') { if (path === '/' && method === 'GET') {
return handleSetupPage(request, env); return handleSetupPage(request, env);
@@ -181,6 +182,12 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
return handleRegister(request, env); return handleRegister(request, env);
} }
// If JWT_SECRET is not safely configured, block any other endpoints.
const secret = (env.JWT_SECRET || '').trim();
if (!secret || secret.length < 32) {
return errorResponse('Server configuration error: JWT_SECRET is not set or too weak', 500);
}
// All other API endpoints require authentication // All other API endpoints require authentication
const auth = new AuthService(env); const auth = new AuthService(env);
const authHeader = request.headers.get('Authorization'); const authHeader = request.headers.get('Authorization');
+4
View File
@@ -5,6 +5,10 @@ export interface Env {
JWT_SECRET: string; JWT_SECRET: string;
} }
// Sample JWT secret used by `.dev.vars.example`.
// If runtime JWT_SECRET equals this value, treat it as unsafe.
export const DEFAULT_DEV_SECRET = 'Enter-your-JWT-key-here-at-least-32-characters';
// Attachment model // Attachment model
export interface Attachment { export interface Attachment {
id: string; id: string;
+7
View File
@@ -0,0 +1,7 @@
// Shared config related to JWT secret bootstrapping / safety checks.
// Keep this in one place so handlers don't duplicate the sample value.
// IMPORTANT:
// This is a *sample* secret value used in `.dev.vars.example`.
// If the runtime JWT_SECRET equals this value, we treat it as unsafe.
export const DEFAULT_DEV_SECRET = 'Enter-your-JWT-key-here-at-least-32-characters';