站点密码门禁
本文档站支持可选的密码门禁:站点部署在公网,但不希望所有人都能访问时启用。 方案没有引入任何后端服务,全部基于浏览器密码学 API 与一次构建时的加密注入完成。 本文完整记录设计思路、密码学原理、真实代码与安全边界——包括这层门禁防不住什么。
当前状态
未设置 SITE_PASSWORD 环境变量时站点公开访问,门禁组件自动放行; 启用方式见文末 启用方式。
一、问题:纯静态站点怎么做访问控制
VitePress 是 SSG(静态站点生成器),部署产物只有 HTML / JS / CSS 等静态文件, 没有服务端进程。这意味着传统方案全部失效:
- 服务端 Session、数据库校验:没有服务器
- Nginx
auth_basic:静态托管平台(对象存储 / Pages 类服务)不支持自定义 Nginx - 网关鉴权:依赖基础设施,不是每个项目都有
于是问题变成:校验逻辑只能写在前端,而前端产物对用户完全可见。
三种朴素实现,为什么都不成立
① 硬编码密码比较
if (input === 'qxj2026') unlock() // 密码明文躺在 JS 产物里,Ctrl+F 即得② 存密码的哈希值做比较
if (sha256(input) === 'a3f9...e21') unlock()哈希虽然不可逆,但那个"标准答案哈希"就在产物里。攻击者可以:
- 拿哈希跑字典 / 暴力破解(离线、无限次)
- 直接改 JS 把判断分支短路(前端代码用户完全可控)
③ 代码混淆
把校验逻辑混淆到不可读——混淆只是提高阅读成本,不是密码学保护,随时可逆。
共同病根:产物里存放了"正确答案"(明文密码、目标哈希或可直接比较的常量), 并且校验方式是值的比较——比较运算可以被旁路,答案常量可以被提取。
二、核心思想:金丝雀验证
什么是金丝雀(Canary)
"金丝雀"一词源自矿工把金丝雀带入矿井检测瓦斯,在计算机安全中泛指一个探针式的 验证值:栈溢出保护中的 stack canary、数据泄露告警中的 canary token 都是同一种思想—— 不直接判断真假,而是观察一个受控标记的状态。
本方案的金丝雀是一段密文,验证范式发生根本变化:
| 朴素方案 | 金丝雀方案 | |
|---|---|---|
| 产物里有什么 | 正确答案(明文 / 目标哈希) | 一道只有正确密钥能解开的谜题(密文) |
| 校验方式 | 值比较(===) | 尝试 AEAD 解密,看认证标签是否通过 |
| 攻击者拿到产物 | 直接得到答案 / 目标哈希 | 只得到密文,必须恢复出密钥才能通过验证 |
| 短路判断分支 | 可以绕过 | 绕过判断也拿不到任何东西(边界见第六节) |
具体做法:
- 构建时:用密码经 SHA-256 派生 32 字节密钥,对一段固定明文(
qxj-gate-v1) 做 AES-256-GCM 加密,把nonce || 密文 || tag编码为金丝雀注入前端产物; - 运行时:用户输入密码,浏览器做同样的派生,尝试用派生密钥解密金丝雀。 GCM 的认证标签通过 ⇔ 密钥正确 ⇔ 密码正确;解密失败则密码错误。
整个过程没有任何秘密值在网络上或产物中出现:密码不出现,密钥不出现, 也没有可供比较的"目标常量"——产物里唯一与密码相关的东西是密文本身。
三、密码学基础
密钥派生:K = SHA-256(password)
AES-256 需要恰好 32 字节密钥。用 SHA-256 把任意长度的密码映射为 32 字节摘要:
构建侧(Node crypto)与运行侧(浏览器 crypto.subtle)实现同一函数, 两端不需要协商,由密码本身保证密钥一致。
AES-256-GCM:带认证的加密(AEAD)
GCM 是一种 AEAD(Authenticated Encryption with Associated Data,认证加密) 模式,一次密码学操作同时提供两种安全保证:
| 安全性质 | 含义 |
|---|---|
| 机密性(Confidentiality) | 没有密钥无法恢复明文,密文不泄露内容 |
| 完整性 / 真实性(Authenticity) | 密文或 tag 被改动哪怕一个比特,解密立刻失败 |
GCM 的三个关键输入 / 输出:
加密:ciphertext, tag = AES-GCM-Encrypt(K, nonce, plaintext)
解密:plaintext = AES-GCM-Decrypt(K, nonce, ciphertext, tag)
↑ tag 校验不过则直接报错,不产出明文2
3
- nonce(Number used once):本方案取 12 字节随机数。GCM 安全的硬性要求是 同一密钥下 nonce 绝不能重复;本方案每次构建随机生成一次、只加密一条消息, 随机 12 字节在生日界(约
次)内安全,单次构建没有碰撞问题。 - tag(认证标签):16 字节,可以理解为"密钥持有者对密文的密码学签名"。 错误密钥下伪造出能通过的 tag 在计算上不可行(约
)。
为什么"解密验证"比"值比较"更可靠
- 不存在可提取的答案。 比较式校验必须把"目标值"放在某处;金丝雀方案中 验证的唯一判据是"GCM tag 是否通过",而 tag 通过的前提是持有正确密钥。
- 恒定时间比较,无时序侧信道。 GCM 的 tag 验证在标准实现中是恒定时间 (constant-time)运算;手写
===比较哈希则可能因逐字符短路在极端条件下 泄露匹配长度。 - 篡改即失败。 攻击者无法构造一个"改过的金丝雀 + 改过的判断"来骗系统—— 他能短路的只有
unlocked标志,而 内容并不在门禁组件里。
金丝雀的数据布局
canary(base64 编码后注入 import.meta.env):
┌──────────────────┬─────────────────────────────┬──────────────────┐
│ nonce │ ciphertext │ tag │
│ 12 字节 │ 固定明文 'qxj-gate-v1' 的密文 │ 16 字节 │
└──────────────────┴─────────────────────────────┴──────────────────┘
运行时 base64 解码:slice(0,12) 作 IV,slice(12) 整体交给 GCM 解密(末尾自带 tag)2
3
4
5
6
7
固定明文的内容不重要,它只是"被加密的载体";安全保证全部来自密钥与 AEAD 结构。
四、完整流程
localStorage 存的是什么——能力令牌,不是密码
解锁后写入 localStorage 的是派生密钥 K 的 base64,不是明文密码:
- 它是单向的:
无法反推出密码; - 它等价于一张能力令牌(capability token):持有它就能通过金丝雀, 作用类似于"记住我"的 session key——下次进入静默解锁,不必重复输密码;
- 密码修改后旧 K 解密新金丝雀必然失败,组件会自动清除旧值重新弹窗,无需迁移逻辑。
五、真实实现代码
1. 构建侧:Vite 插件注入金丝雀
[config.ts](file:///c:/Users/etsuyou/Desktop/qxj-project/qxj-vitepress-page/docs/.vitepress/config.ts#L486-L511) 中注册的 site-password-canary 插件(原文照录):
import { createHash, createCipheriv, randomBytes } from 'node:crypto'
// 注册在 vite.plugins 中
{
name: 'site-password-canary',
config: () => {
const pwd = process.env.SITE_PASSWORD || ''
let canary = ''
if (pwd) {
const key = createHash('sha256').update(pwd).digest() // 32 字节派生密钥
const nonce = randomBytes(12)
const cipher = createCipheriv('aes-256-gcm', key, nonce)
const ct = Buffer.concat([cipher.update('qxj-gate-v1', 'utf8'), cipher.final()])
const tag = cipher.getAuthTag() // 16 字节认证标签
canary = Buffer.concat([nonce, ct, tag]).toString('base64')
}
return {
define: {
'import.meta.env.SITE_PASSWORD_CANARY': JSON.stringify(canary)
}
}
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
- 未设
SITE_PASSWORD⇒ canary 为空字符串,作为"门禁关闭"的信号; - Vite 的
define会在编译期把标识符字面替换为金丝雀字符串, 产物中只出现密文,不出现密码与密钥。
2. 运行侧:PasswordGate 组件
[PasswordGate.vue](file:///c:/Users/etsuyou/Desktop/qxj-project/qxj-vitepress-page/docs/.vitepress/theme/components/PasswordGate.vue#L41-L58) 的核心校验函数(原文照录),全程使用浏览器原生 WebCrypto:
async function tryUnlock(canaryB64: string, keyBytes: Uint8Array): Promise<boolean> {
try {
const raw = base64ToBytes(canaryB64)
const nonce = raw.slice(0, 12)
const data = raw.slice(12)
const key = await crypto.subtle.importKey(
'raw', keyBytes, 'AES-GCM', false, ['decrypt']
)
await crypto.subtle.decrypt({ name: 'AES-GCM', iv: nonce }, key, data)
return true // tag 校验通过
} catch {
return false // 密钥错误 / 密文被篡改,统一视为失败
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
用户提交时的派生与验证(原文照录):
// K' = SHA-256(input),与构建时生成金丝雀的密钥一致
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(pwd.value))
const keyBytes = new Uint8Array(digest)
if (await tryUnlock(canary, keyBytes)) {
localStorage.setItem(STORAGE_KEY, bytesToBase64(keyBytes)) // 记忆能力令牌
unlocked.value = true
} else {
error.value = true
}2
3
4
5
6
7
8
9
模板上以 v-if 隔离,未解锁时文档 Layout 完全不进入渲染树:
<Layout v-if="unlocked" />
<div v-else class="password-gate"> …密码输入卡片… </div>2
3. 挂载:替换主题 Layout
[theme/index.ts](file:///c:/Users/etsuyou/Desktop/qxj-project/qxj-vitepress-page/docs/.vitepress/theme/index.ts#L25-L31) 把 teek 主题的 Layout 替换为 PasswordGate,由它在解锁后再渲染真正的 Layout:
export default {
extends: Teek,
Layout: PasswordGate, // 未解锁渲染密码框,解锁后内部渲染 teek Layout
}2
3
4
六、威胁模型与安全边界
工程上比"实现功能"更重要的是说清楚防什么、不防什么。
这层门节能防住
| 威胁 | 如何防住 |
|---|---|
| 路人随手翻看 | 必须输入正确密码 |
| 搜索引擎收录 | 正文不进入预渲染 HTML(见下方实证),抓取器只拿到密码框 |
| 在产物中翻找密码 / 校验常量 | 产物中只有密文金丝雀,无答案常量 |
| 字典在线试探 | 可配合简单的前端限速;且没有可比对的目标哈希 |
| 篡改金丝雀后通过验证 | GCM 完整性保护,改动即解密失败 |
这层门防不住(必须诚实面对)
① 拿到产物者离线暴力破解密码
每个候选密码的验证成本仅为「一次 SHA-256 + 一次 GCM 解密」。SHA-256 是 为速度设计的快哈希,GPU 每秒可执行数十亿次——若密码本身弱(字典词、短数字), 离线暴力很快就会出结果。单轮 SHA-256 在这里的角色只是"密钥派生函数(KDF)", 不是"口令拉伸函数"。
② 文档正文就在公开的 JS chunk 中——连破解都不需要
这是 SSG 架构决定的,也是本方案最根本的边界。构建产物的结构实证:
- 预渲染的
guide/overview.html:正文关键词命中 0 次,只有密码卡片 (门禁把正文挡在了 SSR 输出之外,这一点设计成立); - 但
assets/guide_overview.md.<hash>.js:页面全部内容以编译后的渲染函数形式存在, 正文关键词命中 24 次——因为客户端解锁后必须有资源来渲染页面, 页面模块必然作为公开静态资源存在,直接对该 URL 发请求即可阅读全文。
# 门禁可以挡住正常浏览路径,但挡不住直接请求页面模块:
GET /assets/guide_overview.md.VEpafQ8i.js → 200,完整页面内容2
文件名中的 hash 不构成保护:它就写在页面 HTML 的 modulepreload 标签里。 任何纯前端门禁(包括商业产品的"站点加密")都绕不开这条边界—— 浏览器能渲染的内容,必然已经完整到达浏览器。
③ XSS 读取 localStorage 中的能力令牌
站点若存在 XSS,攻击者可读出派生密钥直接获得访问能力。因此启用门禁时更应严控 站内 HTML(VitePress 默认转义,但 v-html、自研组件需要审查)。
结论:适用场景
这是一层"提高门槛 + 表明不公开意图"的轻量防护,定位是防路人、防收录、 防随意传播,不是密码学意义上的访问控制。需要真正保护内容机密性时, 必须把校验与内容都移到服务端。
七、与其他方案对比
| 方案 | 校验位置 | 内容是否真正受保护 | 依赖 |
|---|---|---|---|
| 本方案:GCM 金丝雀 | 浏览器 | 否(内容在公开 JS chunk) | 无,纯静态可用 |
Nginx auth_basic | 服务端 | 是(请求不通过则服务器不返回任何资源) | 可控 Nginx |
| 服务端 Session / 网关鉴权 | 服务端 | 是 | 后端服务、鉴权基础设施 |
| 对象存储签名 URL(限时) | 服务端签发 | 接近(链接时效性) | 存储平台能力 |
八、启用方式
1. 设置密码
在仓库根创建 .env.local(已被 .gitignore 忽略,不入库):
# .env.local
SITE_PASSWORD=一个足够长且不在字典里的密码2
2. 重新构建文档站
pnpm docs:build产物中即包含金丝雀,访问任意页面先显示密码卡片。
3. 修改 / 关闭
- 改密码:修改
SITE_PASSWORD后重新构建(旧访客的 localStorage 令牌自动失效); - 关闭门禁:删除或注释
SITE_PASSWORD后重新构建,canary 为空即自动放行。
九、可演进方向
若未来需要更强保护,按代价从低到高:
- 慢哈希 KDF:把单轮 SHA-256 换成 PBKDF2(WebCrypto 原生支持, 可直接设高迭代次数,两端同步修改)或 Argon2(需引入 WASM)。 离线暴力成本随迭代数线性上升——这是对"防不住①"最直接的改进。
- 服务端门禁:在 Nginx / 网关层做
auth_basic或统一身份认证, 页面资源本身不再对未认证请求返回——这是唯一能堵住"防不住②"的方式。 - 内容级加密:页面模块加密后分发,密钥经鉴权后下发——工程复杂度高, 且密钥到达前端后仍受同一边界约束,仅适合有完整密钥管理体系的场景。
附:面试速答
- 为什么用 GCM 而不是 CBC? GCM 是 AEAD,同时给机密性和完整性, 解密本身即验证,不需要额外做 HMAC;nonce 也不需保密。
- 这和把密码哈希写在前端有什么本质区别? 哈希方案产物里有"答案常量", 可离线字典、可短路比较;金丝雀产物里只有密文,通过验证的唯一途径是持有密钥。
- 那这个系统到底安全吗? 在"浏览器正常访问"的信任边界内成立; 但我明确知道两个边界——弱口令可被离线暴力,且 SSG 的页面内容以公开 JS chunk 分发,直接请求 chunk 可绕过门禁。要真正保护内容必须上服务端鉴权; 真到那一步,我会把口令派生换成 PBKDF2/Argon2。