打码:key 和小字都以密文写进 wiki
上面的页面能解开 age 加密的文本,口令(age -p)和私钥(age -r)两种都认,全在浏览器本地算,不往外发数据。它怎么实现,见文末那一节。
不想公开的东西——厂商 API key、网关 token、服务器地址和密码——写进 markdown 时就已经是 age 密文。浏览器里显示成马赛克,点开输口令或粘贴私钥,就地还原。活例是开发环境第 0 步:命令里的服务器地址,和下面一行的登录密码;网关的 key 搭好后打码写在 API 中转里。写法和解锁方式是写作规矩,在写作规范的打码一节。
六种存法比一比
| 存法 | 代表工具 | 人手里要留什么 | 好处 | 代价 |
|---|---|---|---|---|
| 明文放私有仓库 | — | GitHub 账号 | 最省事 | token、App、任何一台 clone 过的机器漏了就全露,历史删不掉 |
| 加密文件进私有仓库 | SOPS + age、git-crypt | 一把 age 私钥,可以用口令锁住 | 离线、免费,跟着 git 同步;仓库漏了也只是密文 | 多维护一个仓库,每台机器装 sops;私钥要自己备份 |
| 密文直接写进 wiki | age 公钥加密 + 本站打码 | 一句口令(锁住 wiki 私钥) | 换机器时打开 wiki 就有,不用另一个仓库;写的时候只用公钥 | 密文公开,安全全看口令;明文只在浏览器里出现 |
| 密码管理器的 CLI | 1Password op、Bitwarden bw / bws | 主密码(1Password 另有一把存在设备上的 Secret Key) | 手机、浏览器、服务器共用一份;服务器限次,主密码可以短 | 依赖在线服务,每台服务器要装 CLI、登录 |
| 自托管 secrets 服务 | Infisical、Doppler | 服务的登录凭证 | 权限分级、审计、轮换 | 多维护一个服务,个人用偏重 |
| 网关收拢 | Caddy 换 key、LiteLLM、new-api | 网关 token | 厂商 key 只在一台机器上,换 key 只改一处 | 不算存法,只是让要存的变少 |
加密的那几行,钥匙怎么管又分对称、非对称两类(原理见下文):
| 类型 | 方案 | 人手里留什么 | 加密时碰不碰秘密 | 代价 |
|---|---|---|---|---|
| 对称 | 口令直接加密数据:age -p,开头网页的预填样例就是这种 | 一句口令 | 碰:每加一个 key 都要输口令 | 最简单;所有机器共用一句口令,换口令要把文件全部重新加密;每段密文解开都要各跑一次 scrypt |
| 对称 | 随机钥匙文件:git-crypt | 一个钥匙文件 | 碰:每台要加密的机器都得有这个文件 | commit 时自动加密,unlock 后自动解开;整个文件加密,GitHub 上看不出改了哪个 key;钥匙记不住,只能备份文件 |
| 非对称 | 私钥明文放在本机:SOPS + age | 一个私钥文件 | 不碰:有公钥就能加密 | 每台机器一对钥匙,都写进收件人;私钥要自己备份,机器被入侵私钥就露了 |
| 非对称 | 私钥再用口令锁住:上一行 + age -p,最后选的 | 一句口令 | 不碰 | 换口令只要重锁一次私钥,已有密文不用动;锁好的私钥公开在站上,口令必须扛得住离线猜 |
| 非对称 | 私钥放进硬件:YubiKey + age-plugin-yubikey | 6–8 位 PIN + 一个设备 | 不碰 | 私钥导不出芯片,PIN 连错 3 次锁死;要买设备,丢了得有备份钥匙 |
| 对称变体 | 蜜罐加密(honey encryption):错口令也解出一份像真的假数据 | 一句口令 | 碰 | 只对格式能完整建模的数据成立;不能带校验码;没有 age 这样的成熟工具 |
| 托管 | 交给密码管理器 | 主密码(1Password 另有 Secret Key) | 碰:要先登录 | 服务器限制猜测次数,主密码可以短;回到上表第四行的依赖 |
两类的分水岭在加密这一步:对称方案里,每台要加密的机器都得握着同一个秘密,漏一台就全漏;非对称只要公钥,秘密只留在解密的地方。最后选的那行是两样叠起来——数据用非对称(age 公钥)锁,私钥再用对称(口令 + scrypt)锁。加密时不碰秘密,人只需记一句口令。
蜜罐那一行反着来:错口令也解出一份像真的假数据,攻击者分不出自己猜没猜对。没选它有两个原因:
- 假数据得像得一丝不差:键名、
sk-前缀、长度、有几家厂商,有一处不像就露馅。它只在卡号、密码库这类格式能完整建模的数据上成立。本站更挡不住:站上每段打码的密文都带校验码,攻击者拿候选私钥去开任意一段,几微秒就知道对不对 - 不能带校验码——带了就等于告诉攻击者猜对了——所以密文被定向改掉也察觉不到。流加密是明文和密钥流逐位异或,攻击者知道开头是
OPENAI_API_KEY=,不用钥匙就能翻几位把它改成别的;口令打错一个字母,页面还会显示一个看着正常的假地址
所以 age 选校验后拒绝:口令错了、密文被改了,一律报错,不吐任何数据。
最后的选择
- 先少存:厂商 key 只给网关机用,Agent 只拿网关 token,见 API 中转
- key 本身打码写进 wiki:用 wiki 公钥加密成
age:…,直接写在 API 中转那篇里;换网关机时打开页面解锁,照抄进网关机 - 人只记一句口令:口令锁住 wiki 私钥,锁好的副本公开在站上;私钥原文另抄一份进手机的密码管理器
这样选是因为 AI 助手经常换服务器:网关收拢之后要存的就那几个 key,再为它们维护一个 SOPS 私有仓库、每台机器装 sops,不值当。打开 wiki 就有,写的时候只用公钥,是最省的一条路。代价是密文公开,安全全看口令:每个 key 写进来之前,先在厂商控制台设好用量上限,口令万一被猜中,损失也有顶。
明文进私有仓库不行:私有只管谁能访问,不管明文
- 带
repo权限的 GitHub token 能读你所有私有仓库——服务器上 clone 用的、给 CI 和 AI 工具授权的都算,漏一个就全露 - 装过的 GitHub App、OAuth 应用、Actions,有仓库读权限就能看
- 每台 clone 过的机器上都是明文,丢一台笔记本就全露
- 仓库误改成公开、fork 出去,一步就公开了
- 写进 commit 就删不掉,发现不妥只能去各家控制台换 key
加密只能发生在写作时
本站同时发布两份产物:给人看的 HTML,和 deploy.sh 从 docs/ 原样镜像上桶的 .md 源(见总纲)。所以:
- 构建期加密会漏:MkDocs 的 encryptcontent1 插件让作者写明文,构建时只加密 HTML。放到本站,
/xxx.md里照样是明文,AI 读到的也是明文;明文和口令还都进了 git。它的粒度也是整页,做不到句子里的一个地址 - Zensical 也跑不了它:Zensical 0.0.62 只认自己重写过的插件(blog、search、meta、redirects 等),不执行第三方 MkDocs 插件,也不支持
hooks - 所以写进源文件的就得是密文:HTML、
.md镜像、git、搜索索引、AI 拿到的全是密文,只有浏览器里解锁才还原。实现上是一段前端脚本,和```echarts的渲染同一种做法,不依赖插件机制
原理:一把钥匙、一对钥匙、两样都用
对称加密:加解密用同一把钥匙
钥匙是 256 位随机数,2²⁵⁶ 种可能,全世界的计算机一起试,宇宙寿命也试不完。算法公开,安全只靠钥匙——这是密码学的基本原则。代表是 AES、ChaCha20。
非对称加密:锁和钥匙分开
age 生成的是一对钥匙:
| 长什么样 | 作用 | 能不能公开 | |
|---|---|---|---|
| 公钥 | age1clt8yf9p…,62 个字符 | 只能加密 | 能,本站的写在 scripts/age-seal.sh 里 |
| 私钥 | AGE-SECRET-KEY-1…,74 个字符 | 只能解密 | 不能,只留在自己手里 |
公钥是一把挂锁,谁都能锁上,只有私钥能打开。所以加密时不碰任何秘密——新打码一个 key,用公钥锁就行。还可以写多个收件人:几对钥匙,加密时公钥都写上,哪把私钥都能解开。
混合加密:两样都用
非对称加密慢,不适合直接加密数据。age 每加密一次,先随机生成一把对称的 file key 加密内容,再用公钥把 file key 锁进头部:
flowchart LR
V[明文] -->|file key + ChaCha20-Poly1305| C[密文]
D[随机 file key] -->|公钥 X25519 锁住| W[头部里锁好的 file key]
C --> F[(age 文件)]
W --> F 解密反过来:私钥解开 file key,file key 再解出内容。开发环境第 0 步里那段打码的服务器地址,解开 base64 后头部长这样:
age-encryption.org/v1
-> X25519 QLEKblpsJKIqU4Yi9WdjwkcxX8ilrMU35JnzGT0p3QQ
KJXX509Foznv4s23SZuoYPqigIx7vPVfdKV7dlajA1Q
--- Yn5Z7vhC1lZum0JQ2nVDBHr5V2mboY6sU9Pk…
(后面是二进制:16 字节 nonce + 内容密文 + 16 字节校验码)
-> X25519后面是加密时现生成的临时公钥,每段密文都不一样:私钥和它做一次 X25519,算出的 shared secret 再派生出锁 file key 的钥匙。所以同一段明文每次加密结果都不同,看不出两段是不是一样- 下一行是锁好的 file key;
---后面是头部 MAC,头部被改一个字节解密就报错 - 内容每 64 KB 一块,每块带 16 字节校验码,改了、截了都会被发现
- 密文长度暴露明文长度:这段是 212 字节,扣掉固定的头部和校验码,明文 12 字节,正好是一个 IP
- SOPS 也是同一套,区别是按值加密:键名留明文,diff 看得出改了哪个 key
人只记一句口令:scrypt 撑住短口令
74 个字符的私钥记不住,所以用 age -p 拿一句口令把它锁起来。口令短了安不安全,不看多短,看攻击者能不能无限次地猜:
- 离线猜:攻击者拿到了密文,在自己机器上一直试,没人拦。只能靠每猜一次的成本拖住他
- 在线猜:每次都要经过他绕不开的东西——硬件芯片、服务器——错几次就锁死。手机锁屏用 6 位数字就够,靠的是这个
本站锁好的私钥公开在站上,属于离线猜。age -p 用 scrypt 把口令拉伸成钥匙,强度写死 N = 2¹⁸:每算一次要 256 MB 内存,本机实测解一次 0.4 秒。内存吃得多,显卡也并行不了几路。按 100 个核、每核每秒猜 2.5 次估:
| 口令 | 可能性 | 全部猜完 |
|---|---|---|
| 6 位数字 | 10⁶ | 约 1 小时,不行 |
| 8 位随机字母 + 数字 | 62⁸ ≈ 2.2×10¹⁴ | 约 2.8 万年 |
4 个随机词,如 river-candle-moth-plaza | 7776⁴ ≈ 3.7×10¹⁵ | 约 46 万年 |
- 口令最好随机生成。生日、名字缩写、自己凑的词组会被字典先试到,实际强度比表里低几个数量级。
age -p提示输口令时直接回车,会自动生成一句 10 个词的(实测:cycle-film-giraffe-confirm-patch-novel-echo-army-gossip-rent);嫌长就从 7776 词的 EFF 词表里随机抽 4 个 - 换口令很便宜:私钥不变,用新口令重锁一次、替换
age-init.js里的WIKI_KEY_AGE就行,已经打码的密文都是公钥加密的,一个不用动 - 想短到 6 位 PIN 只能靠硬件限次,见上面第二张表
打码怎么设计的
flowchart LR
W[写作:scripts/age-seal.sh] -->|只用公钥 age -r| T[密文写进 markdown]
T --> P[页面上显示马赛克]
K[口令锁住的私钥<br/>内嵌在 age-init.js] -->|输口令,scrypt 一次| S[私钥]
I[粘贴私钥原文] --> S
S -->|每段 X25519,几毫秒| R[就地还原]
P --> R - 写的一端不碰秘密:
age -r只要公钥,公钥写在scripts/age-seal.sh里,打码新的东西不用输任何口令 - 读的一端 scrypt 只跑一次:口令先解开内嵌的锁好私钥(约 0.6 秒),之后每段密文用 X25519 解开只要几毫秒。换成口令模式(每段各自
age -p),每段都得各跑一次 scrypt,一页几段就要等几秒 - 解锁框认两种输入:口令,或者私钥原文
AGE-SECRET-KEY-1…。自己的设备上从密码管理器粘私钥,不用等 scrypt - 解开的私钥记在 sessionStorage:同一个标签页翻页自动还原,关掉标签页就忘,右下角「锁上打码」随时清掉。缓存私钥而不是口令,是因为私钥能直接解还没见过的密文,口令每到新页面还得重跑 scrypt
- 复制按钮跟着解锁走:解锁过的标签页,复制按钮把密文换成明文;没解锁换成【已加密】,不把几百字的密文塞进 AI 的上下文。
age-init.js挂出WikiAge.open给它用,同一把记下的私钥
风险落在哪
- 锁好的私钥公开在站上:谁都能拿去离线猜口令,每猜一次要付一遍 scrypt。安全全看口令强度,估算见 scrypt 那一节
- wiki 钥匙对专用:别的地方要用 age,另生成一对,否则猜中一句口令,几条链一起漏
- 口令被猜中时,打码的内容一起漏:所以 API key 写进来之前先设用量上限;云主账号凭证、能直接接管账号的东西不放
- 浏览器是最不可信的一环:解开后明文就在页面上,能读页面的浏览器插件看得到;解锁过的标签页,复制按钮复制出的也是明文,贴进对话就交给了 AI 厂商;sessionStorage 里的私钥,本站任何脚本都读得到。本站脚本全部自托管,但别在借来的电脑上解锁
- 打码要赶在第一次发布之前:上过线的明文,覆盖之后缓存和爬虫里可能还在,只能当它已经公开
实现
| 文件 | 做什么 |
|---|---|
vendor/age-core.js | age v1 解密核心:scrypt、ChaCha20-Poly1305、X25519、bech32,手写不压缩;口令和公钥两种模式都能解,开头的解密页也用它 |
vendor/age-init.js | 扫描 `age:…` 和 ```age 块,换成马赛克;解锁框、sessionStorage、锁上按钮;内嵌口令锁住的私钥 WIKI_KEY_AGE;挂出 WikiAge.open 给复制按钮解密。页面上没有密文就不加载 core |
scripts/age-seal.sh | 写作时生成密文:内置 wiki 公钥,read -s 读明文,不进 shell 历史 |
scripts/age-open.py | 读的一端的命令行版:从标准输入读整页 Markdown,把密文全解开再输出,见命令行里一口气解开 |
mkdocs.yml | 注册 age fence 和 age-init.js |
- 代码块里的密文要跨节点替换:Pygments 把代码拆成很多个 span,一段密文可能横跨好几个文字节点。先把文字节点拼起来找位置,再用 Range 跨节点删掉、塞进马赛克
- 认密文靠固定前缀:
age:加上「age-encryption.org/v1」这串字的 base64。普通文字里的usage:不会误伤;代价是文档里举例只能写age:…,写全前缀就会被当成密文 - 马赛克固定 8 个 ▒,不暴露明文多长;但密文本身的长度还是能看出明文大概多长
- 2026-10-01 在 node 里对拍过:X25519 过了 RFC 7748 测试向量,并和 node 内置实现比对 20 组;命令行
age -r出的 armor 和单行密文、age -p -a出的口令密文,0 字节到 200 KB 共 7 种长度都解对;锁好的私钥用口令能解开,少一个字符就被拒。页面那一半借 jsdom 测了:用 Pygments 真实高亮出的 dev-env 代码块(密文末尾的=被拆进了单独的 span),未解锁时三处都换成马赛克、普通文字不误伤,sessionStorage 里有私钥时三处都还原、出现锁上按钮
装 age、换口令、找回私钥
写作的机器要有 age 才能跑 scripts/age-seal.sh。它是单个二进制,从 GitHub Releases 下(访问不了 GitHub 先做开发环境第 0 步开代理):
curl -sL https://github.com/FiloSottile/age/releases/download/v1.3.2/age-v1.3.2-linux-amd64.tar.gz | tar xz
install -D age/age age/age-keygen -t ~/.local/bin/
验收:age --version 打印 v1.3.2。
换口令:私钥不变,用新口令重锁一次,把输出整段替换 age-init.js 里的 WIKI_KEY_AGE:
找回私钥:私钥原文只在写作机的 ~/.config/age/wiki.key,但仓库里的 WIKI_KEY_AGE 就是它的口令锁住版,记得口令就能在任何一台机器上找回来:
sed -n '/BEGIN AGE ENCRYPTED FILE/,/END AGE ENCRYPTED FILE/p' docs/vendor/age-init.js \
| sed 's/`;$//; s/^.*`//' | age -d -o ~/.config/age/wiki.key # 输口令
比对私钥用 cmp -s,别用 diff、cat:2026-10-01 就是 diff 把私钥原文打进了终端记录,只好整对换掉。
命令行里一口气解开
AI 或脚本读 .md 源时没有浏览器,打码的内容全是密文。age-open.py 接在管道后面,整页一次解开:
set -a; source .env; set +a # .env 里写 WIKI_AGE_PASS=口令
curl -fsSL https://wiki.liuhetian.work/projects/ai-assistant/dev-env.md \
| uv run https://wiki.liuhetian.work/projects/wiki/assets/age-open.py
- 口令不收命令行参数:参数会留在 shell 历史、
ps和 AI 的对话记录里,10-01 那次口令就是这样进了对话记录。凭据按顺序找:WIKI_AGE_KEY_FILE指向的私钥文件(不跑 scrypt)→ 环境变量WIKI_AGE_PASS→ 终端提示输入 - 钥匙只有一处真身:口令模式运行时去站上取
vendor/age-init.js里的WIKI_KEY_AGE,换钥匙对不用改脚本 - 两种 Markdown 都认:源文件里的
```age块解开后变普通代码块;官方 llmstxt 插件导出的版本里 armor 本来就在普通代码块里,照样认。提示框里缩进的块,缩进跟着走 - 解不开的原样保留:标准错误里报数;一段都没解开时退出码 1。没有密文的页原样输出,不要凭据
- 解开的明文进了 AI 的上下文:等于在那台机器上解锁了一次,只在自己的机器上这么用
- 2026-10-15 测过:私钥模式解源文件和插件导出版;口令模式用一把测试私钥和测试口令搭的假站,走
uv run <网址>;口令错报错退出;站上真实的age-init.js能提取出WIKI_KEY_AGE
assets/age-open.py —— 命令行解密脚本(PEP 723 单文件,依赖 pyrage)
# /// script
# requires-python = ">=3.11"
# dependencies = ["pyrage>=1.4"]
# ///
"""把 wiki 页面 Markdown 里打码的部分一口气解开:从标准输入读,解开后写到标准输出。
curl -fsSL https://wiki.liuhetian.work/<页面>.md | uv run https://wiki.liuhetian.work/projects/wiki/assets/age-open.py
认的密文和浏览器里的 vendor/age-init.js 一致:
- 小字 age:<整个 age 文件的 base64>,解开后留在原来的反引号里
- armor 块 -----BEGIN AGE ENCRYPTED FILE----- … -----END …,解开后 ```age 换成普通代码块;
官方 llmstxt 插件导出的版本里它本来就是普通代码块,也认
凭据按顺序找。口令不收命令行参数:参数会留在 shell 历史、ps 和 AI 的对话记录里。
1. WIKI_AGE_KEY_FILE 指向私钥文件(AGE-SECRET-KEY-1…):直接用私钥,不跑 scrypt
2. 环境变量 WIKI_AGE_PASS:口令写进客户端的 .env,命令里不出现
3. 都没有就在终端提示输入,不回显
口令模式先从站上取 vendor/age-init.js 里口令锁住的 WIKI_KEY_AGE——钥匙只有那一处真身,换钥匙不用改这里——
跑一次 scrypt 解出私钥,之后每段用 X25519 解。WIKI_SITE 可以换站点地址。
输入里没有密文就原样输出,不要凭据。解不开的段落原样保留、在标准错误里报数;一段都没解开时退出码 1。
解开的明文只往标准输出写,私钥不落盘、不打印。
"""
import base64
import getpass
import os
import re
import sys
import urllib.request
import pyrage
SITE = os.environ.get("WIKI_SITE", "https://wiki.liuhetian.work").rstrip("/")
# 前缀是「age-encryption.org/v1」的 base64,和 age-init.js 的 TOKEN 同一条
TOKEN = re.compile(r"age:(YWdlLWVuY3J5cHRpb24ub3JnL3Yx[A-Za-z0-9+/]*={0,2})")
# armor 块,可带开头的 ```age 那一行;缩进跟着走(提示框、折叠块里的代码块缩进 4 格)
ARMOR = re.compile(
r"^(?P<fence>[ \t]*(?:`{3,}|~{3,})age[ \t]*\n)?"
r"(?P<indent>[ \t]*)-----BEGIN AGE ENCRYPTED FILE-----[ \t]*\n"
r"(?P<body>.*?)\n[ \t]*-----END AGE ENCRYPTED FILE-----",
re.S | re.M,
)
LOCKED_KEY = re.compile(
r"WIKI_KEY_AGE = `(-----BEGIN AGE ENCRYPTED FILE-----.*?-----END AGE ENCRYPTED FILE-----)`", re.S
)
def dearmor(armor_body: str) -> bytes:
return base64.b64decode("".join(armor_body.split()))
def load_identity() -> pyrage.x25519.Identity:
key_file = os.environ.get("WIKI_AGE_KEY_FILE")
if key_file:
with open(os.path.expanduser(key_file)) as f:
line = next((l for l in f if l.startswith("AGE-SECRET-KEY-1")), None)
if line is None:
sys.exit(f"age-open:{key_file} 里没有 AGE-SECRET-KEY-1 开头的私钥")
return pyrage.x25519.Identity.from_str(line.strip())
passphrase = os.environ.get("WIKI_AGE_PASS") or getpass.getpass("wiki 打码口令:")
with urllib.request.urlopen(f"{SITE}/vendor/age-init.js", timeout=30) as r:
js = r.read().decode()
m = LOCKED_KEY.search(js)
if not m:
sys.exit(f"age-open:{SITE}/vendor/age-init.js 里没找到 WIKI_KEY_AGE")
locked = dearmor(ARMOR.search(m.group(1)).group("body"))
try:
secret = pyrage.passphrase.decrypt(locked, passphrase).decode().strip()
except pyrage.DecryptError:
sys.exit("age-open:口令不对,WIKI_KEY_AGE 解不开")
return pyrage.x25519.Identity.from_str(secret)
def main() -> None:
text = sys.stdin.buffer.read().decode("utf-8")
if not TOKEN.search(text) and "-----BEGIN AGE ENCRYPTED FILE-----" not in text:
sys.stdout.buffer.write(text.encode("utf-8"))
return
identity = load_identity()
opened = failed = 0
def decrypt(data: bytes) -> str | None:
nonlocal opened, failed
try:
plain = pyrage.decrypt(data, [identity]).decode("utf-8")
except (pyrage.DecryptError, ValueError, UnicodeDecodeError):
failed += 1
return None
opened += 1
return plain
def open_token(m: re.Match) -> str:
plain = decrypt(base64.b64decode(m.group(1)))
return m.group(0) if plain is None else plain
def open_armor(m: re.Match) -> str:
plain = decrypt(dearmor(m.group("body")))
if plain is None:
return m.group(0)
indent = m.group("indent")
fence = m.group("fence") or ""
fence = re.sub(r"age[ \t]*\n$", "\n", fence) # ```age → ```
return fence + "\n".join(indent + line if line else line for line in plain.rstrip("\n").split("\n"))
text = ARMOR.sub(open_armor, text)
text = TOKEN.sub(open_token, text)
sys.stdout.buffer.write(text.encode("utf-8"))
if failed:
print(f"age-open:{failed} 段解不开,原样保留(解开 {opened} 段)", file=sys.stderr)
if opened == 0:
sys.exit(1)
if __name__ == "__main__":
main()
开头那个网页:age 解密
网页手写了 age v1 的解密:口令模式用 scrypt 拉伸口令,公钥模式用 X25519 算出 shared secret,内容都用 ChaCha20-Poly1305 解开;HKDF 和 HMAC 用浏览器自带的 Web Crypto,全在本地算。密码学在共享的 vendor/age-core.js,页面里只剩交互。
可以这样试:
- 输入口令点「解密」,展开「解密过程」,看每一步做了什么
- 口令错一个字母:卡在「解开 file key」那一步,直接拒绝,不会解出一串乱码——为什么拒绝而不是吐假数据,见对比表蜜罐那一行
- 密文中间改一个字符:校验码对不上,同样拒绝
assets/age-passphrase.html —— 解密单页源码(自包含、未压缩)
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>age 解密:在浏览器里解开 age -a 的密文</title>
<link rel="stylesheet" href="/vendor/fonts/fonts.css">
<style>
:root {
--paper: #fafafa; --bone: #fff; --ink: #262626; --mute: #595959; --faint: #8c8c8c;
--line: #d9d9d9; --accent: #16a005; --alert: #a8071a;
}
* { box-sizing: border-box; }
body {
margin: 0; padding: 16px; background: var(--paper); color: var(--ink);
font: 14px/1.6 "Maple Mono", -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
}
.top { display: flex; flex-wrap: wrap; gap: 4px 12px; align-items: baseline; margin-bottom: 12px; }
.top b { font-size: 16px; }
.top span { color: var(--mute); font-size: 13px; }
section { background: var(--bone); border: 1px solid var(--line); border-radius: 10px; padding: 12px; }
label { display: block; color: var(--mute); font-size: 13px; margin-bottom: 8px; }
input, textarea {
display: block; width: 100%; margin-top: 4px; padding: 6px 8px;
border: 1px solid var(--line); border-radius: 6px; background: var(--paper); color: var(--ink);
font: 13px/1.5 "Maple Mono", ui-monospace, Menlo, Consolas, monospace;
}
textarea { resize: vertical; white-space: pre; overflow-wrap: normal; }
#cipher { height: 150px; }
#plain { height: 80px; }
.row { display: flex; gap: 8px; align-items: end; margin-bottom: 8px; }
.row label { flex: 1; margin: 0; }
button {
padding: 7px 14px; border: 1px solid var(--ink); border-radius: 6px; background: var(--ink); color: #fff;
font: inherit; font-size: 13px; cursor: pointer;
}
button.ghost { background: var(--bone); color: var(--ink); }
button:disabled { opacity: .4; cursor: wait; }
#status { margin: 10px 0 4px; min-height: 22px; font-size: 13px; }
#status.ok { color: var(--accent); }
#status.err { color: var(--alert); }
details { font-size: 13px; color: var(--mute); }
summary { cursor: pointer; }
ol { margin: 6px 0 0; padding-left: 22px; }
li { margin: 0 0 6px; color: var(--ink); }
li.fail { color: var(--alert); }
li .v { display: block; color: var(--accent); font-size: 12px; word-break: break-all; }
li .n { display: block; color: var(--faint); font-size: 12px; }
pre {
margin: 10px 0 0; padding: 8px 10px; background: var(--bone); border: 1px dashed var(--line); border-radius: 6px;
font: 12px/1.5 "Maple Mono", ui-monospace, Menlo, Consolas, monospace; white-space: pre-wrap; word-break: break-all;
}
pre:empty { display: none; }
</style>
</head>
<body>
<div class="top">
<b>age 解密</b>
<span>全在浏览器本地计算,页面不往外发数据;能解 <code>age -p -a</code>(口令)和 <code>age -r -a</code>(公钥)加密的文本</span>
</div>
<section>
<!-- 预填密文:换成自己 age -p -a 的输出,BEGIN 到 END 整段替换 -->
<label>密文<textarea id="cipher" spellcheck="false">-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IHNjcnlwdCBNQW82R1lJNUNqU25WZmJR
bm1Wb0VnIDE4CmExOHBLWk5jbmJCaktVR0lGY3ZFcTQ5WEliWWJRODhSY1h4dTJh
bmd3bWsKLS0tIEloQmJTMFFtK2ppdTRWL0RrZXQ3K3NROWF5b3FpWG42WHgwM2lh
bkZIekkKGQpLakqtT/W+EkKerGg+inKqoADNa3TecGXv4pkcc6gEWA7orpvKj1lx
B5vNO28NX2wcr8rT9tq8kSxqRuM=
-----END AGE ENCRYPTED FILE-----</textarea></label>
<div class="row">
<label>口令或私钥<input id="pass" type="password" autocomplete="off" spellcheck="false" placeholder="加密时用的口令,或 AGE-SECRET-KEY-1…;回车解密"></label>
<button id="btnShow" class="ghost">显示</button>
<button id="btnDec">解密</button>
</div>
<label>明文<textarea id="plain" readonly spellcheck="false" placeholder="解密后显示在这里"></textarea></label>
</section>
<div id="status"></div>
<details>
<summary>解密过程</summary>
<ol id="steps"></ol>
<pre id="headerView"></pre>
</details>
<!-- 密码学核心和全站打码共用一份:口令模式(scrypt)、公钥模式(X25519)都能解 -->
<script src="/vendor/age-core.js"></script>
<!-- ===== 第二段:页面交互 ===== -->
<script>
const $ = id => document.getElementById(id);
function setStatus(text, kind) {
$("status").textContent = text;
$("status").className = kind || "";
}
function addStep({ title, value, note, fail }) {
const li = document.createElement("li");
if (fail) li.className = "fail";
li.append(title);
if (value) { const v = document.createElement("span"); v.className = "v"; v.textContent = value; li.append(v); }
if (note) { const n = document.createElement("span"); n.className = "n"; n.textContent = note; li.append(n); }
$("steps").append(li);
}
async function decrypt() {
$("btnDec").disabled = true;
$("steps").innerHTML = "";
$("headerView").textContent = "";
$("plain").value = "";
try {
const pass = $("pass").value;
if (!pass) throw new Error("先输入口令或私钥");
const file = AgeCore.dearmor($("cipher").value);
const secret = AgeCore.isIdentity(pass) ? { identity: AgeCore.parseIdentity(pass) } : { passphrase: pass };
setStatus("scrypt 计算中…");
await new Promise(f => setTimeout(f, 30)); // 让「计算中」先画出来
const { plaintext, header } = await AgeCore.ageDecrypt(file, secret, addStep, f => setStatus(`scrypt 计算中… ${Math.round(f * 100)}%`));
try { $("plain").value = new TextDecoder("utf-8", { fatal: true }).decode(plaintext); }
catch { throw new Error(`解密成功,但解出的 ${plaintext.length} 字节不是 UTF-8 文本,网页没法显示`); }
$("headerView").textContent = "armor 里面其实是这段文字头部,后面跟着二进制内容:\n\n" + header;
setStatus("✓ 解密完成", "ok");
} catch (e) {
const msg = e instanceof RangeError ? "浏览器分不出这么多内存" : e.message;
addStep({ title: "✗ " + msg, fail: true });
setStatus("✗ " + msg, "err");
} finally {
$("btnDec").disabled = false;
}
}
$("btnDec").onclick = decrypt;
$("pass").onkeydown = e => { if (e.key === "Enter") decrypt(); };
$("btnShow").onclick = () => {
const shown = $("pass").type === "text";
$("pass").type = shown ? "password" : "text";
$("btnShow").textContent = shown ? "显示" : "隐藏";
};
</script>
</body>
</html>
vendor/age-core.js —— age 解密核心(口令 + 公钥两种模式,未压缩)
// age v1 解密核心:口令模式(scrypt)和公钥模式(X25519)都能解。纯函数,不碰 DOM。
// 对照规范读:https://age-encryption.org/v1 。用的人:
// - vendor/age-init.js:全站打码(`age:…` 小字和 ```age 块),按需加载本文件
// - projects/wiki/assets/age-passphrase.html:解密演示页
// SHA-256 系(HMAC / HKDF / PBKDF2)用浏览器自带的 Web Crypto;scrypt、ChaCha20-Poly1305、
// X25519、bech32 Web Crypto 没有(或不是每个浏览器都有),手写,不压缩,一眼能对上规范。
(function () {
const te = new TextEncoder();
const subtle = crypto.subtle;
const CHUNK = 64 * 1024; // age 内容按 64 KiB 一块加密
const SCRYPT_LABEL = te.encode("age-encryption.org/v1/scrypt");
const X25519_INFO = "age-encryption.org/v1/X25519";
const MAX_LOG_N = 20; // 2^20 要 1 GB 内存,再高浏览器扛不住
// ---------- 字节小工具 ----------
function concat(...parts) {
const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
let off = 0;
for (const p of parts) { out.set(p, off); off += p.length; }
return out;
}
const hex = b => Array.from(b, x => x.toString(16).padStart(2, "0")).join("");
function equalBytes(a, b) {
if (a.length !== b.length) return false;
let d = 0;
for (let i = 0; i < a.length; i++) d |= a[i] ^ b[i];
return d === 0;
}
function leToBigInt(bytes) {
let n = 0n;
for (let i = bytes.length - 1; i >= 0; i--) n = (n << 8n) | BigInt(bytes[i]);
return n;
}
function bigIntToLe(n, length) {
const out = new Uint8Array(length);
for (let i = 0; i < length; i++) { out[i] = Number(n & 0xffn); n >>= 8n; }
return out;
}
// base64:age 头部用标准字母表、不带 = 填充;armor 外壳和单行 `age:` 小字带不带填充都认
function b64decode(str) {
const s = atob(str);
const out = new Uint8Array(s.length);
for (let i = 0; i < s.length; i++) out[i] = s.charCodeAt(i);
return out;
}
function b64rawDecode(str) {
if (!/^[A-Za-z0-9+/]*$/.test(str) || str.length % 4 === 1) throw new Error("头部的 base64 不合法");
return b64decode(str + "==".slice(0, (4 - str.length % 4) % 4));
}
// ---------- SHA-256 系:浏览器自带的 Web Crypto ----------
async function hmacSha256(key, data) {
const k = await subtle.importKey("raw", key, { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
return new Uint8Array(await subtle.sign("HMAC", k, data));
}
async function hkdfSha256(ikm, salt, info, length) {
const k = await subtle.importKey("raw", ikm, "HKDF", false, ["deriveBits"]);
const bits = await subtle.deriveBits({ name: "HKDF", hash: "SHA-256", salt, info: te.encode(info) }, k, length * 8);
return new Uint8Array(bits);
}
async function pbkdf2Once(password, salt, length) {
const k = await subtle.importKey("raw", password, "PBKDF2", false, ["deriveBits"]);
const bits = await subtle.deriveBits({ name: "PBKDF2", hash: "SHA-256", salt, iterations: 1 }, k, length * 8);
return new Uint8Array(bits);
}
// ---------- scrypt(RFC 7914,r = 8,p = 1) ----------
const rotl = (v, n) => (v << n) | (v >>> (32 - n));
function salsa20_8(B) { // 原地变换 16 个 32 位字
let x0 = B[0], x1 = B[1], x2 = B[2], x3 = B[3], x4 = B[4], x5 = B[5], x6 = B[6], x7 = B[7],
x8 = B[8], x9 = B[9], x10 = B[10], x11 = B[11], x12 = B[12], x13 = B[13], x14 = B[14], x15 = B[15];
for (let i = 0; i < 8; i += 2) {
// 列
x4 ^= rotl(x0 + x12, 7); x8 ^= rotl(x4 + x0, 9); x12 ^= rotl(x8 + x4, 13); x0 ^= rotl(x12 + x8, 18);
x9 ^= rotl(x5 + x1, 7); x13 ^= rotl(x9 + x5, 9); x1 ^= rotl(x13 + x9, 13); x5 ^= rotl(x1 + x13, 18);
x14 ^= rotl(x10 + x6, 7); x2 ^= rotl(x14 + x10, 9); x6 ^= rotl(x2 + x14, 13); x10 ^= rotl(x6 + x2, 18);
x3 ^= rotl(x15 + x11, 7); x7 ^= rotl(x3 + x15, 9); x11 ^= rotl(x7 + x3, 13); x15 ^= rotl(x11 + x7, 18);
// 行
x1 ^= rotl(x0 + x3, 7); x2 ^= rotl(x1 + x0, 9); x3 ^= rotl(x2 + x1, 13); x0 ^= rotl(x3 + x2, 18);
x6 ^= rotl(x5 + x4, 7); x7 ^= rotl(x6 + x5, 9); x4 ^= rotl(x7 + x6, 13); x5 ^= rotl(x4 + x7, 18);
x11 ^= rotl(x10 + x9, 7); x8 ^= rotl(x11 + x10, 9); x9 ^= rotl(x8 + x11, 13); x10 ^= rotl(x9 + x8, 18);
x12 ^= rotl(x15 + x14, 7); x13 ^= rotl(x12 + x15, 9); x14 ^= rotl(x13 + x12, 13); x15 ^= rotl(x14 + x13, 18);
}
B[0] += x0; B[1] += x1; B[2] += x2; B[3] += x3; B[4] += x4; B[5] += x5; B[6] += x6; B[7] += x7;
B[8] += x8; B[9] += x9; B[10] += x10; B[11] += x11; B[12] += x12; B[13] += x13; B[14] += x14; B[15] += x15;
}
function blockMix(B, Y, X, r) { // B 是 2r 个 64 字节块
X.set(B.subarray((2 * r - 1) * 16, 2 * r * 16));
for (let i = 0; i < 2 * r; i++) {
for (let k = 0; k < 16; k++) X[k] ^= B[i * 16 + k];
salsa20_8(X);
Y.set(X, ((i & 1) ? r + (i >> 1) : (i >> 1)) * 16); // 偶数块排前、奇数块排后
}
B.set(Y);
}
// onProgress(0~1):每算完一段让出一次主线程,好让页面刷新进度
async function scrypt(password, salt, logN, length, onProgress) {
const r = 8, N = 2 ** logN, words = 32 * r;
const bytes = await pbkdf2Once(password, salt, 128 * r);
const X = new Uint32Array(words);
const dv = new DataView(bytes.buffer);
for (let i = 0; i < words; i++) X[i] = dv.getUint32(4 * i, true);
const V = new Uint32Array(words * N); // 吃内存的就是这张表:128 × r × N 字节
const Y = new Uint32Array(words), T = new Uint32Array(16);
const step = Math.min(N, 1 << 15);
for (let i = 0; i < N; i++) {
V.set(X, i * words);
blockMix(X, Y, T, r);
if (onProgress && (i + 1) % step === 0) { onProgress((i + 1) / (2 * N)); await new Promise(f => setTimeout(f)); }
}
for (let i = 0; i < N; i++) {
const j = X[(2 * r - 1) * 16] & (N - 1); // 按当前状态跳着读表,没法只存一部分
for (let k = 0; k < words; k++) X[k] ^= V[j * words + k];
blockMix(X, Y, T, r);
if (onProgress && (i + 1) % step === 0) { onProgress(0.5 + (i + 1) / (2 * N)); await new Promise(f => setTimeout(f)); }
}
for (let i = 0; i < words; i++) dv.setUint32(4 * i, X[i], true);
return pbkdf2Once(password, bytes, length);
}
// ---------- ChaCha20-Poly1305(RFC 8439)解密 ----------
function chacha20Block(key, counter, nonce) { // 32 字节钥匙 + 12 字节 nonce → 64 字节密钥流
const s = new Uint32Array(16);
s.set([0x61707865, 0x3320646e, 0x79622d32, 0x6b206574]);
const kv = new DataView(key.buffer, key.byteOffset, 32), nv = new DataView(nonce.buffer, nonce.byteOffset, 12);
for (let i = 0; i < 8; i++) s[4 + i] = kv.getUint32(4 * i, true);
s[12] = counter;
for (let i = 0; i < 3; i++) s[13 + i] = nv.getUint32(4 * i, true);
const x = Int32Array.from(s);
const qr = (a, b, c, d) => {
x[a] += x[b]; x[d] = rotl(x[d] ^ x[a], 16);
x[c] += x[d]; x[b] = rotl(x[b] ^ x[c], 12);
x[a] += x[b]; x[d] = rotl(x[d] ^ x[a], 8);
x[c] += x[d]; x[b] = rotl(x[b] ^ x[c], 7);
};
for (let i = 0; i < 10; i++) {
qr(0, 4, 8, 12); qr(1, 5, 9, 13); qr(2, 6, 10, 14); qr(3, 7, 11, 15);
qr(0, 5, 10, 15); qr(1, 6, 11, 12); qr(2, 7, 8, 13); qr(3, 4, 9, 14);
}
const out = new Uint8Array(64), ov = new DataView(out.buffer);
for (let i = 0; i < 16; i++) ov.setUint32(4 * i, (x[i] + s[i]) >>> 0, true);
return out;
}
function chacha20Xor(key, nonce, counter, data) {
const out = new Uint8Array(data.length);
for (let off = 0; off < data.length; off += 64, counter++) {
const ks = chacha20Block(key, counter, nonce);
for (let i = 0; i < 64 && off + i < data.length; i++) out[off + i] = data[off + i] ^ ks[i];
}
return out;
}
function poly1305(key, msg) { // 用 BigInt 直译公式,慢但一眼能对上规范
const r = leToBigInt(key.subarray(0, 16)) & 0x0ffffffc0ffffffc0ffffffc0fffffffn;
const s = leToBigInt(key.subarray(16, 32));
const p = (1n << 130n) - 5n;
let acc = 0n;
for (let i = 0; i < msg.length; i += 16) {
const block = msg.subarray(i, i + 16);
acc = ((acc + leToBigInt(block) + (1n << BigInt(8 * block.length))) * r) % p;
}
return bigIntToLe((acc + s) & ((1n << 128n) - 1n), 16);
}
function macInput(ciphertext) { // 没有附加数据:密文 ‖ 补零到 16 的倍数 ‖ 0 ‖ 密文长度
const padded = Math.ceil(ciphertext.length / 16) * 16;
const m = new Uint8Array(padded + 16);
m.set(ciphertext);
new DataView(m.buffer).setBigUint64(padded + 8, BigInt(ciphertext.length), true);
return m;
}
function open(key, nonce, sealed) { // 校验码对不上返回 null,一个字节都不解
if (sealed.length < 16) return null;
const ct = sealed.subarray(0, sealed.length - 16), tag = sealed.subarray(sealed.length - 16);
const otk = chacha20Block(key, 0, nonce).subarray(0, 32);
if (!equalBytes(poly1305(otk, macInput(ct)), tag)) return null;
return chacha20Xor(key, nonce, 1, ct);
}
// ---------- X25519(RFC 7748):Montgomery ladder,BigInt 直译 ----------
const P25519 = (1n << 255n) - 19n;
const mod = a => { const r = a % P25519; return r < 0n ? r + P25519 : r; };
function powMod(b, e) {
let r = 1n;
b = mod(b);
for (; e > 0n; e >>= 1n) { if (e & 1n) r = r * b % P25519; b = b * b % P25519; }
return r;
}
function x25519(scalar, u) { // 32 字节标量 × 32 字节 u 坐标 → 32 字节
const k = scalar.slice();
k[0] &= 248; k[31] &= 127; k[31] |= 64; // clamp
const n = leToBigInt(k);
const ub = u.slice();
ub[31] &= 127; // 最高位按规范忽略
const x1 = mod(leToBigInt(ub));
let x2 = 1n, z2 = 0n, x3 = x1, z3 = 1n, swap = 0n;
for (let t = 254n; t >= 0n; t--) {
const bit = (n >> t) & 1n;
if (swap ^ bit) { [x2, x3] = [x3, x2]; [z2, z3] = [z3, z2]; }
swap = bit;
const A = mod(x2 + z2), AA = A * A % P25519, B = mod(x2 - z2), BB = B * B % P25519, E = mod(AA - BB);
const C = mod(x3 + z3), D = mod(x3 - z3), DA = D * A % P25519, CB = C * B % P25519;
x3 = mod(DA + CB) ** 2n % P25519;
z3 = x1 * (mod(DA - CB) ** 2n % P25519) % P25519;
x2 = AA * BB % P25519;
z2 = E * mod(AA + 121665n * E) % P25519;
}
if (swap) { [x2, x3] = [x3, x2]; [z2, z3] = [z3, z2]; }
return bigIntToLe(x2 * powMod(z2, P25519 - 2n) % P25519, 32);
}
const BASE_POINT = bigIntToLe(9n, 32);
// ---------- 私钥 AGE-SECRET-KEY-1…:bech32(BIP 173,不是 bech32m) ----------
const BECH32 = "qpzry9x8gf2tvdw0s3jn54khce6mua7l";
function bech32Polymod(values) {
const GEN = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3];
let chk = 1;
for (const v of values) {
const top = chk >>> 25;
chk = ((chk & 0x1ffffff) << 5) ^ v;
for (let i = 0; i < 5; i++) if ((top >>> i) & 1) chk ^= GEN[i];
}
return chk;
}
function bech32Decode(str) {
if (str !== str.toLowerCase() && str !== str.toUpperCase()) throw new Error("私钥大小写混用");
const s = str.toLowerCase(), pos = s.lastIndexOf("1");
if (pos < 1 || pos + 7 > s.length) throw new Error("私钥格式不对");
const hrp = s.slice(0, pos), data = [];
for (const c of s.slice(pos + 1)) {
const d = BECH32.indexOf(c);
if (d < 0) throw new Error("私钥里有 bech32 以外的字符");
data.push(d);
}
const codes = Array.from(hrp, c => c.charCodeAt(0));
if (bech32Polymod([...codes.map(c => c >> 5), 0, ...codes.map(c => c & 31), ...data]) !== 1) {
throw new Error("私钥校验和不对:抄错了某个字符");
}
let acc = 0, bits = 0;
const out = [];
for (const w of data.slice(0, -6)) { // 5 位一组 → 8 位一组
acc = (acc << 5) | w; bits += 5;
if (bits >= 8) { bits -= 8; out.push((acc >> bits) & 0xff); acc &= (1 << bits) - 1; }
}
if (bits >= 5 || acc !== 0) throw new Error("私钥的填充位不对");
return { hrp, bytes: new Uint8Array(out) };
}
const isIdentity = str => /^AGE-SECRET-KEY-1[0-9A-Z]+$/.test(str.trim());
function parseIdentity(str) { // → { sk, pk },pk 是对应的公钥(32 字节)
const { hrp, bytes } = bech32Decode(str.trim());
if (hrp !== "age-secret-key-" || bytes.length !== 32) throw new Error("这不是 age 的 X25519 私钥");
return { sk: bytes, pk: x25519(bytes, BASE_POINT) };
}
// age-keygen 写出的私钥文件里有注释行,挑出 AGE-SECRET-KEY-1… 那一行
function findIdentity(text) {
const line = text.split(/\r?\n/).map(l => l.trim()).find(isIdentity);
if (!line) throw new Error("里面没有 AGE-SECRET-KEY-1 开头的私钥");
return line;
}
// ---------- age v1 文件格式 ----------
function streamNonce(counter, last) { // 11 字节大端计数器 ‖ 1 字节「最后一块」标记
const n = new Uint8Array(12);
new DataView(n.buffer).setUint32(7, counter);
n[11] = last ? 1 : 0;
return n;
}
const memMB = logN => 2 ** (logN - 10); // 128 × 8 × 2^logN 字节
function parseHeader(file) {
const marker = te.encode("\n--- ");
let at = -1;
for (let i = 0; i + marker.length <= file.length; i++) {
if (marker.every((c, k) => file[i + k] === c)) { at = i; break; }
}
const end = at < 0 ? -1 : file.indexOf(0x0a, at + marker.length);
if (end < 0) throw new Error("找不到头部结尾的 --- 行,这不是 age 文件");
const headerText = new TextDecoder().decode(file.subarray(0, end));
const lines = headerText.split("\n");
if (lines[0] !== "age-encryption.org/v1") throw new Error("第一行不是 age-encryption.org/v1,这不是 age 文件");
const macLine = lines[lines.length - 1];
const stanzas = [];
let i = 1;
while (i < lines.length - 1) {
if (!lines[i].startsWith("-> ")) throw new Error("头部格式不对:每段要以 -> 开头");
const args = lines[i++].slice(3).split(" ");
let b64 = "";
for (;;) {
if (i >= lines.length - 1) throw new Error("头部格式不对:段落没有结束");
const l = lines[i++];
b64 += l;
if (l.length < 64) break;
}
stanzas.push({ args, body: b64rawDecode(b64) });
}
return {
headerText,
headerNoMac: headerText.slice(0, headerText.length - macLine.length + 3), // 算 MAC 的范围到 --- 为止
mac: b64rawDecode(macLine.slice(4)),
stanzas,
payload: file.subarray(end + 1),
};
}
// 口令段:口令 + 盐 → scrypt → 包装钥匙 → 解开 file key
async function unwrapScrypt(h, passphrase, log, onProgress) {
const s = h.stanzas[0];
if (h.stanzas.length !== 1) throw new Error("口令模式的文件头部只能有一段 scrypt");
const [, saltB64, logNStr] = s.args;
const salt = b64rawDecode(saltB64 || "");
if (salt.length !== 16 || !/^[1-9][0-9]?$/.test(logNStr || "") || s.body.length !== 32) {
throw new Error("scrypt 段的参数不合法");
}
const logN = Number(logNStr);
if (logN > MAX_LOG_N) throw new Error(`强度 N = 2^${logN} 要 ${memMB(logN)} MB 内存,网页最多做到 2^${MAX_LOG_N}`);
log({ title: "读头部:取出盐和强度", value: `盐 ${hex(salt)},N = 2^${logN}`, note: "这两样都明文写在文件里,不是秘密" });
const t0 = performance.now();
const wrapKey = await scrypt(te.encode(passphrase), concat(SCRYPT_LABEL, salt), logN, 32, onProgress);
const ms = Math.round(performance.now() - t0);
log({ title: `scrypt(口令, 盐) → 包装钥匙,用时 ${ms} 毫秒`,
note: `占内存 ${memMB(logN)} MB。攻击者每猜一次口令都要付这个代价;口令对不对,这一步还看不出来` });
const fileKey = open(wrapKey, new Uint8Array(12), s.body);
if (!fileKey) throw new Error("解不开 file key:校验码对不上,口令不对。不会解出一串乱码,而是直接拒绝");
log({ title: "用包装钥匙解开 file key ✓", note: "file key 才是真正加密内容的钥匙;校验码对上了,说明口令对" });
return fileKey;
}
// 公钥段:私钥 × 头部里的临时公钥 → shared secret → HKDF → 包装钥匙 → 解开 file key
async function unwrapX25519(h, identity, log) {
const stanzas = h.stanzas.filter(s => s.args[0] === "X25519");
if (!stanzas.length) throw new Error("这段密文不是用公钥加密的,要输口令");
for (const s of stanzas) { // 多个收件人时逐段试,其他类型(grease 等)跳过
if (s.args.length !== 2 || s.body.length !== 32) throw new Error("X25519 段的参数不合法");
const epk = b64rawDecode(s.args[1]);
if (epk.length !== 32) throw new Error("X25519 段的临时公钥不是 32 字节");
const shared = x25519(identity.sk, epk);
if (shared.every(b => b === 0)) throw new Error("X25519 段的临时公钥不合法");
const wrapKey = await hkdfSha256(shared, concat(epk, identity.pk), X25519_INFO, 32);
const fileKey = open(wrapKey, new Uint8Array(12), s.body);
if (fileKey) {
log({ title: "私钥 × 临时公钥 → shared secret → 解开 file key ✓", value: `临时公钥 ${hex(epk)}`,
note: "临时钥匙是加密时现生成的,每段密文都不一样" });
return fileKey;
}
}
throw new Error("这把私钥解不开这段密文:它是给别的公钥加密的");
}
// secret:{ passphrase } 或 { identity: parseIdentity(...) 的结果 }
// log({ title, value, note }):每做完一步报一次,页面照着列出来
async function ageDecrypt(file, secret, log = () => {}, onProgress) {
const h = parseHeader(file);
let fileKey;
if (h.stanzas.some(s => s.args[0] === "scrypt")) {
if (secret.passphrase === undefined) throw new Error("这段密文是用口令加密的,要输口令");
fileKey = await unwrapScrypt(h, secret.passphrase, log, onProgress);
} else {
if (!secret.identity) throw new Error("这段密文是用公钥加密的,要用私钥解");
fileKey = await unwrapX25519(h, secret.identity, log);
}
const mac = await hmacSha256(await hkdfSha256(fileKey, new Uint8Array(0), "header", 32), te.encode(h.headerNoMac));
if (!equalBytes(mac, h.mac)) throw new Error("头部 MAC 对不上:头部被改过");
log({ title: "校验头部 MAC ✓", note: "MAC 的钥匙从 file key 派生,头部没被改过" });
if (h.payload.length < 16) throw new Error("内容被截断了");
const nonce = h.payload.subarray(0, 16), body = h.payload.subarray(16);
const payloadKey = await hkdfSha256(fileKey, nonce, "payload", 32);
const parts = [];
for (let off = 0, counter = 0; ; off += CHUNK + 16, counter++) {
const last = body.length - off <= CHUNK + 16;
const pt = open(payloadKey, streamNonce(counter, last), body.subarray(off, Math.min(off + CHUNK + 16, body.length)));
if (!pt) throw new Error(`第 ${counter + 1} 块内容的校验码对不上:内容被改过或被截断`);
if (last && counter > 0 && pt.length === 0) throw new Error("最后一块不能是空的");
parts.push(pt);
if (last) break;
}
const plaintext = concat(...parts);
log({ title: `解开内容 ✓:${body.length} 字节密文 → ${plaintext.length} 字节明文`,
note: "内容钥匙 = HKDF(file key, 文件里的随机 nonce);每 64 KB 一块,每块的校验码都对上了" });
return { plaintext, header: h.headerText + "\n" };
}
// armor:age -a 的文本外壳,整个文件转标准 base64,64 列折行
const ARMOR_BEGIN = "-----BEGIN AGE ENCRYPTED FILE-----", ARMOR_END = "-----END AGE ENCRYPTED FILE-----";
function dearmor(text) {
const t = text.trim();
if (!t.startsWith(ARMOR_BEGIN) || !t.endsWith(ARMOR_END)) {
throw new Error("密文要以 " + ARMOR_BEGIN + " 开头、" + ARMOR_END + " 结尾(age -a 的文本格式)");
}
const body = t.slice(ARMOR_BEGIN.length, t.length - ARMOR_END.length).replace(/\s+/g, "");
if (!/^[A-Za-z0-9+/]*={0,2}$/.test(body) || body.length % 4 !== 0) throw new Error("密文里有不属于 base64 的字符");
return b64decode(body);
}
// 单行小字:age: + 整个 age 文件的 base64(scripts/age-seal.sh 生成)
function decodeToken(token) {
const b = token.replace(/^age:/, "").replace(/=+$/, "");
return b64rawDecode(b);
}
globalThis.AgeCore = { ageDecrypt, dearmor, decodeToken, parseHeader, parseIdentity, isIdentity, findIdentity, x25519 };
})();
没做的
- 解锁框本身(
<dialog>弹窗、输口令那一步)和样式还没在浏览器里验过,jsdom 不支持showModal - 只认反引号里的密文,正文里裸写的不认;块解开后是素
<pre><code>,没有语法高亮 - 网关已接 DeepSeek,但 API 中转里还没写 key
- YubiKey 这类硬件钥匙没试
-
encryptcontent,2026-10-01 读。 ↩