Files
blog/blog-admin/src/lib/deployer.ts
T
zqlit 43dbc8b75f feat(ssl): ACME 自动签发与自动续期,实现证书全生命周期闭环
证书管家此前只做「探针」(查剩余天数),现补齐签发+部署两个环节,
参照 certimate(MIT)的 DNS-01 流程自行实现,不再依赖闭源 certd。

新增(纯 WebCrypto,零 npm 依赖):
- lib/acme.ts        ACME v2 客户端:ES256 JWS(原始 r||s)、RFC7638
                     thumbprint、EAB、badNonce 重试、DNS-01、手写 DER CSR
- lib/dnsprovider.ts DNS-01 适配:DNSPod(TC3-HMAC-SHA256)、Cloudflare
- lib/deployer.ts    部署适配:多吉云 CDN、1Panel 站点(幂等换证书)
- lib/certissue.ts   编排:探针判剩余天数 → 注册/复用账户 → 签发 → 落库
                     → 逐目标部署;RENEW_BEFORE_DAYS=30
- routes/ssl.ts      新增 POST /ssl/issue、GET /ssl/renew-check、
                     POST /ssl/selfcheck(环境自检,只读不签发)
- index.ts + cron    每日 04:10 自动续期检查;cpu_ms 提到 60s

与 certimate 的差异:certimate 每个 workflow 每天无条件重跑,
这里改为先探针查剩余天数、低于阈值才签,省 CA 限速额度。

实测修正(易误判,勿回退):
- 多吉云 bind 参数是 {id, domain},非 {cert_id}(用假 id 对照实验确认:
  cert_id 回「域名不存在」= 参数被无视)
- 多吉云上传私钥字段是 private;列域名用 /cdn/domain/list.json
- 1Panel 必须用 /api/v2/(v1 返回 HTTP 200 但正文是 HTML 停用页)
- 1Panel HTTPS 配置字段是 SSL(大写),写错会导致每次续期都重绑
- LiteSSL ACME 目录须带 /v2:acme.trustasia.com/acme/v2/directory

测试:selftest-acme 16/16(CSR 过 openssl 验签、JWS 过 Node crypto 验签)、
selftest-deploy 18/18、selftest:ssl 117/117、UI 全过、tsc 干净
2026-10-06 16:59:37 +08:00

611 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 部署适配层 —— 把签好的证书推到真正对外提供服务的地方。
*
* 目前两个目标(对应 `certstore.ts` 里的 `DEPLOY_TARGETS`):
* · dogecloud 多吉云 CDN —— 上传证书 + 绑到指定加速域名
* · 1panel 1Panel 面板 —— 上传证书 + 绑到指定网站(走 openresty)
*
* ★ 两家的 API 风格完全相反,各自的坑单独记在下面各自的类里。
*
* ★ 为什么部署要「幂等」(重复调用不出错):
* 续期失败一次就可能连着重试;更要紧的是——**每天都会跑一遍**,
* 如果每次都无脑新建证书,多吉云那边的证书列表会膨胀成几百条,
* 1Panel 那边会不断覆盖同名 SSL。所以这里的每个动作都先查后写。
*/
import type { AccessRecord } from './certstore';
// ==================================================================== 类型
export interface DeployCert {
/** 主域名(1Panel 用来给 SSL 起名字,多吉云用来做备注) */
domain: string;
/** 证书链 PEM(叶 + 中间,多吉云要求含完整链) */
cert: string;
/** 私钥 PEM */
key: string;
}
export interface DeployResult {
/** 目标标签,日志里用 */
target: string;
/** 这次实际做了什么(用于日志/UI 展示),如「绑定 usj.cc」 */
details: string[];
}
export interface Deployer {
readonly kind: string;
/** 把证书推到这个目标的所有配置对象上 */
deploy(cert: DeployCert, opts: DeployOptions): Promise<DeployResult>;
}
export interface DeployOptions {
/** 多吉云:要绑的加速域名列表(空则只上传不绑定) */
dogecloudDomains?: string[];
/** 1Panel:要绑的网站(域名或 id)列表(空则只上传不绑定) */
onePanelSites?: string[];
/** 追加日志 */
log?: (msg: string) => void;
}
// ==================================================================== 工具
const enc = (s: string) => new TextEncoder().encode(s);
// ==================================================================== 多吉云 CDN
/** hex 输出(多吉云签名用) */
function bufToHex(buf: ArrayBuffer | Uint8Array): string {
const b = buf instanceof Uint8Array ? buf : new Uint8Array(buf);
return [...b].map((x) => x.toString(16).padStart(2, '0')).join('');
}
/**
* 多吉云(api.dogecloud.com)。
*
* ★ 签名方式(老派但有性格):
* stringToSign = <path[+?query]> + "\n" + <body原始字符串>
* signature = HMAC-SHA1(secretKey, stringToSign) 的 **hex**
* Authorization: `TOKEN <accessKey>:<signature>`
* 注意:**不含时间戳**(所以要靠 HTTPS 防重放);HMAC 用的是 **SHA1** 不是 SHA256;
* 输出是 **hex** 不是 base64。这三点任一搞错都只会得到 `401 签名错误`。
*
* ★ body 必须**原样**参与签名:先序列化成字符串再一起发出去,
* 不能签名 JSON.stringify(a) 却发送 JSON.stringify(b)。
* 这里统一「先定 body 字符串 → 签名 → 发送同一个字符串」。
*
* ★ 端点与参数(2026-10-06 用真凭据逐个实测确认,别照抄网上的旧文档):
* POST /cdn/domain/list.json {} → { domains: [{id,name,cname,…}] }
* POST /cdn/cert/list.json {} → { certs: [{id,note,name,domains,…}] }
* POST /cdn/cert/upload.json { note, cert, private } → { id }
* POST /cdn/cert/bind.json { id, domain } → {}
* POST /cdn/cert/delete.json { id } → {}
* 几个容易写错的地方:
* · 列域名是 `/cdn/domain/**list**.json`;`/cdn/domain.json` 会回
* `400 domain 格式错误`(它其实是「查单个域名」的接口,要传 domain)。
* · 上传的私钥字段叫 **`private`**(不是 pri/key/privateKey —— 那三个都会回
* `400 私钥格式错误`)。
* · 绑定的证书 id 字段是 **`id`**(官方文档如此)。★ 已实测一锤定音:
* 用假 id 999999 试 `{cert_id,…}` 回「域名不存在」(参数被无视),
* 试 `{id,…}` 回「指定证书不存在」(参数生效走到查证书)—— 差别一目了然。
*
* ★ 幂等策略:上传前先列 cert 列表,若已存在「同一组域名 + 内容相同」的证书
* 就直接复用它的 id,不再上传。多吉云上传限速约 300 次/日,
* 每天续期检查跑 3 个域名,不做复用虽然也够,但证书列表会越堆越长。
*/
export class DogeCloudDeployer implements Deployer {
readonly kind = 'dogecloud';
private static readonly HOST = 'https://api.dogecloud.com';
constructor(
private readonly accessKey: string,
private readonly secretKey: string,
) {}
private async call<T>(path: string, body: Record<string, unknown> | null): Promise<T> {
// ★ body 字符串只算一次,签名和发送用同一个
const bodyStr = body === null ? '' : JSON.stringify(body);
const stringToSign = `${path}\n${bodyStr}`;
const key = await crypto.subtle.importKey('raw', enc(this.secretKey), { name: 'HMAC', hash: 'SHA-1' }, false, [
'sign',
]);
const sig = bufToHex(await crypto.subtle.sign('HMAC', key, enc(stringToSign)));
const r = await fetch(DogeCloudDeployer.HOST + path, {
method: 'POST',
headers: {
Authorization: `TOKEN ${this.accessKey}:${sig}`,
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: bodyStr || undefined,
});
const text = await r.text();
let d: { code?: number; msg?: string; data?: T };
try {
d = JSON.parse(text);
} catch {
throw new Error(`多吉云返回非 JSON(HTTP ${r.status}):${text.slice(0, 200)}`);
}
// code === 200 是成功;0 也有接口用(历史遗留),一并认
if (d.code !== 200 && d.code !== 0) {
throw new Error(`多吉云 ${path} 失败:code=${d.code} ${d.msg || ''}`);
}
return d.data as T;
}
async deploy(cert: DeployCert, opts: DeployOptions): Promise<DeployResult> {
const log = opts.log || (() => {});
const details: string[] = [];
// ① 先看有没有可复用的证书(同一组域名)—— 避免每天续期都堆一张新的
const certId = await this.uploadOrReuse(cert, opts.dogecloudDomains || [], log);
details.push(`证书 #${certId}`);
// ② 逐个域名绑定(★ 字段名是 cert_id,下划线)
const domains = (opts.dogecloudDomains || []).map((s) => s.trim()).filter(Boolean);
if (!domains.length) {
log('多吉云:没有配置要绑定的域名,只上传不绑定');
return { target: 'dogecloud', details };
}
for (const domain of domains) {
log(`多吉云:绑定 ${domain}…`);
await this.call('/cdn/cert/bind.json', { id: certId, domain });
details.push(`绑定 ${domain}`);
}
return { target: 'dogecloud', details };
}
/**
* 上传证书;如果已经有「覆盖同一组域名」的证书,直接复用它的 id。
*
* ★ 复用判据只看**域名集合**,不比对证书内容:
* 多吉云的 list 接口不返回 PEM 正文,比对不了内容;而我们的用途是
* 「让这些域名用上新证书」,同一组域名本来就该共用同一张证书。
*/
private async uploadOrReuse(cert: DeployCert, wantDomains: string[], log: (m: string) => void): Promise<number> {
const need = new Set(
(wantDomains.length ? wantDomains : [cert.domain]).map((s) => s.trim().toLowerCase()).filter(Boolean),
);
try {
const list = await this.call<{ certs?: { id: number; domains?: { name: string }[] }[] }>(
'/cdn/cert/list.json',
{},
);
const hit = (list?.certs || []).find((c) => {
const have = new Set((c.domains || []).map((d) => String(d.name).toLowerCase()));
if (have.size !== need.size) return false;
for (const d of need) if (!have.has(d)) return false;
return true;
});
if (hit?.id) {
log(`多吉云:已有覆盖 ${[...need].join(', ')} 的证书 #${hit.id},复用`);
return hit.id;
}
} catch (e) {
// 列举失败不阻断部署 —— 大不了多传一张,比整个部署失败好
log(`多吉云:列举已有证书失败(继续上传新的):${e instanceof Error ? e.message : e}`);
}
log('多吉云:上传证书…');
const up = await this.call<{ id?: number | string }>('/cdn/cert/upload.json', {
note: `${cert.domain} (${new Date().toISOString().slice(0, 10)})`,
cert: cert.cert,
// ★ 私钥字段名是 `private` —— 实测 pri/key/privateKey 都会回「私钥格式错误」
private: cert.key,
});
const id = up?.id;
if (id === undefined || id === null || id === '') {
throw new Error('多吉云上传成功但没返回证书 id(接口可能改了)');
}
return Number(id);
}
}
// ==================================================================== 1Panel
/**
* 1Panel(自建面板)。
*
* ★ 签名:`1Panel-Token = md5("1panel" + apiKey + timestamp)`(hex),
* 同时带 `1Panel-Timestamp`(unix 秒)。**没有别的材料**,
* 与多吉云那种「body 进签名」完全无关 —— 它就是防重放 + 持有密钥即通过。
*
* ★ 版本:国内机(119.29.215.187:3721)**必须用 v2**。
* 2026-10-06 逐条实测的结论(网上和早期笔记里的说法都不准,以实测为准):
* · `/api/v2/dashboard/base/os` → `code:200`,真实数据 ✓
* · `/api/v1/dashboard/base/os` → **HTTP 200 但正文是 HTML 提示页**
* (`Access Temporarily Unavailable`)—— 看起来像限流,其实是 v1 已停用,
* 面板把「路径不对」统一渲染成了那个页面。这一点极易误判成「被限流了」。
*
* ★ v2 的请求体要求和 v1 不同,实测踩过的点:
* · `POST /websites/search` 的 `orderBy` / `order` 是 **required**
* (漏了会回 `400 参数错误: Key: 'WebsiteSearch.OrderBy' … required`)。
* → 固定传 `{orderBy:'created_at', order:'descending'}`。
* · `/websites/list`、`GET /websites` 在 v2 下都是 404,别用。
* · `GET /websites/:id` 是 404(不是 `GET /websites?id=`)。
* · 站点里的 `ssl` 字段在列表里是空的 —— 要拿 https 配置得单独
* `GET /websites/:id/https`。
*
* ★ 部署流程(**必须**先读后写):
* ① `POST /websites/ssl/search` 找同名 SSL;有就 `POST /websites/ssl/update` 覆盖,
* 没有就 `POST /websites/ssl/upload` 新建 → 得到 SSL id
* ② `GET /websites/:id/https` 读现状,若 `enable && ssl.id === 目标` → **跳过**(幂等)
* ③ `POST /websites/:id/https` 写入(`type:'existed'` 引用已有 SSL)
*
* ★ 网站匹配:域名优先(人配的是域名,id 会变),拿不到再当 id 用。
*/
/** 1Panel 的搜索分页包装 */
interface PageResult<T> {
items?: T[];
total?: number;
}
/** 1Panel v2 的网站对象(只列我们关心的字段) */
interface WebSite {
id: number;
primaryDomain?: string;
/** 别名,多个用逗号分隔 */
alias?: string;
type?: string;
}
export class OnePanelDeployer implements Deployer {
readonly kind = '1panel';
constructor(
private readonly serverUrl: string,
private readonly apiKey: string,
// ★ 默认 v2 —— 实测国内机只有 v2 能用(v1 会返回一个假的「限流」HTML 页)
private readonly apiVersion: 'v1' | 'v2' = 'v2',
) {}
private get base(): string {
return `${this.serverUrl.replace(/\/+$/, '')}/api/${this.apiVersion}`;
}
private async call<T>(path: string, method: 'GET' | 'POST', body?: unknown): Promise<T> {
const timestamp = String(Math.floor(Date.now() / 1000));
const md5 = await md5Hex(`1panel${this.apiKey}${timestamp}`);
const r = await fetch(this.base + path, {
method,
headers: {
'1Panel-Token': md5,
'1Panel-Timestamp': timestamp,
...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
},
body: body !== undefined ? JSON.stringify(body) : undefined,
});
const text = await r.text();
let d: { code?: number; message?: string; data?: T };
try {
d = JSON.parse(text);
} catch {
// ★ 2026-10-06 实测:1Panel 在「不方便直接回错」时会**返回 HTTP 200
// 但内容是 HTML**。两种成因,处置完全不同,所以必须分开报:
// ① 开了「安全登录」→ 面板挪到随机入口路径,根路径只回提示页
// (正文含 `secure login access` / `1pctl user-info`)
// ② 短时间调用过密被限流 → 正文标题 `Access Temporarily Unavailable`
// 只按「非 JSON 就抛错」处理的话,两种都会被抓成一句看不懂的
// 「返回非 JSON」,排查成本极高。
if (/secure login access|1pctl user-info/i.test(text)) {
throw new Error(
`1Panel 启用了「安全登录」,面板不在根路径下(serverUrl 少了入口路径)。` +
`SSH 上机执行 \`1pctl user-info\` 拿到入口,再把 serverUrl 改成 ` +
`http://<ip>:<port>/<入口路径>`,
);
}
if (/Access Temporarily Unavailable|<!DOCTYPE/i.test(text)) {
throw new Error(
`1Panel 拒绝了本次请求(返回「Access Temporarily Unavailable」页面)。` +
`通常是短时间调用过于频繁触发限流 —— 等一会儿再试。`,
);
}
throw new Error(`1Panel 返回非 JSON(HTTP ${r.status},${r.headers.get('content-type') || '无 CT'}):${text.slice(0, 200)}`);
}
// 1Panel 统一信封:code === 200 才是成功
if (d.code !== 200) {
throw new Error(`1Panel ${path} 失败:code=${d.code} ${d.message || `HTTP ${r.status}`}`);
}
return d.data as T;
}
/**
* 连通性 + 可用性探测(只读)。给「环境自检」用。
*
* ★ 为什么要单独有个 ping 而不是直接 try 某个业务接口:
* `GET /dashboard/base/os` 是最轻的只读接口,用它做「面板是否可用」的
* 探针最合适 —— 业务接口(网站/证书列表)失败时你分不清是「签名错」
* 还是「没数据」,而这个接口必然有数据可回。
*/
async ping(): Promise<{ ok: boolean; error?: string; hint?: string }> {
try {
await this.call<unknown>('/dashboard/base/os', 'GET');
return { ok: true };
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
return {
ok: false,
error: msg,
hint: msg.includes('Access Temporarily Unavailable')
? '1Panel 启用了「安全登录」:面板被挪到了随机入口路径下,根路径只回提示页。' +
'需要 SSH 上机执行 `1pctl user-info` 拿到入口,再把 serverUrl 改成 ' +
'`http://<ip>:<port>/<入口路径>`'
: msg.includes('401')
? 'API Key 不对(1Panel → 设置 → API 接口 里重新生成)'
: undefined,
};
}
}
/** 域名 or 数字 id → 网站对象 */
async findWebsite(key: string): Promise<{ id: number; primaryDomain?: string; alias?: string }> {
// ★ v2 下 `/websites/:id` 是 404,所以「按 id 找」也得走 search:
// 拉一页(orderBy/order 必填)再在前端按 id 过滤。
// 顺带这一步就拿到了全量网站,后面按域名找也不用再请求一次。
const all = await this.listWebsites();
const key_l = key.toLowerCase();
const match = (w: WebSite) => {
if (String(w.id) === key) return true;
const names = [w.primaryDomain || '', ...(w.alias || '').split(',')].map((s) => s.trim().toLowerCase());
return names.includes(key_l);
};
const hit = all.find(match);
if (hit) return hit;
throw new Error(
`1Panel 里找不到网站「${key}」(可用主域名或网站别名匹配;目前面板上有 ${all.length} 个网站)`,
);
}
/** 拉全量网站列表(v2 的 search 按 name 过滤不可靠,统一拉回来自己筛) */
private async listWebsites(): Promise<WebSite[]> {
const page = await this.call<PageResult<WebSite>>('/websites/search', 'POST', {
page: 1,
pageSize: 200,
// ★ 这两个字段 v2 是 required,漏了直接 400
orderBy: 'created_at',
order: 'descending',
});
return page?.items || [];
}
/** 上传证书;同名同内容的已有 SSL 直接复用,避免面板里堆一堆 */
private async uploadSsl(cert: DeployCert, log: (m: string) => void): Promise<number> {
// ① 查同名(v2 的 ssl/search 同样需要 orderBy/order)
const page = await this.call<PageResult<{ id: number; primaryDomain?: string }>>('/websites/ssl/search', 'POST', {
page: 1,
pageSize: 100,
orderBy: 'created_at',
order: 'descending',
});
const existing = (page?.items || []).find((s) => s.primaryDomain === cert.domain);
if (existing?.id) {
// ② 同名存在 → 用 update 覆盖内容(保持 id 不变,网站那边的引用就不会断)
log(`1Panel:更新已有 SSL #${existing.id}(${cert.domain})`);
await this.call('/websites/ssl/update', 'POST', {
id: existing.id,
type: 'paste',
certificate: cert.cert,
privateKey: cert.key,
});
return existing.id;
}
log('1Panel:上传新证书…');
const up = await this.call<{ id?: number } | number>('/websites/ssl/upload', 'POST', {
type: 'paste',
certificate: cert.cert,
privateKey: cert.key,
});
const id = typeof up === 'number' ? up : up?.id;
if (!id) throw new Error('1Panel 上传成功但没拿到 SSL id');
return id;
}
async deploy(cert: DeployCert, opts: DeployOptions): Promise<DeployResult> {
const log = opts.log || (() => {});
const details: string[] = [];
const sslId = await this.uploadSsl(cert, log);
details.push(`证书 SSL #${sslId}`);
const sites = (opts.onePanelSites || []).map((s) => s.trim()).filter(Boolean);
if (!sites.length) {
log('1Panel:没有配置要绑定的网站,只上传不绑定');
return { target: '1panel', details };
}
for (const key of sites) {
const site = await this.findWebsite(key);
// ★ 先读现状 —— 幂等的关键。已经在用同一张证书就什么都别做。
//
// ★★ 字段名是 **`SSL`(全大写)**,不是 `ssl`。写成小写会让
// `cur.ssl?.id === sslId` 永远为 false → 每次续期都重绑一遍。
// 危害不止是多余请求:重绑会 brief 地重载该站点的 nginx 配置。
//
// ★ 这个 GET 的响应里**带明文私钥**(`data.SSL.privateKey`)——
// 绝不能把它写进日志、日志记录或 HTTP 响应。这里只取需要的几个
// 标量字段,然后让整个对象尽快离开作用域。
const resp = await this.call<{
enable?: boolean;
SSL?: { id?: number; primaryDomain?: string };
httpConfig?: string;
SSLProtocol?: string[];
algorithm?: string;
hsts?: boolean;
}>(`/websites/${site.id}/https`, 'GET');
const cur = {
enable: resp?.enable,
sslId: resp?.SSL?.id,
httpConfig: resp?.httpConfig,
SSLProtocol: resp?.SSLProtocol,
algorithm: resp?.algorithm,
hsts: resp?.hsts,
};
if (cur.enable && cur.sslId === sslId) {
log(`1Panel:网站 ${key} 已经在用这张证书,跳过`);
details.push(`跳过 ${key}(已生效)`);
continue;
}
// ★ 保留原有配置:HTTP→HTTPS 跳转、协议版本、算法、HSTS —— 只换证书
const body: Record<string, unknown> = {
websiteId: site.id,
type: 'existed',
sslId,
enable: true,
httpConfig: cur.httpConfig || 'HTTPToHTTPS',
SSLProtocol: cur.SSLProtocol?.length ? cur.SSLProtocol : ['TLSv1.2', 'TLSv1.3'],
algorithm: cur.algorithm || 'RSA',
hsts: cur.hsts ?? false,
};
log(`1Panel:给网站 ${key}(#${site.id})绑定证书…`);
await this.call(`/websites/${site.id}/https`, 'POST', body);
details.push(`绑定 ${key}`);
}
return { target: '1panel', details };
}
/**
* 读某个网站当前的 SSL 绑定情况(只回标量,**绝不回私钥**)。
* 给「环境自检」用 —— 让管理员能在续期之前就看出「站点绑的是不是我们要的那张」。
*/
async inspectSite(
key: string,
): Promise<{ id: number; primaryDomain: string; enable: boolean; sslId?: number; certCN?: string }> {
const site = await this.findWebsite(key);
const resp = await this.call<{ enable?: boolean; SSL?: { id?: number; primaryDomain?: string } }>(
`/websites/${site.id}/https`,
'GET',
);
return {
id: site.id,
primaryDomain: site.primaryDomain || '',
enable: !!resp?.enable,
sslId: resp?.SSL?.id,
certCN: resp?.SSL?.primaryDomain,
};
}
}
// ==================================================================== 工厂
/**
* 按凭据记录造部署器。
* ★ 只认 dogecloud / 1panel;其余抛错而非静默 —— 理由同 makeDnsProvider。
*/
export function makeDeployer(rec: AccessRecord): Deployer {
const t = String(rec.type || '');
if (t === 'dogecloud') {
const ak = String(rec.accessKey || '');
const sk = String(rec.secretKey || '');
if (!ak || !sk) throw new Error('多吉云凭据缺少 accessKey / secretKey');
return new DogeCloudDeployer(ak, sk);
}
if (t === '1panel') {
const url = String(rec.serverUrl || '');
const key = String(rec.apiKey || '');
if (!url || !key) throw new Error('1Panel 凭据缺少 serverUrl / apiKey');
const ver = String(rec.apiVersion || 'v2') === 'v1' ? 'v1' : 'v2';
return new OnePanelDeployer(url, key, ver);
}
throw new Error(`部署目标不支持凭据类型「${t}」(目前只支持 dogecloud / 1panel)`);
}
// ==================================================================== md5
/**
* 1Panel 要的 md5(hex)。
*
* ★ 用 `crypto.subtle` 没有 MD5(它是过时算法,WebCrypto 故意不提供),
* 所以自己写一份。这里只需要处理 ASCII("1panel" + apiKey + 时间戳),
* 但为了将来可能复用它算别的,还是按 UTF-8 字节做了正确处理。
* 实现照 RFC 1321;输出小写 hex。
*/
export function md5Hex(input: string): Promise<string> {
return Promise.resolve(md5(enc(input)));
}
function md5(bytes: Uint8Array): string {
const S = [
7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14,
20, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21, 6,
10, 15, 21,
];
const K = new Uint32Array(64);
for (let i = 0; i < 64; i++) K[i] = Math.floor(Math.abs(Math.sin(i + 1)) * 4294967296) >>> 0;
// 补位:0x80 + 0x00... 到 56 mod 64,再附 64 位长度(小端)
const len = bytes.length;
const withPad = new Uint8Array((((len + 8) >> 6) + 1) << 6);
withPad.set(bytes);
withPad[len] = 0x80;
const bitLen = len * 8;
// 低 32 位 + 高 32 位(JS 里用除法拆,避免 >> 32 的坑)
const lo = bitLen >>> 0;
const hi = Math.floor(bitLen / 4294967296) >>> 0;
const dv = new DataView(withPad.buffer);
dv.setUint32(withPad.length - 8, lo, true);
dv.setUint32(withPad.length - 4, hi, true);
let a0 = 0x67452301;
let b0 = 0xefcdab89;
let c0 = 0x98badcfe;
let d0 = 0x10325476;
const rotl = (x: number, c: number) => ((x << c) | (x >>> (32 - c))) >>> 0;
for (let off = 0; off < withPad.length; off += 64) {
const M = new Uint32Array(16);
for (let i = 0; i < 16; i++) M[i] = dv.getUint32(off + i * 4, true);
let A = a0;
let B = b0;
let C = c0;
let D = d0;
for (let i = 0; i < 64; i++) {
let F: number;
let g: number;
if (i < 16) {
F = (B & C) | (~B & D);
g = i;
} else if (i < 32) {
F = (D & B) | (~D & C);
g = (5 * i + 1) % 16;
} else if (i < 48) {
F = B ^ C ^ D;
g = (3 * i + 5) % 16;
} else {
F = C ^ (B | ~D);
g = (7 * i) % 16;
}
F = (F + A + K[i] + M[g]) >>> 0;
A = D;
D = C;
C = B;
B = (B + rotl(F, S[i])) >>> 0;
}
a0 = (a0 + A) >>> 0;
b0 = (b0 + B) >>> 0;
c0 = (c0 + C) >>> 0;
d0 = (d0 + D) >>> 0;
}
return [a0, b0, c0, d0].map((x) => {
// 每个字按小端输出
const b = new Uint8Array(4);
new DataView(b.buffer).setUint32(0, x, true);
return [...b].map((v) => v.toString(16).padStart(2, '0')).join('');
}).join('');
}