跳转至

打码: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 的渲染同一种做法,不依赖插件机制

原理:一把钥匙、一对钥匙、两样都用

对称加密:加解密用同一把钥匙

abcdefg + 钥匙K ──加密──→ x7Qp9…(看上去全是乱码)
x7Qp9…  + 钥匙K ──解密──→ abcdefg

钥匙是 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:

age -p -a ~/.config/age/wiki.key   # 输两遍新口令(第二遍是确认没打错)

找回私钥:私钥原文只在写作机的 ~/.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 这类硬件钥匙没试

  1. encryptcontent,2026-10-01 读。 ↩