From 48c7854018856c5f15b4fea53ea74358c9c4f8cf Mon Sep 17 00:00:00 2001 From: Hufe921 Date: Sun, 18 Oct 2026 10:44:40 +0800 Subject: [PATCH] feat: add OFD reading, SES V4 signing, and password encryption --- .gitignore | 1 + README.md | 31 +- docs/.vitepress/config.ts | 3 + docs/guide/advanced.md | 30 + docs/guide/differences.md | 2 +- docs/guide/encryption.md | 48 ++ docs/guide/ofd-reader.md | 55 ++ docs/guide/signatures.md | 116 ++++ .../2026-10-17-ofd-ses-signature-design.md | 160 +++++ docs/plans/2026-10-17-ofd-ses-signature.md | 568 ++++++++++++++++++ packages/core/package.json | 4 + packages/core/src/builder.ts | 14 +- packages/core/src/crypto/base64.ts | 34 ++ packages/core/src/crypto/der.ts | 102 ++++ packages/core/src/crypto/ofd-encryption.ts | 175 ++++++ packages/core/src/crypto/ofd-package.ts | 79 +++ packages/core/src/crypto/ofd-seal.ts | 117 ++++ packages/core/src/crypto/ofd-sign.ts | 215 +++++++ packages/core/src/crypto/ofd-signature-xml.ts | 82 +++ packages/core/src/crypto/ofd-verify.ts | 200 ++++++ packages/core/src/crypto/revocation.ts | 9 + packages/core/src/crypto/ses-v4.ts | 298 +++++++++ packages/core/src/crypto/signer.ts | 59 ++ packages/core/src/crypto/sm-crypto.d.ts | 17 + packages/core/src/crypto/sm2.ts | 252 ++++++++ packages/core/src/crypto/sm3.ts | 148 +++++ packages/core/src/crypto/sm4.ts | 35 ++ packages/core/src/crypto/x509-sm2.ts | 355 +++++++++++ packages/core/src/index.ts | 67 +++ packages/core/src/jsofd.ts | 4 + packages/core/src/modules/redact.ts | 155 +++++ packages/core/src/modules/watermark.ts | 192 ++++++ packages/core/src/ofd-open.ts | 232 +++++++ packages/core/src/ofd-reader.ts | 525 ++++++++++++++++ packages/core/src/ofd-svg.ts | 236 ++++++++ packages/core/src/xml-parse.ts | 193 ++++++ packages/core/src/zip-read.ts | 146 +++++ packages/core/tests/crypto-boundary.test.ts | 79 +++ packages/core/tests/crypto.test.ts | 131 ++++ packages/core/tests/der.test.ts | 56 ++ packages/core/tests/fixtures/ses-v4.ts | 9 + packages/core/tests/helpers/ses-material.ts | 56 ++ packages/core/tests/helpers/test-ca.ts | 139 +++++ .../core/tests/manual/ses-interop.test.ts | 100 +++ packages/core/tests/ofd-encryption.test.ts | 73 +++ packages/core/tests/ofd-package.test.ts | 113 ++++ packages/core/tests/ofd-reader.test.ts | 238 ++++++++ packages/core/tests/ofd-seal.test.ts | 101 ++++ packages/core/tests/package.test.ts | 10 + packages/core/tests/ses-v4.test.ts | 50 ++ packages/core/tests/sign-multi.test.ts | 52 ++ packages/core/tests/sign.test.ts | 78 +++ packages/core/tests/signer.test.ts | 35 ++ packages/core/tests/sm4.test.ts | 28 + packages/core/tests/verify-attacks.test.ts | 67 +++ packages/core/tests/verify.test.ts | 72 +++ packages/core/tests/watermark-redact.test.ts | 125 ++++ packages/core/tests/x509-sm2.test.ts | 182 ++++++ packages/core/tsup.config.ts | 2 +- packages/playground/index.html | 51 +- packages/playground/src/app.ts | 220 ++++++- .../playground/tests/signature-demo.test.ts | 26 + packages/playground/tsconfig.json | 2 +- pnpm-lock.yaml | 47 ++ 64 files changed, 7081 insertions(+), 20 deletions(-) create mode 100644 docs/guide/encryption.md create mode 100644 docs/guide/ofd-reader.md create mode 100644 docs/guide/signatures.md create mode 100644 docs/plans/2026-10-17-ofd-ses-signature-design.md create mode 100644 docs/plans/2026-10-17-ofd-ses-signature.md create mode 100644 packages/core/src/crypto/base64.ts create mode 100644 packages/core/src/crypto/der.ts create mode 100644 packages/core/src/crypto/ofd-encryption.ts create mode 100644 packages/core/src/crypto/ofd-package.ts create mode 100644 packages/core/src/crypto/ofd-seal.ts create mode 100644 packages/core/src/crypto/ofd-sign.ts create mode 100644 packages/core/src/crypto/ofd-signature-xml.ts create mode 100644 packages/core/src/crypto/ofd-verify.ts create mode 100644 packages/core/src/crypto/revocation.ts create mode 100644 packages/core/src/crypto/ses-v4.ts create mode 100644 packages/core/src/crypto/signer.ts create mode 100644 packages/core/src/crypto/sm-crypto.d.ts create mode 100644 packages/core/src/crypto/sm2.ts create mode 100644 packages/core/src/crypto/sm3.ts create mode 100644 packages/core/src/crypto/sm4.ts create mode 100644 packages/core/src/crypto/x509-sm2.ts create mode 100644 packages/core/src/modules/redact.ts create mode 100644 packages/core/src/modules/watermark.ts create mode 100644 packages/core/src/ofd-open.ts create mode 100644 packages/core/src/ofd-reader.ts create mode 100644 packages/core/src/ofd-svg.ts create mode 100644 packages/core/src/xml-parse.ts create mode 100644 packages/core/src/zip-read.ts create mode 100644 packages/core/tests/crypto-boundary.test.ts create mode 100644 packages/core/tests/crypto.test.ts create mode 100644 packages/core/tests/der.test.ts create mode 100644 packages/core/tests/fixtures/ses-v4.ts create mode 100644 packages/core/tests/helpers/ses-material.ts create mode 100644 packages/core/tests/helpers/test-ca.ts create mode 100644 packages/core/tests/manual/ses-interop.test.ts create mode 100644 packages/core/tests/ofd-encryption.test.ts create mode 100644 packages/core/tests/ofd-package.test.ts create mode 100644 packages/core/tests/ofd-reader.test.ts create mode 100644 packages/core/tests/ofd-seal.test.ts create mode 100644 packages/core/tests/ses-v4.test.ts create mode 100644 packages/core/tests/sign-multi.test.ts create mode 100644 packages/core/tests/sign.test.ts create mode 100644 packages/core/tests/signer.test.ts create mode 100644 packages/core/tests/sm4.test.ts create mode 100644 packages/core/tests/verify-attacks.test.ts create mode 100644 packages/core/tests/verify.test.ts create mode 100644 packages/core/tests/watermark-redact.test.ts create mode 100644 packages/core/tests/x509-sm2.test.ts create mode 100644 packages/playground/tests/signature-demo.test.ts diff --git a/.gitignore b/.gitignore index 7d414b6..390d7ee 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ node_modules/ +package-lock.json dist/ packages/*/dist/ coverage/ diff --git a/README.md b/README.md index 098e3f5..d53dcc4 100644 --- a/README.md +++ b/README.md @@ -37,15 +37,17 @@ pnpm add @hufe921/jsofd pdfjs-dist # + PDF → OFD 转换 ## 核心能力 -| 类别 | 说明 | -| -------- | ---------------------------------------------------------------------- | -| 文本 | 字体/字号/颜色、对齐(含两端)、自动断行(中英混排)、基线、旋转、字距 | -| 中文字体 | 内置宋/黑/楷/仿宋 + **TTF 嵌入**(任何阅读器渲染一致) | -| 图形 | 直线、贝塞尔、矩形、圆角矩形、圆、椭圆、三角形、虚线、透明度 | -| 图像 | PNG/JPEG/GIF/BMP/TIFF 原样嵌入、旋转、去重 | -| 交互 | 超链接、层级书签、附件 | -| PDF 转换 | 逐条重放绘制指令流,保留真实排版数据 | -| 输出 | 全同步:`arraybuffer` · `blob` · `dataurlstring` 等 | +| 类别 | 说明 | +| -------- | ------------------------------------------------------------------------------------- | +| 文本 | 字体/字号/颜色、对齐(含两端)、自动断行(中英混排)、基线、旋转、字距 | +| 中文字体 | 内置宋/黑/楷/仿宋 + **TTF 嵌入**(任何阅读器渲染一致) | +| 图形 | 直线、贝塞尔、矩形、圆角矩形、圆、椭圆、三角形、虚线、透明度 | +| 图像 | PNG/JPEG/GIF/BMP/TIFF 原样嵌入、旋转、去重 | +| 交互 | 超链接、层级书签、附件 | +| 安全 | 水印、脱敏;SES 制章/签章与可信验签;GM/T 0099 口令加密(SM4-CBC) | +| PDF 转换 | 逐条重放绘制指令流,保留真实排版数据 | +| OFD 读取 | 解析 OFD 包:页面/文本/图形/图片/书签/附件/元数据,导出 SVG/PNG、全文提取、可编辑回写 | +| 输出 | 全同步:`arraybuffer` · `blob` · `dataurlstring` 等 | ## 嵌入字体(中文推荐) @@ -65,6 +67,17 @@ const doc = await pdfToOfd(pdfBytes, { workerSrc: '/pdf.worker.min.mjs' }); doc.save('converted.ofd'); ``` +## 读取 OFD + +```ts +import { parseOfd, extractText, pageToSvg } from '@hufe921/jsofd'; + +const ofd = await parseOfd(ofdBytes); // 解析 GB/T 33190 包 +extractText(ofd); // 每页文本 +pageToSvg(ofd.pages[0], { zoom: 3 }); // 渲染为可选中文字的 SVG +ofd.outlines; // 书签树 / 附件 / 签名清单 / DocType +``` + ## 开发 ```sh diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 711a5b1..df43907 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -34,6 +34,9 @@ export default defineConfig({ { text: '图像嵌入', link: '/guide/images' }, { text: '嵌入字体', link: '/guide/fonts' }, { text: 'PDF → OFD 转换', link: '/guide/pdf-import' }, + { text: '读取 OFD 文件', link: '/guide/ofd-reader' }, + { text: 'SES 电子签章', link: '/guide/signatures' }, + { text: 'OFD 口令加密', link: '/guide/encryption' }, { text: '高级功能', link: '/guide/advanced' }, { text: '与 jsPDF 的差异', link: '/guide/differences' }, ], diff --git a/docs/guide/advanced.md b/docs/guide/advanced.md index 866162d..fdafbcb 100644 --- a/docs/guide/advanced.md +++ b/docs/guide/advanced.md @@ -159,3 +159,33 @@ doc.autoPaging(chapters.join('\n\n'), { x: 20, topMargin: 28, bottomMargin: 28 } const endY = doc.table(20, doc.autoPaging 返回值 + 10, { columns, rows }); doc.headerFooter({ footer: (d, i) => d.text(`${i.pageNumber} / ${i.pageCount}`, 105, 288, { align: 'center' }) }); ``` + +## 水印 + +文字或图片水印,支持单枚居中与平铺两种模式,可放在内容上方或下方: + +```ts +// 文字水印:默认全部页面、45° 斜排、半透明 +doc.addWatermark({ text: '绝密', opacity: 0.12 }); + +// 平铺 + 仅当前页 + 置于内容下方 +doc.addWatermark({ text: '草稿', mode: 'tile', fontSize: 24, layer: 'under' }); + +// 图片水印(PNG 透明底最佳),只盖第 2 页 +doc.addWatermark({ image: sealPng, imageWidth: 120, pages: [2] }); +``` + +## 关键字脱敏 + +在已排版的文本中定位关键字并遮盖,**字形推进不变、版面不错位**: + +```ts +// 遮盖模式:黑色矩形盖住关键字,文本替换为空格(提取/复制不再泄漏) +const hits = doc.redact(['王小明', '13800138000']); +// hits → [{ keyword: '王小明', page: 1, count: 1 }, ...] + +// 星号模式:关键字替换为 *,其余位置不动 +doc.redact('绝密', { mode: 'asterisk', pages: 'current' }); +``` + +`hits` 里 `page` 为 1 起始页码(0 表示未命中),`count` 为该页命中次数。 diff --git a/docs/guide/differences.md b/docs/guide/differences.md index 6f4920f..9b95f4c 100644 --- a/docs/guide/differences.md +++ b/docs/guide/differences.md @@ -49,7 +49,7 @@ jsOFD 的目标是**让 jsPDF 代码零成本迁移到 OFD**:方法名、参 | -------------------------------------------- | ----------------------------------------------------------------------------------------- | | `doc.addFont()` 的 postScriptName 三段式重载 | 保留 jsPDF 的 `(postScriptName, fontName, fontStyle)` 签名,同时推荐更明确的 `addFontTtf` | | `doc.setLanguage` 写入 /Lang | OFD 的语言标记挂在附件与元数据上,仅保留方法兼容、无渲染影响 | -| 加密 / 权限(`encryption` 选项) | OFD 加密是独立的标准分册(GB/T 33190.7),涉及证书体系,不适合轻量库内置 | +| 加密 / 权限(`encryption` 选项) | 不兼容 jsPDF 的选项式加密;使用独立的 `encryptOfdPassword` / `decryptOfdPassword`(GM/T 0099 口令方案) | | `html()` 的 CSS 布局引擎 | 完整 CSS 排版是一个浏览器级工程;`html()` 定位为语义标签渲染器,需要精确版式请用原生 API | ## 行为差异细节 diff --git a/docs/guide/encryption.md b/docs/guide/encryption.md new file mode 100644 index 0000000..5ab1cd6 --- /dev/null +++ b/docs/guide/encryption.md @@ -0,0 +1,48 @@ +# OFD 口令加密与解密 + +jsOFD 按 GM/T 0099-2020 附录 C 实现口令加密:包内文件使用随机 16 字节 FEK 和 IV 做 **SM4-CBC/PKCS#7** 加密,口令经 **SM3-KDF** 派生密钥包装 FEK。加密与电子签章是不同能力:加密后没有正确口令无法还原正文。 + +## 加密 + +```ts +import { encryptOfdPassword } from '@hufe921/jsofd'; + +const encrypted = await encryptOfdPassword(ofdBytes, { + userName: 'demo', + password: '12345678', +}); +``` + +默认加密全部包内文件并删除对应明文。也可以按路径局部加密: + +```ts +const encrypted = await encryptOfdPassword(ofdBytes, { + userName: 'demo', + password: '12345678', + filter: (name) => name.startsWith('Doc_0/Pages/Page_0/'), +}); +``` + +局部加密应把页面内容、注释、模板及其引用资源一起纳入,否则解密端可能无法正确渲染。 + +生成物包含: + +- `Encryptions.xml`:解密入口、加密范围和密钥文件位置; +- `decryptseed.dat`:口令方案 `1.1.1`、用户信息、包装后的 FEK 和 IV; +- `entriesmap.dat`:加密后的明密文路径映射; +- 各包内密文 `.dat` 文件。 + +## 解密 + +```ts +import { decryptOfdPassword } from '@hufe921/jsofd'; + +const plainBytes = await decryptOfdPassword(encrypted, { + userName: 'demo', + password: '12345678', +}); +``` + +解密会还原映射中的明文路径和内容。口令错误、密文被篡改或 PKCS#7 填充损坏时会抛出异常,不会返回部分明文。 + +当前范围是单次口令加密操作;证书加密(方案 `1.1.2`)和多重加密不在此 API 内。生产系统应通过安全通道传递口令,不要在代码、日志或分享链接中硬编码真实口令。 diff --git a/docs/guide/ofd-reader.md b/docs/guide/ofd-reader.md new file mode 100644 index 0000000..4d98fed --- /dev/null +++ b/docs/guide/ofd-reader.md @@ -0,0 +1,55 @@ +# 读取 OFD 文件 + +jsOFD 不仅能生成 OFD,也能**读取**符合 GB/T 33190-2016 的 OFD 包,解析为对象模型后做提取、渲染或二次加工。 + +```ts +import { parseOfd, extractText, pageToSvg } from '@hufe921/jsofd'; + +const ofd = await parseOfd(fileBytes); // Uint8Array | ArrayBuffer + +ofd.docType; // "OFD" | "OFD-H" +ofd.meta; // { docId, title, author, subject, ... } +ofd.pages.length; // 页数 +ofd.pages[0].width; // 页宽(mm) +ofd.outlines; // 书签树(含目标页码) +ofd.attachments; // 附件(文件名字节) +ofd.signatures; // 签名文件清单 +ofd.fonts; // 嵌入字体(名字 + 字节) +ofd.limitations; // 未支持特性的清单 +``` + +## 页面对象 + +每页的 `objects` 是按绘制顺序排列的对象数组: + +```ts +for (const o of ofd.pages[0].objects) { + if (o.kind === 'text') o.codes; // TextCode:文本 + 每字符偏移 + if (o.kind === 'path') o.ops; // M / L / C(三次) / Q(二次) / Z + if (o.kind === 'image') o.data; // 图片字节(PNG/JPEG/GIF/BMP/TIFF) +} +``` + +所有坐标单位为 **毫米**,与 OFD 原生单位一致;颜色统一转成 RGB(灰度/CMYK 自动换算),`alpha` 为 0–1。 + +## 文本提取 + +```ts +const perPage = extractText(ofd); // string[],每页一段 +``` + +## 渲染为 SVG + +```ts +const svg = pageToSvg(ofd.pages[0], { zoom: 3 }); +document.querySelector('#preview').innerHTML = svg; +``` + +- 文字保持为可选中的真实 ``(逐字 tspan 按 DeltaX 定位); +- 图片内嵌为 data URL; +- `zoom` 控制输出像素尺寸,`includeText: false` 可隐藏文字层(双层预览场景)。 + +## 当前限制 + +解析器聚焦生成侧往返与主流阅读场景,以下特性会跳过并记录在 `limitations` 中: +复合对象(CompositeObject)嵌套、裁剪区(Clip)、页面注解外观(StampAnnot)、GoTo 之外的动作。`parseOfd` 只列出签名文件清单;SES 制章、签章与可信验签见[电子签章与可信验签](./signatures.md)。 diff --git a/docs/guide/signatures.md b/docs/guide/signatures.md new file mode 100644 index 0000000..d8ed948 --- /dev/null +++ b/docs/guide/signatures.md @@ -0,0 +1,116 @@ +# SES 电子签章与可信验签 + +jsOFD 支持 GB/T 38540-2020 SES V4 电子印章、OFD 多次签章、可见印章外观,以及基于显式信任锚的验签。电子签章用于证明身份和完整性,**不会加密正文**。 + +## 生产密钥接口 + +生产私钥不应进入浏览器或 jsOFD。实现 `Sm2Signer`,把准确的 TBS DER 字节交给 UKey、KMS、密码机或服务端: + +```ts +import type { Sm2Signer } from '@hufe921/jsofd'; + +const signer: Sm2Signer = { + async sign({ tbs, certificate, userId, signal }) { + return signingService.signSm2Der({ tbs, certificate, userId, signal }); + }, +}; +``` + +`createLocalTestSm2Signer(privateKey)` 只用于本地测试,不应用于生产密钥。 + +## 生成电子印章 + +制章和签章是两级独立签名。下面用测试签名器演示接口形状: + +```ts +import { createSesSeal, createLocalTestSm2Signer } from '@hufe921/jsofd'; + +const eSeal = await createSesSeal({ + info: { + header: { id: 'ES', version: 4, vid: 'JSOFD' }, + esId: 'SEAL-001', + property: { + type: 1, + name: '测试印章', + certList: [makerCertificate], + createDate: new Date('2026-01-01T00:00:00Z'), + validStart: new Date('2026-01-01T00:00:00Z'), + validEnd: new Date('2027-01-01T00:00:00Z'), + }, + picture: { type: 'PNG', data: sealPng, width: 40, height: 40 }, + }, + makerCertificate, + signer: makerSigner, +}); +``` + +已有合规制章系统时,也可以直接传入其生成的标准 `SESeal` DER,无需重新制章。 + +## 签章 + +```ts +import { signOfd } from '@hufe921/jsofd'; + +const signedBytes = await signOfd(ofdBytes, { + eSeal, + signingCertificate, + signer, + stamp: { page: 1, x: 120, y: 240, w: 40, h: 40 }, +}); +``` + +生成的包包含 `Signatures.xml`、独立的 `Sign_N/Signature.xml`、`Seal.esl` 和 `SignedValue.dat`。印章图片取自 SES 电子印章,不是单独伪造的 PNG 外观。 + +默认允许继续签章,`Signatures.xml` 不进入本次保护列表,旧签章文件会保留。若 `allowAdditionalSignatures: false`,签名列表也纳入保护,之后不能再追加签章。 + +## 可信验签 + +验签必须显式提供受信根证书;包内公钥和证书本身不构成信任依据。 + +```ts +import { verifyOfd } from '@hufe921/jsofd'; + +const report = await verifyOfd(signedBytes, { + trustAnchors: [rootCaDer], + intermediateCertificates: [intermediateCaDer], + revocationProvider: { + async check(certificate, issuer, at) { + return revocationService.status(certificate, issuer, at); // good | revoked | unknown + }, + }, + requireRevocation: true, +}); + +for (const signature of report.signatures) { + console.log(signature.status); // valid | invalid | untrusted | indeterminate + console.log(signature.referencesValid, signature.signatureValid); + console.log(signature.signerTrust.status, signature.makerTrust.status); +} +``` + +结果按每个签章分别返回: + +- `referencesValid`:受保护包内文件的 SM3 是否全部匹配; +- `signatureValid`:`Signature.xml` 摘要绑定和签章者 SM2 签名是否正确; +- `sealSignatureValid`:制章者签名及 `Seal.esl` 绑定是否正确; +- `signerTrust` / `makerTrust`:两条证书链的信任结果; +- `status`:按当前信任和吊销策略得出的综合状态。 + +数学签名正确但证书不受信时,结果为 `untrusted`,不能当作有效签章。缺少策略要求的吊销证据时为 `indeterminate`,不会静默放行。 + +## 数科与福昕联调 + +仓库提供 `packages/core/tests/manual/ses-interop.test.ts`。在仓库外的安全目录放入: + +- 共享或分别命名的 `root.der`、`intermediate.der`;也可使用 `maker-root.der`、`signer-root.der` 和对应 intermediate; +- `maker.der`、`signer.der`; +- 仅用于联调的 `maker-key.hex`、`signer-key.hex`(32 字节 SM2 私钥 hex); +- `seal.png`。 + +然后运行: + +```sh +JSOFD_SES_TEST_DIR=/secure/local/path pnpm --filter @hufe921/jsofd exec vitest run tests/manual/ses-interop.test.ts +``` + +脚本先在本地验证单次签章、两次签章和篡改样本,再把 `single.ofd`、`multiple.ofd`、`tampered.ofd` 写到系统临时目录,供数科和福昕检查。真实证书、私钥和生成物不要提交仓库。 diff --git a/docs/plans/2026-10-17-ofd-ses-signature-design.md b/docs/plans/2026-10-17-ofd-ses-signature-design.md new file mode 100644 index 0000000..4fb31c3 --- /dev/null +++ b/docs/plans/2026-10-17-ofd-ses-signature-design.md @@ -0,0 +1,160 @@ +# OFD SES 电子签章修复设计 + +日期:2026-10-17 + +## 目标 + +将当前实验性的自签自验实现重写为可用于生产集成的 OFD SES 电子签章功能,目标标准: + +- GB/T 33190-2016 第 18 章:OFD 签名列表、签名描述、保护范围与签名外观。 +- GM/T 0099-2020:OFD 密码应用流程。 +- GB/T 38540-2020:SES V4 电子印章与电子签章数据结构。 +- GB/T 20518、GB/T 32918、GB/T 32905:X.509、SM2 与 SM3。 + +不包含 OFD 内容加密/解密;那是独立需求。 + +## 验收目标 + +1. 新签章在数科和福昕 OFD 阅读器中显示可见印章,并通过结构、签名和证书检查。 +2. 使用真实制章者、签章者两套 SM2 证书链完成联调;私钥不进入仓库。 +3. 支持保留已有签章的多次签章,旧签章不因新增签章而失效。 +4. 验签区分文件完整性、SES 两级签名、证书链、信任与吊销状态,不信任包内自报公钥。 +5. 全部单元测试、类型检查、lint 和构建通过。 + +## 架构 + +### `crypto/ses-v4.ts` + +使用 `asn1js` 编解码 SES V4 DER 结构,包括 `SESeal`、`SES_Signature`、`TBS_Sign`、印章信息和图片信息。严格检查标签、长度、必需字段、DER 编码和输入大小,不再使用线性字节扫描。 + +支持两种输入: + +- 已有标准 `SESeal` DER; +- 制章材料与外部制章签名接口,由库生成 `SESeal`。 + +### `crypto/x509-sm2.ts` + +解析标准 X.509 证书并执行: + +- SM2 证书签名验证; +- 有效期、Basic Constraints、Key Usage 检查; +- 未知关键扩展拒绝; +- 信任链构建与受信根匹配; +- CRL/OCSP 通过 `RevocationProvider` 接口接入。 + +信任锚必须由调用方提供。证书链和吊销检查结果是独立状态,不以数学验签通过代替身份可信。 + +### `crypto/signer.ts` + +外部签名接口接收准确的 TBS DER 字节,返回 SM2 DER 签名值。生产私钥由 UKey、KMS、密码机或服务端管理,jsOFD 不读取生产私钥。 + +本地 SM2 签名器只用于测试和开发,并修复私钥、随机数和 nonce 边界检查。证书序列号不再从私钥派生。 + +### `crypto/ofd-seal.ts` + +生成、解析和验证电子印章,提取标准印章图片用于 OFD 签名外观。制章签名与签章签名是两个独立步骤。 + +### `crypto/ofd-sign.ts` / `crypto/ofd-verify.ts` + +负责 OFD 包处理、签名列表、逐文件保护范围、签名描述、SES 封装、印章外观和多次签章。 + +## 公共 API + +签名: + +```ts +const signed = await signOfd(bytes, { + eSeal, + signingCertificate, + signer, + stamp: { page: 1, x: 120, y: 240, w: 40, h: 40 }, + allowAdditionalSignatures: true, +}); +``` + +验签: + +```ts +const report = await verifyOfd(signed, { + trustAnchors, + intermediateCertificates, + revocationProvider, +}); +``` + +每个签章返回独立结果: + +- 包结构与引用完整性; +- 受保护文件摘要; +- `Signature.xml` 摘要绑定; +- 制章签名与签章签名; +- 制章者、签章者证书链; +- 信任与吊销状态; +- 印章有效期和签章时间; +- 综合状态及机器可读原因码。 + +只有调用方策略要求的检查全部通过时,综合状态才为可信。 + +## 签章流程 + +1. 校验 ZIP、包内路径、重复条目、大小限制、OFD 主入口和签名权限。 +2. 首次签章时在目标 `DocBody` 添加 `Signatures` 入口;后续签章不重复修改 `OFD.xml`。 +3. 分配未使用的 `Sign_N`,保留所有已有签章文件。 +4. 确定保护文件列表并逐个计算 SM3。允许继续签章时排除 `Signs/Signatures.xml`,不排除整个 `Signs/`;已有签章文件默认纳入新签章保护范围。 +5. 按标准生成 `Signature.xml`,包含算法提供者、签名方法、时间、逐文件引用摘要、`SignedValue` 位置和 `StampAnnot` 外观。 +6. 计算 `Signature.xml` 的 SM3,组装 SES `TBS_Sign`,通过签章者外部签名接口完成签名。 +7. 写入 `SignedValue.dat` 和 `Seal.esl`,更新 `Signatures.xml` 后重新打包。 + +可见印章来自 SES 电子印章图片,不再忽略 `sealImage` 字节或写入无资源关联的空外观。 + +## 验签流程 + +1. 安全解析签名列表和每个签名描述,检查包内路径及引用合法性。 +2. 按每个 `Reference` 重新计算文件摘要,检查文件缺失、篡改和重复引用。 +3. 严格解析 SES DER,验证 `dataHash` 与 `Signature.xml` 的绑定。 +4. 分别验证制章签名和签章签名。 +5. 分别验证制章者与签章者证书链、有效期、用途、信任锚和吊销状态。 +6. 校验印章有效期、签章时间及相关 SES 属性。 +7. 返回每个签章的独立报告;不因第一个签章成功而忽略后续签章。 + +## 错误处理 + +- `signOfd` 对无效参数、无权限、结构不安全或外部签名失败抛出明确异常,不返回部分签章包。 +- `verifyOfd` 对畸形 ZIP/XML/DER、缺失文件和不支持的关键扩展返回失败或不确定报告,不直接崩溃。 +- 参数类型错误和信任策略配置错误可以抛异常。 +- 不做静默降级;缺少信任锚或策略要求的吊销证据时不能报告为可信。 + +## 安全与资源限制 + +- ZIP 条目数、单条目和总解压大小、XML/DER 深度与长度均设上限。 +- 拒绝绝对路径、路径穿越、重复包条目和不安全引用。 +- XML 文本和属性统一转义,不依赖固定前缀或单一字符串模板处理外部文档。 +- 外部签名和吊销接口设置超时与错误传播策略。 +- 测试私钥仅由测试生成;真实私钥、证书链和印章材料通过本地未跟踪文件或环境变量提供。 + +## 测试与发布 + +单元与集成测试: + +- SM2/SM3 标准向量及边界条件; +- SES V4 DER 标准样例、往返与畸形输入; +- X.509 信任链、有效期、用途、未知关键扩展、CRL/OCSP 状态; +- 受保护文件篡改和恶意整体重签; +- 多次签章与旧签章保持; +- 可见印章及页面引用; +- 不同 XML 命名空间前缀、异常 ZIP/XML/DER; +- 公共 API、类型检查、lint、构建和文档示例。 + +外部验收: + +1. 使用两套真实测试证书链生成本地联调 OFD。 +2. 在数科 OFD 中检查印章外观、签章、证书链和篡改结果。 +3. 在福昕 OFD 中重复相同检查。 +4. 真实材料及生成物不提交仓库;仓库测试使用自动生成的测试 CA。 + +## 参考 + +- [GB/T 33190-2016 标准文本](https://ghhzrzyj.sxjz.gov.cn/Upload/gtj/ContentManage/Article/File/2023/12/11/GB/T33190-2016电子文件存储与交换格式版式文档-已压缩(1).pdf) +- [GM/T 0099-2020 内容摘录](https://www.sohu.com/a/695887360_121649130) +- [OFDRW SES V4 容器参考实现](https://github.com/ofdrw/ofdrw/blob/master/ofdrw-sign/src/main/java/org/ofdrw/sign/signContainer/SESV4Container.java) +- [OFDRW 数字签名说明](https://github.com/ofdrw/ofdrw/blob/master/ofdrw-sign/README.md) diff --git a/docs/plans/2026-10-17-ofd-ses-signature.md b/docs/plans/2026-10-17-ofd-ses-signature.md new file mode 100644 index 0000000..bc18969 --- /dev/null +++ b/docs/plans/2026-10-17-ofd-ses-signature.md @@ -0,0 +1,568 @@ +# OFD SES V4 电子签章 Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** 将实验性自签自验代码重写为支持 SES V4、X.509 信任链、多次签章和可见印章的 OFD 电子签章能力,并完成数科/福昕互操作验收。 + +**Architecture:** `asn1js` 负责严格 DER;独立 SM2/X.509 层负责数学验签与证书链;外部 signer 接口负责生产私钥。OFD 层只负责包、XML、逐文件摘要、SES 封装和验签报告,签名与信任判断分离。 + +**Tech Stack:** TypeScript 6、Vitest 5、asn1js 3.1.0、现有 SM2/SM3、GB/T 33190-2016、GB/T 38540-2020、GM/T 0099-2020。 + +设计依据:`docs/plans/2026-10-17-ofd-ses-signature-design.md`。除提交步骤外,每个任务都必须完成并通过测试后再进入下一任务;真实私钥和证书不得写入仓库。 + +--- + +### Task 1: 引入严格 DER 基础设施 + +**Files:** +- Modify: `packages/core/package.json` +- Modify: `pnpm-lock.yaml` +- Create: `packages/core/src/crypto/der.ts` +- Test: `packages/core/tests/der.test.ts` + +**Step 1: 写失败测试** + +覆盖:合法 DER 往返、尾随字节拒绝、非最短长度拒绝、超长输入拒绝、必填 SEQUENCE 子项数量检查。 + +```ts +import { describe, expect, it } from 'vitest'; +import { derSequence, parseDer, readSequence } from '../src/crypto/der'; + +it('拒绝尾随垃圾和非最短长度', () => { + const encoded = derSequence([]); + expect(() => parseDer(new Uint8Array([...encoded, 0]))).toThrow(/trailing/i); + expect(() => parseDer(new Uint8Array([0x30, 0x81, 0x00]))).toThrow(/DER|length/i); +}); +``` + +**Step 2: 运行并确认失败** + +Run: `pnpm --filter @hufe921/jsofd exec vitest run tests/der.test.ts` +Expected: FAIL,模块不存在。 + +**Step 3: 安装依赖并实现最小 DER 包装** + +Run: `pnpm --filter @hufe921/jsofd add asn1js@3.1.0` + +实现固定导出: + +```ts +export interface DerLimits { maxBytes: number; maxDepth: number; maxElements: number } +export const DEFAULT_DER_LIMITS: DerLimits = { maxBytes: 16 * 1024 * 1024, maxDepth: 32, maxElements: 10000 }; +export function parseDer(bytes: Uint8Array, limits?: Partial): asn1js.BaseBlock; +export function readSequence(block: asn1js.BaseBlock, min: number, max?: number): asn1js.Sequence; +export function derSequence(items: asn1js.BaseBlock[]): Uint8Array; +export function expectDerReencode(block: asn1js.BaseBlock, original: Uint8Array): void; +``` + +所有解析入口检查 `lenBlock`、原始长度、重编码一致性和资源上限;不对外暴露 asn1js 类型到公共 API。 + +**Step 4: 测试与静态检查** + +Run: `pnpm --filter @hufe921/jsofd exec vitest run tests/der.test.ts && pnpm --filter @hufe921/jsofd typecheck` +Expected: PASS。 + +**Step 5: Commit** + +```bash +git add packages/core/package.json packages/core/src/crypto/der.ts packages/core/tests/der.test.ts pnpm-lock.yaml +git commit -m "feat: add strict DER handling for OFD signatures" +``` + +--- + +### Task 2: 修复 SM2/SM3 边界与签名编码 + +**Files:** +- Modify: `packages/core/src/crypto/sm2.ts` +- Modify: `packages/core/src/crypto/sm3.ts` +- Create: `packages/core/src/crypto/base64.ts` +- Test: `packages/core/tests/crypto.test.ts` +- Test: `packages/core/tests/crypto-boundary.test.ts` + +**Step 1: 写边界失败测试** + +至少覆盖:`d = 0`、`d = N - 1`、`d = N`、`k = 0`、`k = N`、`k > N`、64/65/66 字节公钥、短签名、非法点、非法 hex;SM2 DER `SEQUENCE(INTEGER r, INTEGER s)` 与 64 字节 `r||s` 双向转换。 + +```ts +expect(() => sm2SignWithK(key, msg, 0n)).toThrow(/nonce|range/i); +expect(() => sm2SignWithK(key, msg, N)).toThrow(/nonce|range/i); +expect(() => sm2Sign(nMinusOne, msg)).toThrow(/private key/i); +expect(sm2Verify(new Uint8Array(65), msg, new Uint8Array(1))).toBe(false); +``` + +**Step 2: 确认测试按预期失败** + +Run: `pnpm --filter @hufe921/jsofd exec vitest run tests/crypto-boundary.test.ts` +Expected: 新边界测试 FAIL。 + +**Step 3: 最小修复** + +- `randomScalar` 使用拒绝采样,私钥范围为 `[1, N - 2]`;nonce 为 `[1, N - 1]`。 +- `sm2SignWithK` 显式检查 `d` 和 `k`,不再可能因 `1 + d ≡ 0 (mod N)` 无限循环。 +- 公钥只接受规范 65 字节 `04 || X || Y`,签名只接受 64 字节或规范 DER;`za` 使用同一规范化公钥。 +- `hexToBytes` 拒绝非 hex 字符;新增无平台依赖的 Base64 编解码供 OFD `CheckValue` 使用。 + +**Step 4: 跑密码测试** + +Run: `pnpm --filter @hufe921/jsofd exec vitest run tests/crypto.test.ts tests/crypto-boundary.test.ts` +Expected: 原标准向量、sm-crypto 互验和新增边界全部 PASS。 + +**Step 5: Commit** + +```bash +git add packages/core/src/crypto packages/core/tests/crypto*.test.ts +git commit -m "fix: harden SM2 boundaries and signature encodings" +``` + +--- + +### Task 3: 实现 X.509 SM2 解析与信任链 + +**Files:** +- Create: `packages/core/src/crypto/x509-sm2.ts` +- Create: `packages/core/src/crypto/revocation.ts` +- Create: `packages/core/tests/helpers/test-ca.ts` +- Test: `packages/core/tests/x509-sm2.test.ts` + +**Step 1: 写失败测试** + +测试 CA 动态生成 Root → Intermediate → Leaf,覆盖:可信链、错误根、过期、非 CA 签发者、pathLen、Key Usage、未知关键扩展、被吊销、吊销未知、证书签名被篡改。 + +公共结果形状: + +```ts +interface CertificateTrustResult { + status: 'trusted' | 'untrusted' | 'expired' | 'revoked' | 'indeterminate'; + chain: string[]; + reasons: string[]; +} +``` + +**Step 2: 确认失败** + +Run: `pnpm --filter @hufe921/jsofd exec vitest run tests/x509-sm2.test.ts` +Expected: FAIL,API 不存在。 + +**Step 3: 实现 X.509 与吊销接口** + +```ts +export interface RevocationProvider { + check(certificate: Uint8Array, issuer: Uint8Array, at: Date): Promise<'good' | 'revoked' | 'unknown'>; +} +export interface VerifyCertificateOptions { + trustAnchors: Uint8Array[]; + intermediates?: Uint8Array[]; + at?: Date; + revocationProvider?: RevocationProvider; + requireRevocation?: boolean; +} +export function parseSm2Certificate(der: Uint8Array): Sm2X509Certificate; +export function verifySm2CertificateSignature(cert: Uint8Array, issuer: Uint8Array): boolean; +export async function verifyCertificateChain(cert: Uint8Array, options: VerifyCertificateOptions): Promise; +``` + +只接受标准 X.509 字段,不保留旧的简化自签 DER 生成器作为生产路径。测试 CA 序列号独立随机,证书用途按制章/签章区分。 + +**Step 4: 跑测试** + +Run: `pnpm --filter @hufe921/jsofd exec vitest run tests/x509-sm2.test.ts` +Expected: 全部 PASS,错误根不能因包内公钥数学验签成功而变为 trusted。 + +**Step 5: Commit** + +```bash +git add packages/core/src/crypto/x509-sm2.ts packages/core/src/crypto/revocation.ts packages/core/tests +git commit -m "feat: add SM2 X509 trust validation" +``` + +--- + +### Task 4: 实现 SES V4 DER 对象 + +**Files:** +- Create: `packages/core/src/crypto/ses-v4.ts` +- Create: `packages/core/tests/fixtures/ses-v4.ts` +- Test: `packages/core/tests/ses-v4.test.ts` + +**Step 1: 写 SES 结构失败测试** + +用 OFDRW/标准样例的 DER 十六进制作为 golden fixture,验证逐字节解析与重编码;覆盖 `SESeal`、`SES_SealInfo`、`TBS_Sign`、`SES_Signature`、图片信息、可选扩展和 `[0]` 时间戳。 + +```ts +const seal = parseSesSeal(SES_V4_SEAL_DER); +expect(seal.header.version).toBe(4); +expect(encodeSesSeal(seal)).toEqual(SES_V4_SEAL_DER); +expect(() => parseSesSignature(new Uint8Array([0x30, 0x00]))).toThrow(/SES|field/i); +``` + +**Step 2: 确认失败** + +Run: `pnpm --filter @hufe921/jsofd exec vitest run tests/ses-v4.test.ts` +Expected: FAIL。 + +**Step 3: 实现最小完整 SES 映射** + +固定结构: + +```text +SESeal ::= SEQUENCE { eSealInfo, cert OCTET STRING, signAlgID OID, signedValue BIT STRING } +TBS_Sign ::= SEQUENCE { version INTEGER, eseal SESeal, timeInfo GeneralizedTime, + dataHash BIT STRING, propertyInfo IA5String, extDatas [0] OPTIONAL } +SES_Signature ::= SEQUENCE { toSign TBS_Sign, cert OCTET STRING, signatureAlgID OID, + signature BIT STRING, timeStamp [0] BIT STRING OPTIONAL } +``` + +同时实现 V4 印章头、属性、图片类型/数据、宽高和扩展数据;保留原始 DER 子字段供签名验证,禁止解析后重序列化 TBS 再验签。 + +**Step 4: 跑 golden 与畸形输入测试** + +Run: `pnpm --filter @hufe921/jsofd exec vitest run tests/ses-v4.test.ts tests/der.test.ts` +Expected: golden 逐字节一致,截断/错标签/尾随数据均拒绝。 + +**Step 5: Commit** + +```bash +git add packages/core/src/crypto/ses-v4.ts packages/core/tests/ses-v4.test.ts packages/core/tests/fixtures/ses-v4.ts +git commit -m "feat: implement SES V4 DER structures" +``` + +--- + +### Task 5: 外部签名接口与本地测试签名器 + +**Files:** +- Create: `packages/core/src/crypto/signer.ts` +- Modify: `packages/core/src/crypto/ofd-sign.ts` +- Test: `packages/core/tests/signer.test.ts` + +**Step 1: 写失败测试** + + fake signer 记录收到的字节,断言收到的是原始 TBS DER 而非摘要;验证异步错误、超时和 DER 签名格式错误会失败。 + +```ts +export interface Sm2Signer { + sign(request: { tbs: Uint8Array; certificate: Uint8Array; userId?: string; signal?: AbortSignal }): Promise; +} +export function createLocalTestSm2Signer(privateKey: Uint8Array, userId?: string): Sm2Signer; +``` + +**Step 2: 确认失败** + +Run: `pnpm --filter @hufe921/jsofd exec vitest run tests/signer.test.ts` +Expected: FAIL。 + +**Step 3: 实现接口与测试适配器** + +生产接口只传 `tbs`、证书和可选 UserID;本地适配器显式命名为 `createLocalTestSm2Signer`,文档标明不得用于生产密钥。删除 `cert.privateKey!` 非空断言和隐式自签证书默认行为。 + +**Step 4: 跑测试** + +Run: `pnpm --filter @hufe921/jsofd exec vitest run tests/signer.test.ts tests/crypto.test.ts` +Expected: PASS。 + +**Step 5: Commit** + +```bash +git add packages/core/src/crypto/signer.ts packages/core/src/crypto/ofd-sign.ts packages/core/tests/signer.test.ts +git commit -m "feat: add external SM2 signer interface" +``` + +--- + +### Task 6: 制章、验章与印章图片 + +**Files:** +- Create: `packages/core/src/crypto/ofd-seal.ts` +- Test: `packages/core/tests/ofd-seal.test.ts` + +**Step 1: 写失败测试** + +覆盖:制章者签名生成 eSeal、制章者证书不匹配、印章过期、图片提取、篡改 `eSealInfo`、未知关键扩展、真实信任链与不受信链分离。 + +```ts +const seal = await createSesSeal({ info, makerCertificate, signer: makerSigner }); +const result = await verifySesSeal(seal, { trustAnchors, at: new Date('2026-10-17T00:00:00Z') }); +expect(result.signatureValid).toBe(true); +expect(result.picture.data).toEqual(png); +``` + +**Step 2: 确认失败** + +Run: `pnpm --filter @hufe921/jsofd exec vitest run tests/ofd-seal.test.ts` +Expected: FAIL。 + +**Step 3: 实现制章与验章** + +- 对原始 `eSealInfo` DER 调用制章 signer;`signedValue` 保存 DER SM2 签名。 +- 验章使用保存的原始 TBS 字节、制章证书和 SM2 UserID 策略。 +- 独立返回 `signatureValid`、`trust`、`validity` 和 `reasons`;图片只来自 `eSealInfo.picture`。 + +**Step 4: 跑测试** + +Run: `pnpm --filter @hufe921/jsofd exec vitest run tests/ofd-seal.test.ts tests/ses-v4.test.ts` +Expected: PASS。 + +**Step 5: Commit** + +```bash +git add packages/core/src/crypto/ofd-seal.ts packages/core/tests/ofd-seal.test.ts +git commit -m "feat: create and verify SES electronic seals" +``` + +--- + +### Task 7: 安全的 OFD 签名包与 XML 操作 + +**Files:** +- Create: `packages/core/src/crypto/ofd-package.ts` +- Create: `packages/core/src/crypto/ofd-signature-xml.ts` +- Modify: `packages/core/src/zip-read.ts` +- Test: `packages/core/tests/ofd-package.test.ts` + +**Step 1: 写失败测试** + +覆盖:路径穿越、绝对路径归一化、重复 ZIP 条目、大小限制、多 DocBody、`ofd:` 与默认命名空间、已有 `Signatures` 入口、特殊字符转义、非法页 ID 和 StampAnnot。 + +XML 生成形状: + +```xml + + + s002 + +``` + +```xml + + base64(sm3) + +``` + +**Step 2: 确认失败** + +Run: `pnpm --filter @hufe921/jsofd exec vitest run tests/ofd-package.test.ts` +Expected: FAIL。 + +**Step 3: 实现包和 XML 层** + +提供: + +```ts +export function resolveOfdPath(baseFile: string, loc: string): string; +export function selectDocBody(entries: Map, index?: number): OfdDocBody; +export function collectProtectedEntries(entries: Map, signaturesLoc: string, allowAdditional: boolean): ProtectedEntry[]; +export function buildSignatureXml(input: SignatureXmlInput): Uint8Array; +export function updateSignatureList(input: SignatureListInput): UpdateResult; +``` + +首次签章先修改 `OFD.xml` 再计算其摘要;后续只更新签名列表。允许后续签章时仅排除签名列表,旧 `Sign_N` 全部保留并纳入保护。完整保护模式包含签名列表,后续签章必须拒绝。 + +**Step 4: 跑测试** + +Run: `pnpm --filter @hufe921/jsofd exec vitest run tests/ofd-package.test.ts tests/zip.test.ts` +Expected: PASS。 + +**Step 5: Commit** + +```bash +git add packages/core/src/crypto/ofd-package.ts packages/core/src/crypto/ofd-signature-xml.ts packages/core/src/zip-read.ts packages/core/tests/ofd-package.test.ts +git commit -m "feat: add safe OFD signature package handling" +``` + +--- + +### Task 8: 重写 signOfd 为 SES V4 多次签章 + +**Files:** +- Modify: `packages/core/src/crypto/ofd-sign.ts` +- Test: `packages/core/tests/sign.test.ts` +- Test: `packages/core/tests/sign-multi.test.ts` + +**Step 1: 写失败测试** + +断言生成物包含: + +```text +OFD.xml(仅首次修改) +Signs/Signatures.xml +Signs/Sign_0/Signature.xml +Signs/Sign_0/Seal.esl +Signs/Sign_0/SignedValue.dat +Signs/Sign_1/...(第二次签章) +``` + +检查 References 逐文件 SM3、SES `dataHash = SM3(Signature.xml)`、`Seal.esl` 等于输入 eSeal、StampAnnot 页面/Boundary/ID 正确、旧签章字节不变。 + +**Step 2: 确认旧实现失败** + +Run: `pnpm --filter @hufe921/jsofd exec vitest run tests/sign.test.ts tests/sign-multi.test.ts` +Expected: 旧 129 字节格式 FAIL。 + +**Step 3: 重写签章编排** + +公共输入: + +```ts +export interface SignOfdOptions { + eSeal: Uint8Array; + signingCertificate: Uint8Array; + signer: Sm2Signer; + stamp?: { page: number; x: number; y: number; w: number; h: number; clip?: string }; + allowAdditionalSignatures?: boolean; + docBodyIndex?: number; + provider?: { name: string; company?: string; version?: string }; + now?: () => Date; + signal?: AbortSignal; +} +``` + +严格按“包预检 → 修改入口 → 保护列表 → Signature.xml → SES TBS → 外部签章 → 写 Seal.esl/SignedValue.dat → 更新列表 → 重打包”执行。删除 `SignedValue = r||s||publicKey`、`Certificate.der`、空 `sealImage` 分支和旧 `cmsParse`。 + +**Step 4: 跑签章测试** + +Run: `pnpm --filter @hufe921/jsofd exec vitest run tests/sign.test.ts tests/sign-multi.test.ts` +Expected: 单次/多次签章、命名空间和 XML 特殊字符全部 PASS。 + +**Step 5: Commit** + +```bash +git add packages/core/src/crypto/ofd-sign.ts packages/core/tests/sign*.test.ts +git commit -m "feat: generate standard SES V4 OFD signatures" +``` + +--- + +### Task 9: 重写 verifyOfd 与抗恶意重签 + +**Files:** +- Create: `packages/core/src/crypto/ofd-verify.ts` +- Modify: `packages/core/src/crypto/ofd-sign.ts` +- Test: `packages/core/tests/verify.test.ts` +- Test: `packages/core/tests/verify-attacks.test.ts` + +**Step 1: 写失败测试** + +覆盖:正文篡改、Signature.xml 篡改、Seal.esl 篡改、SignedValue 替换、攻击者自有密钥整体重签、错误信任根、证书过期/吊销、后续合法签章、畸形 ZIP/XML/DER、未知关键扩展。 + +```ts +const report = await verifyOfd(attackerResigned, { trustAnchors: [victimRoot] }); +expect(report.signatures[0].status).toBe('untrusted'); +expect(report.signatures[0].signatureValid).toBe(true); // 数学有效但不属于受信签章者 +``` + +**Step 2: 确认旧验签失败** + +Run: `pnpm --filter @hufe921/jsofd exec vitest run tests/verify-attacks.test.ts` +Expected: 旧实现对恶意重签错误地返回 valid。 + +**Step 3: 实现分层验签报告** + +```ts +type SignatureStatus = 'valid' | 'invalid' | 'untrusted' | 'indeterminate'; +interface OfdSignatureVerification { + id: string; + referencesValid: boolean; + signatureValid: boolean; + sealSignatureValid: boolean; + signerTrust: CertificateTrustResult; + makerTrust: CertificateTrustResult; + status: SignatureStatus; + reasons: string[]; +} +interface OfdVerifyResult { signatures: OfdSignatureVerification[]; errors: string[] } +``` + +验证所有签名,不取第一个后返回。只有 References、SES 两级签名、时间、证书链、信任与策略要求的吊销检查全部满足时状态为 `valid`。输入畸形进入 `errors` 或对应签名报告。 + +**Step 4: 跑全部安全测试** + +Run: `pnpm --filter @hufe921/jsofd exec vitest run tests/verify.test.ts tests/verify-attacks.test.ts tests/sign-multi.test.ts` +Expected: 恶意重签不能成为 valid,合法两次签章均通过。 + +**Step 5: Commit** + +```bash +git add packages/core/src/crypto/ofd-verify.ts packages/core/src/crypto/ofd-sign.ts packages/core/tests/verify*.test.ts +git commit -m "fix: verify SES signatures with explicit trust" +``` + +--- + +### Task 10: 公共 API、文档与清理 + +**Files:** +- Modify: `packages/core/src/index.ts` +- Modify: `packages/core/src/crypto/ofd-sign.ts` +- Modify: `packages/core/package.json` +- Modify: `README.md` +- Modify: `docs/guide/advanced.md` +- Create: `docs/guide/signatures.md` +- Modify: `docs/.vitepress/config.ts` +- Modify: `docs/guide/ofd-reader.md` + +**Step 1: 写公共导入失败测试** + +```ts +import { signOfd, verifyOfd, createSesSeal, createLocalTestSm2Signer } from '@hufe921/jsofd'; +``` + +在 `packages/core/tests/package.test.ts` 检查导出和构建类型声明;明确本地 signer 的测试用途。 + +**Step 2: 确认失败并完成导出** + +导出签名、验签、制章、 signer/报告类型;不导出内部 ASN.1 和 DER 辅助类型。更新包 `exports`(如采用 `./sign` 子路径则同步 tsup 与类型声明)。 + +**Step 3: 更新文档** + +包含完整 SES 示例、外部 signer 示例、信任链/吊销示例、多次签章行为、可见印章来源和安全注意事项;删除 CMS 注释和“自签证书默认可信”等旧描述。 + +**Step 4: 全量验证** + +Run: `pnpm test && pnpm typecheck && pnpm lint && pnpm build && pnpm docs:build` +Expected: 全部 PASS,无旧 129 字节格式或 `cmsParse` 残留。 + +**Step 5: Commit** + +```bash +git add packages/core/src/index.ts packages/core/package.json README.md docs packages/core/tests/package.test.ts +git commit -m "docs: document production SES signature integration" +``` + +--- + +### Task 11: 数科与福昕真实材料互操作验收 + +**Files:** +- Create: `packages/core/tests/manual/ses-interop.ts` +- Modify: `.gitignore`(仅在本地输出目录确有必要时) +- Modify: `docs/guide/signatures.md` + +**Step 1: 编写手工联调脚本** + +从 `JSOFD_SES_TEST_DIR` 读取两套证书链、私钥句柄/外部 signer 配置和印章材料;默认输出到系统临时目录。仓库中只保存脚本,不保存真实材料。 + +**Step 2: 自动前置检查** + +Run: `JSOFD_SES_TEST_DIR=/secure/local/path pnpm --filter @hufe921/jsofd exec tsx tests/manual/ses-interop.ts` +Expected: 生成单次签章、两次签章和篡改样本;本地 `verifyOfd` 对合法样本为 valid,篡改为 invalid。 + +**Step 3: 数科验收** + +逐一确认:印章图片、位置、签章者/制章者证书链、签名有效、两次签章均保留、篡改样本失败。记录阅读器版本和结果,不记录私钥。 + +**Step 4: 福昕验收** + +重复 Step 3。若厂商要求兼容写法,先在标准允许范围内修正,并补充对应 golden 测试,不能通过关闭信任检查换取通过。 + +**Step 5: 最终回归** + +Run: `pnpm test && pnpm typecheck && pnpm lint && pnpm build && pnpm docs:build` +Expected: 全部 PASS;外部验收结果补齐后方可宣称生产互操作完成。 + +**Step 6: Commit** + +```bash +git add packages/core/tests/manual/ses-interop.ts docs/guide/signatures.md .gitignore +git commit -m "test: add external SES interoperability harness" +``` diff --git a/packages/core/package.json b/packages/core/package.json index c96335e..4cb0af7 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -79,5 +79,9 @@ "publishConfig": { "registry": "https://registry.npmjs.org/", "access": "public" + }, + "dependencies": { + "asn1js": "3.1.0", + "sm-crypto": "0.5.7" } } diff --git a/packages/core/src/builder.ts b/packages/core/src/builder.ts index 516af74..694feaa 100644 --- a/packages/core/src/builder.ts +++ b/packages/core/src/builder.ts @@ -200,8 +200,18 @@ export function buildOFDPackage(ctx: BuildContext): ZipEntry[] { } doc += ` \n`; doc += ` \n`; - ctx.pages.forEach((_page, idx) => { - doc += ` \n`; + ctx.pages.forEach((page, idx) => { + // GB/T 33190 allows a per-page Area overriding the CommonData template — + // required when pages use different formats (A4 + A5 …). + const w = fmt(page.width * MM_PER_PT); + const h = fmt(page.height * MM_PER_PT); + if (w !== w0 || h !== h0) { + doc += ` \n`; + doc += ` 0 0 ${w} ${h}\n`; + doc += ` \n`; + } else { + doc += ` \n`; + } }); doc += ` \n`; if (ctx.outlineRoot.children.length) { diff --git a/packages/core/src/crypto/base64.ts b/packages/core/src/crypto/base64.ts new file mode 100644 index 0000000..53cdb69 --- /dev/null +++ b/packages/core/src/crypto/base64.ts @@ -0,0 +1,34 @@ +const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; +const BASE64_PATTERN = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/; + +export function base64Encode(bytes: Uint8Array): string { + let out = ''; + for (let i = 0; i < bytes.length; i += 3) { + const a = bytes[i]!; + const b = bytes[i + 1]; + const c = bytes[i + 2]; + const value = (a << 16) | ((b ?? 0) << 8) | (c ?? 0); + out += ALPHABET[(value >>> 18) & 63]; + out += ALPHABET[(value >>> 12) & 63]; + out += b === undefined ? '=' : ALPHABET[(value >>> 6) & 63]; + out += c === undefined ? '=' : ALPHABET[value & 63]; + } + return out; +} + +export function base64Decode(text: string): Uint8Array { + if (!BASE64_PATTERN.test(text)) throw new Error('Invalid Base64 input'); + const out = new Uint8Array((text.length / 4) * 3 - (text.endsWith('==') ? 2 : text.endsWith('=') ? 1 : 0)); + let offset = 0; + for (let i = 0; i < text.length; i += 4) { + const a = ALPHABET.indexOf(text[i]!); + const b = ALPHABET.indexOf(text[i + 1]!); + const c = text[i + 2] === '=' ? 0 : ALPHABET.indexOf(text[i + 2]!); + const d = text[i + 3] === '=' ? 0 : ALPHABET.indexOf(text[i + 3]!); + const value = (a << 18) | (b << 12) | (c << 6) | d; + if (offset < out.length) out[offset++] = (value >>> 16) & 0xff; + if (offset < out.length) out[offset++] = (value >>> 8) & 0xff; + if (offset < out.length) out[offset++] = value & 0xff; + } + return out; +} diff --git a/packages/core/src/crypto/der.ts b/packages/core/src/crypto/der.ts new file mode 100644 index 0000000..c410e95 --- /dev/null +++ b/packages/core/src/crypto/der.ts @@ -0,0 +1,102 @@ +import * as asn1js from 'asn1js'; + +export interface DerLimits { + maxBytes: number; + maxDepth: number; + maxElements: number; +} + +export const DEFAULT_DER_LIMITS: DerLimits = { + maxBytes: 16 * 1024 * 1024, + maxDepth: 32, + maxElements: 10000, +}; + +function equalBytes(a: Uint8Array, b: Uint8Array): boolean { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) return false; + } + return true; +} + +export function expectDerReencode(block: asn1js.BaseBlock, original: Uint8Array): void { + const encoded = new Uint8Array(block.toBER()); + if (!equalBytes(encoded, original)) { + throw new Error('DER value is not canonically encoded'); + } +} + +function assertCanonicalLengths(block: asn1js.BaseBlock): void { + const { lenBlock } = block; + if (lenBlock.isIndefiniteForm) { + throw new Error('DER length must use definite form'); + } + if (lenBlock.longFormUsed) { + let length = BigInt(lenBlock.length); + let minimumBytes = 0; + while (length > 0n) { + minimumBytes++; + length >>= 8n; + } + if (lenBlock.length < 128 || lenBlock.blockLength - 1 !== minimumBytes) { + throw new Error('DER length is not minimally encoded'); + } + } + const children = (block.valueBlock as unknown as { value?: unknown }).value; + if (Array.isArray(children)) { + for (const child of children) { + if (child instanceof asn1js.BaseBlock) assertCanonicalLengths(child); + } + } +} + +export function parseDer( + bytes: Uint8Array, + limits: Partial = {}, +): asn1js.BaseBlock { + const resolved = { ...DEFAULT_DER_LIMITS, ...limits }; + if (bytes.length > resolved.maxBytes) { + throw new Error('DER size limit exceeded'); + } + if (resolved.maxDepth < 1 || resolved.maxElements < 1) { + throw new Error('DER limits must be positive'); + } + const parsed = asn1js.fromBER(bytes, { + maxDepth: resolved.maxDepth - 1, + maxNodes: resolved.maxElements, + maxContentLength: resolved.maxBytes, + copyInput: true, + }); + if (parsed.offset < 0 || parsed.result.error) { + const reason = parsed.result.error || 'invalid DER'; + if (/depth/i.test(reason)) throw new Error(`DER depth limit exceeded: ${reason}`); + if (/node|element/i.test(reason)) throw new Error(`DER elements limit exceeded: ${reason}`); + throw new Error(`DER parse error: ${reason}`); + } + if (parsed.offset !== bytes.length) { + throw new Error('DER trailing bytes are not allowed'); + } + assertCanonicalLengths(parsed.result); + expectDerReencode(parsed.result, bytes); + return parsed.result; +} + +export function readSequence( + block: asn1js.BaseBlock, + min: number, + max = min, +): asn1js.Sequence { + if (!(block instanceof asn1js.Sequence)) { + throw new Error('DER SEQUENCE expected'); + } + const length = block.valueBlock.value.length; + if (length < min || length > max) { + throw new Error(`DER SEQUENCE children length must be between ${min} and ${max}`); + } + return block; +} + +export function derSequence(items: asn1js.BaseBlock[]): Uint8Array { + return new Uint8Array(new asn1js.Sequence({ value: items }).toBER()); +} diff --git a/packages/core/src/crypto/ofd-encryption.ts b/packages/core/src/crypto/ofd-encryption.ts new file mode 100644 index 0000000..3f8983f --- /dev/null +++ b/packages/core/src/crypto/ofd-encryption.ts @@ -0,0 +1,175 @@ +import { base64Decode, base64Encode } from './base64'; +import { readOfdPackage, resolveOfdPath } from './ofd-package'; +import { sm4CbcDecrypt, sm4CbcEncrypt } from './sm4'; +import { kdf } from './sm3'; +import { zipStore } from '../zip'; +import { escapeXmlAttr, utf8Encode } from '../utils'; +import { kid, kids, parseXml, textOf } from '../xml-parse'; +import { OFD_NAMESPACE } from '../xml'; + +const PASSWORD_CASE_ID = '1.1.1'; + +export interface OfdPasswordEncryptionOptions { + userName: string; + userType?: 'Owner' | 'User'; + password: string; + filter?: (name: string) => boolean; + provider?: { name: string; company?: string; version?: string }; +} + +export interface OfdPasswordDecryptionOptions { + userName?: string; + password: string; +} + +function repack(entries: Map): Uint8Array { + const files = [...entries].map(([name, data]) => ({ name, data })); + files.sort((a, b) => (a.name === 'OFD.xml' ? -1 : b.name === 'OFD.xml' ? 1 : 0)); + return zipStore(files); +} + +function passwordKey(password: string): Uint8Array { + if (!password) throw new Error('OFD encryption password must not be empty'); + return kdf(new TextEncoder().encode(password), 16); +} + +function buildEncryptions(options: OfdPasswordEncryptionOptions, partial: boolean): Uint8Array { + const provider = options.provider ?? { name: 'jsOFD', version: '1.0' }; + const attrs = + `ProviderName="${escapeXmlAttr(provider.name)}"` + + (provider.company ? ` Company="${escapeXmlAttr(provider.company)}"` : '') + + (provider.version ? ` Version="${escapeXmlAttr(provider.version)}"` : ''); + const xml = + `\n\n` + + ` \n \n` + + ` ${partial ? 'Custom' : 'All'}\n` + + ' /decryptseed.dat\n' + + ' /entriesmap.dat\n' + + ' \n\n'; + return utf8Encode(xml); +} + +function buildDecryptSeed( + options: OfdPasswordEncryptionOptions, + encryptedWk: Uint8Array, + iv: Uint8Array, +): Uint8Array { + const userType = options.userType ? ` UserType="${options.userType}"` : ''; + const xml = + `\n` + + `\n` + + ` \n` + + ` ${base64Encode(encryptedWk)}\n` + + ` ${base64Encode(iv)}\n` + + ' \n \n\n'; + return utf8Encode(xml); +} + +function buildEntriesMap(entries: { path: string; encryptedPath: string }[]): Uint8Array { + let xml = `\n\n`; + for (const entry of entries) { + xml += ` \n`; + } + return utf8Encode(xml + '\n'); +} + +function encryptedName(plaintext: string, used: Set): string { + const parts = plaintext.split('/'); + const base = parts.pop()!.toLowerCase(); + const dir = parts.join('/'); + let index = 0; + for (;;) { + const name = `${base}${index ? `_${index}` : ''}.dat`; + const candidate = dir ? `${dir}/${name}` : name; + if (!used.has(candidate)) return candidate; + index++; + } +} + +export async function encryptOfdPassword( + bytes: Uint8Array | ArrayBuffer, + options: OfdPasswordEncryptionOptions, +): Promise { + if (!options.userName) throw new Error('OFD encryption userName must not be empty'); + const input = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes); + const entries = await readOfdPackage(input); + if (entries.has('Encryptions.xml')) throw new Error('OFD package is already encrypted'); + const wrappingKey = passwordKey(options.password); + const fek = new Uint8Array(16); + const iv = new Uint8Array(16); + crypto.getRandomValues(fek); + crypto.getRandomValues(iv); + const filter = options.filter ?? (() => true); + const selected = [...entries.keys()].filter(filter).sort(); + if (!selected.length) throw new Error('OFD encryption selected no files'); + const used = new Set(entries.keys()); + const mappings: { path: string; encryptedPath: string }[] = []; + for (const name of selected) { + const encryptedPath = encryptedName(name, used); + const ciphertext = sm4CbcEncrypt(entries.get(name)!, fek, iv); + entries.delete(name); + entries.set(encryptedPath, ciphertext); + used.add(encryptedPath); + mappings.push({ path: `/${name}`, encryptedPath: `/${encryptedPath}` }); + } + const mapXml = buildEntriesMap(mappings); + entries.set('entriesmap.dat', sm4CbcEncrypt(mapXml, fek, iv)); + entries.set('Encryptions.xml', buildEncryptions(options, Boolean(options.filter))); + entries.set( + 'decryptseed.dat', + buildDecryptSeed(options, sm4CbcEncrypt(fek, wrappingKey, iv), iv), + ); + return repack(entries); +} + +export async function decryptOfdPassword( + bytes: Uint8Array | ArrayBuffer, + options: OfdPasswordDecryptionOptions, +): Promise { + try { + const input = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes); + const entries = await readOfdPackage(input); + const encryptionXml = entries.get('Encryptions.xml'); + if (!encryptionXml) throw new Error('Encryptions.xml is missing'); + const encryptions = parseXml(new TextDecoder().decode(encryptionXml)); + const info = kids(encryptions, 'EncryptInfo')[0]; + if (!info) throw new Error('EncryptInfo is missing'); + const seedLoc = textOf(kid(info, 'DecryptSeedLoc')!); + const mapLoc = textOf(kid(info, 'EntriesMapLoc')!); + const seedPath = resolveOfdPath('Encryptions.xml', seedLoc); + const mapPath = resolveOfdPath('Encryptions.xml', mapLoc); + const seed = parseXml(new TextDecoder().decode(entries.get(seedPath))); + if (seed.name !== 'DecyptSeed' || seed.attrs.EncryptCaseId !== PASSWORD_CASE_ID) { + throw new Error('Unsupported OFD encryption scheme'); + } + const users = kids(seed, 'UserInfo'); + const user = options.userName + ? users.find((item) => item.attrs.UserName === options.userName) + : users[0]; + if (!user) throw new Error('OFD decryption user was not found'); + const ivElement = kid(user, 'IVValue'); + const iv = ivElement ? base64Decode(textOf(ivElement)) : new Uint8Array(16); + const encryptedWk = base64Decode(textOf(kid(user, 'EncryptedWK')!)); + const fek = sm4CbcDecrypt(encryptedWk, passwordKey(options.password), iv); + if (fek.length !== 16) throw new Error('OFD invalid file encryption key'); + const mapXml = sm4CbcDecrypt(entries.get(mapPath)!, fek, iv); + const map = parseXml(new TextDecoder().decode(mapXml)); + const mappings = kids(map, 'EncryptEntry'); + const result = new Map(entries); + result.delete('Encryptions.xml'); + result.delete(seedPath); + result.delete(mapPath); + for (const mapping of mappings) { + const plaintextPath = resolveOfdPath('', mapping.attrs.Path!); + const encryptedPath = resolveOfdPath('', mapping.attrs.EPath!); + const ciphertext = result.get(encryptedPath); + if (!ciphertext) throw new Error(`OFD encrypted entry is missing: ${encryptedPath}`); + result.delete(encryptedPath); + result.set(plaintextPath, sm4CbcDecrypt(ciphertext, fek, iv)); + } + return repack(result); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`OFD password decryption failed: ${message}`, { cause: error }); + } +} diff --git a/packages/core/src/crypto/ofd-package.ts b/packages/core/src/crypto/ofd-package.ts new file mode 100644 index 0000000..da31635 --- /dev/null +++ b/packages/core/src/crypto/ofd-package.ts @@ -0,0 +1,79 @@ +import { unzip } from '../zip-read'; +import { kid, kids, parseXml, textOf } from '../xml-parse'; + +export interface OfdDocBody { + index: number; + docRoot: string; + signaturesLoc?: string; +} + +export interface ProtectedEntry { + name: string; + data: Uint8Array; +} + +export function resolveOfdPath(baseFile: string, loc: string): string { + if (!loc || loc.includes('\\') || loc.includes('\0') || /^[a-zA-Z]:/.test(loc)) { + throw new Error('OFD invalid package path'); + } + const rootRelative = loc.startsWith('/'); + const baseDir = rootRelative ? '' : baseFile.split('/').slice(0, -1).join('/'); + const input = `${baseDir}/${loc.replace(/^\/+/, '')}`; + const parts: string[] = []; + for (const part of input.split('/')) { + if (!part || part === '.') continue; + if (part === '..') { + if (!parts.length) throw new Error('OFD path traversal is not allowed'); + parts.pop(); + } else { + parts.push(part); + } + } + if (!parts.length) throw new Error('OFD invalid empty package path'); + return parts.join('/'); +} + +export async function readOfdPackage(data: Uint8Array): Promise> { + const entries = await unzip(data, { + maxEntries: 10000, + maxEntryBytes: 64 * 1024 * 1024, + maxTotalBytes: 256 * 1024 * 1024, + rejectDuplicates: true, + }); + for (const name of entries.keys()) { + if (resolveOfdPath('', name) !== name) throw new Error(`OFD unsafe package entry name: ${name}`); + } + return entries; +} + +export function selectDocBody(entries: Map, index = 0): OfdDocBody { + const bytes = entries.get('OFD.xml'); + if (!bytes) throw new Error('OFD.xml is missing'); + const root = parseXml(new TextDecoder().decode(bytes)); + if (root.name !== 'OFD') throw new Error('OFD main entry has an invalid root'); + const bodies = kids(root, 'DocBody'); + const body = bodies[index]; + if (!body) throw new Error(`OFD DocBody index out of range: ${index}`); + const docRoot = kid(body, 'DocRoot'); + if (!docRoot) throw new Error('OFD DocRoot is missing'); + const signatures = kid(body, 'Signatures'); + return { + index, + docRoot: resolveOfdPath('OFD.xml', textOf(docRoot)), + signaturesLoc: signatures ? resolveOfdPath('OFD.xml', textOf(signatures)) : undefined, + }; +} + +export function collectProtectedEntries( + entries: Map, + signaturesLoc: string, + allowAdditional: boolean, +): ProtectedEntry[] { + const out: ProtectedEntry[] = []; + for (const [name, data] of entries) { + if (allowAdditional && name === signaturesLoc) continue; + out.push({ name, data }); + } + out.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)); + return out; +} diff --git a/packages/core/src/crypto/ofd-seal.ts b/packages/core/src/crypto/ofd-seal.ts new file mode 100644 index 0000000..947aaed --- /dev/null +++ b/packages/core/src/crypto/ofd-seal.ts @@ -0,0 +1,117 @@ +import * as asn1js from 'asn1js'; +import { parseDer, readSequence } from './der'; +import { + encodeSesSeal, + encodeSesSealInfo, + parseSesSeal, + type SesPicture, + type SesSealInfoInput, +} from './ses-v4'; +import { requestSm2Signature, type Sm2Signer } from './signer'; +import { sm2Verify } from './sm2'; +import { + parseSm2Certificate, + verifyCertificateChain, + type CertificateTrustResult, + type VerifyCertificateOptions, +} from './x509-sm2'; + +const OID_SM2_SIGN = '1.2.156.10197.1.501'; + +export interface CreateSesSealOptions { + info: SesSealInfoInput; + makerCertificate: Uint8Array; + signer: Sm2Signer; + userId?: string; + signal?: AbortSignal; +} + +export interface VerifySesSealOptions extends VerifyCertificateOptions { + userId?: string; +} + +export interface SesSealVerification { + valid: boolean; + signatureValid: boolean; + validity: boolean; + trust: CertificateTrustResult; + picture?: SesPicture; + reasons: string[]; +} + +function unknownCriticalExtensions(extensions?: Uint8Array): string[] { + if (!extensions) return []; + const unknown: string[] = []; + const sequence = readSequence(parseDer(extensions), 0, Number.MAX_SAFE_INTEGER); + for (const item of sequence.valueBlock.value) { + const extension = readSequence(item, 2, 3); + const [oid, ...rest] = extension.valueBlock.value; + if (!(oid instanceof asn1js.ObjectIdentifier)) throw new Error('SES extension OID expected'); + const critical = rest[0] instanceof asn1js.Boolean && rest[0].getValue(); + if (critical) unknown.push(oid.getValue()); + } + return unknown; +} + +export async function createSesSeal(options: CreateSesSealOptions): Promise { + if (options.info.header.id !== 'ES' || options.info.header.version !== 4) { + throw new Error('SES V4 header must use ID ES and version 4'); + } + const infoDer = encodeSesSealInfo(options.info); + const signedValue = await requestSm2Signature(options.signer, { + tbs: infoDer, + certificate: options.makerCertificate, + userId: options.userId, + signal: options.signal, + }); + return encodeSesSeal({ + info: { ...options.info, rawDer: infoDer }, + makerCertificate: options.makerCertificate, + signAlgId: OID_SM2_SIGN, + signedValue, + }); +} + +export async function verifySesSeal( + der: Uint8Array, + options: VerifySesSealOptions, +): Promise { + const reasons: string[] = []; + let trust: CertificateTrustResult = { + status: 'indeterminate', + chain: [], + reasons: [], + }; + try { + const seal = parseSesSeal(der); + const at = options.at ?? new Date(); + const certificate = parseSm2Certificate(seal.makerCertificate); + const signatureValid = + seal.signAlgId === OID_SM2_SIGN && + sm2Verify(certificate.publicKey, seal.info.rawDer, seal.signedValue, options.userId); + if (!signatureValid) reasons.push('SES maker signature is invalid'); + const validity = seal.info.property.validStart <= at && seal.info.property.validEnd >= at; + if (!validity) reasons.push('SES seal is outside its validity period'); + const unknown = unknownCriticalExtensions(seal.info.extensions); + if (unknown.length) reasons.push(`SES unknown critical extensions: ${unknown.join(',')}`); + trust = await verifyCertificateChain(seal.makerCertificate, options); + if (trust.status !== 'trusted') reasons.push(...trust.reasons); + return { + valid: signatureValid && validity && !unknown.length && trust.status === 'trusted', + signatureValid, + validity, + trust, + picture: seal.info.picture, + reasons, + }; + } catch (error) { + reasons.push(error instanceof Error ? error.message : String(error)); + return { + valid: false, + signatureValid: false, + validity: false, + trust, + reasons, + }; + } +} diff --git a/packages/core/src/crypto/ofd-sign.ts b/packages/core/src/crypto/ofd-sign.ts new file mode 100644 index 0000000..48009a4 --- /dev/null +++ b/packages/core/src/crypto/ofd-sign.ts @@ -0,0 +1,215 @@ +import { base64Encode } from './base64'; +import { + collectProtectedEntries, + readOfdPackage, + resolveOfdPath, + selectDocBody, +} from './ofd-package'; +import { buildSignatureXml, updateSignatureList } from './ofd-signature-xml'; +import { + encodeSesSignature, + encodeTbsSign, + parseSesSeal, +} from './ses-v4'; +import { requestSm2Signature, type Sm2Signer } from './signer'; +import { Sm3 } from './sm3'; +import { zipStore } from '../zip'; +import { kid, kids, parseXml } from '../xml-parse'; +import { fmt, utf8Encode } from '../utils'; + +const OID_SM2_SIGN = '1.2.156.10197.1.501'; +const OID_SM3 = '1.2.156.10197.1.401'; + +export interface SignOfdOptions { + eSeal: Uint8Array; + signingCertificate: Uint8Array; + signer: Sm2Signer; + stamp?: { page: number; x: number; y: number; w: number; h: number; clip?: string }; + allowAdditionalSignatures?: boolean; + docBodyIndex?: number; + provider?: { name: string; company?: string; version?: string }; + now?: () => Date; + userId?: string; + signal?: AbortSignal; +} + +function signatureDateTime(date: Date): string { + const pad = (value: number) => String(value).padStart(2, '0'); + return ( + `${date.getUTCFullYear()}${pad(date.getUTCMonth() + 1)}${pad(date.getUTCDate())}` + + `${pad(date.getUTCHours())}${pad(date.getUTCMinutes())}${pad(date.getUTCSeconds())}` + ); +} + +function repack(entries: Map): Uint8Array { + const files = [...entries].map(([name, data]) => ({ name, data })); + files.sort((a, b) => (a.name === 'OFD.xml' ? -1 : b.name === 'OFD.xml' ? 1 : 0)); + return zipStore(files); +} + +function addSignaturesEntry( + entries: Map, + docBodyIndex: number, + signaturesLoc: string, +): void { + const source = new TextDecoder().decode(entries.get('OFD.xml')); + const pattern = /<((?:[A-Za-z_][\w.-]*:)?)DocBody\b[^>]*>/g; + let match: RegExpExecArray | null; + let current = 0; + let selected: RegExpExecArray | null = null; + while ((match = pattern.exec(source))) { + if (current++ === docBodyIndex) { + selected = match; + break; + } + } + if (!selected) throw new Error(`OFD DocBody index out of range: ${docBodyIndex}`); + const prefix = selected[1]!; + const close = ``; + const closeAt = source.indexOf(close, selected.index + selected[0].length); + if (closeAt < 0) throw new Error('OFD DocBody closing tag is missing'); + const insertion = ` <${prefix}Signatures>${signaturesLoc}\n `; + const updated = source.slice(0, closeAt) + insertion + source.slice(closeAt); + entries.set('OFD.xml', utf8Encode(updated)); + const body = selectDocBody(entries, docBodyIndex); + if (body.signaturesLoc !== signaturesLoc) throw new Error('OFD signatures entry could not be added'); +} + +function signatureListEntries(entries: Map, signaturesLoc: string) { + const bytes = entries.get(signaturesLoc); + if (!bytes) return [] as { id: string; baseLoc: string }[]; + const root = parseXml(new TextDecoder().decode(bytes)); + return kids(root, 'Signature').map((signature) => ({ + id: signature.attrs.ID ?? '', + baseLoc: signature.attrs.BaseLoc ?? '', + })); +} + +function assertAdditionalSignatureAllowed( + entries: Map, + signaturesLoc: string, +): void { + for (const signature of signatureListEntries(entries, signaturesLoc)) { + const signaturePath = resolveOfdPath(signaturesLoc, signature.baseLoc); + const bytes = entries.get(signaturePath); + if (!bytes) throw new Error(`OFD existing signature is missing: ${signaturePath}`); + const root = parseXml(new TextDecoder().decode(bytes)); + const signedInfo = kid(root, 'SignedInfo'); + const references = signedInfo && kid(signedInfo, 'References'); + for (const reference of references ? kids(references, 'Reference') : []) { + const fileRef = reference.attrs.FileRef; + if (fileRef && resolveOfdPath('', fileRef) === signaturesLoc) { + throw new Error('OFD additional signatures are not allowed: the signature list is protected'); + } + } + } +} + +function nextSignatureLocation( + entries: Map, + signaturesLoc: string, +): { directory: string; id: string; maxSignId: string } { + const list = signatureListEntries(entries, signaturesLoc); + let maxNumericId = 0; + for (const item of list) { + const match = /^s?(\d+)$/i.exec(item.id); + if (match) maxNumericId = Math.max(maxNumericId, Number(match[1])); + } + const root = signaturesLoc.split('/').slice(0, -1).join('/'); + let maxDirectory = -1; + const expression = new RegExp(`^${root ? root + '/' : ''}Sign_(\\d+)/`); + for (const name of entries.keys()) { + const match = expression.exec(name); + if (match) maxDirectory = Math.max(maxDirectory, Number(match[1])); + } + const numericId = maxNumericId + 1; + return { + directory: `${root ? root + '/' : ''}Sign_${maxDirectory + 1}`, + id: `s${String(numericId).padStart(3, '0')}`, + maxSignId: `s${String(numericId + 1).padStart(3, '0')}`, + }; +} + +function pageIdAt(entries: Map, docRoot: string, page: number): string { + if (!Number.isInteger(page) || page < 1) throw new Error('OFD stamp page must be a positive integer'); + const bytes = entries.get(docRoot); + if (!bytes) throw new Error(`OFD document root is missing: ${docRoot}`); + const document = parseXml(new TextDecoder().decode(bytes)); + const pages = kid(document, 'Pages'); + const target = (pages ? kids(pages, 'Page') : [])[page - 1]; + if (!target?.attrs.ID) throw new Error(`OFD stamp page does not exist: ${page}`); + return target.attrs.ID; +} + +export async function signOfd( + bytes: Uint8Array | ArrayBuffer, + options: SignOfdOptions, +): Promise { + const input = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes); + const entries = await readOfdPackage(input); + parseSesSeal(options.eSeal); + const body = selectDocBody(entries, options.docBodyIndex ?? 0); + const signaturesLoc = body.signaturesLoc ?? 'Signs/Signatures.xml'; + assertAdditionalSignatureAllowed(entries, signaturesLoc); + if (!body.signaturesLoc) addSignaturesEntry(entries, body.index, signaturesLoc); + + const allowAdditional = options.allowAdditionalSignatures ?? true; + const location = nextSignatureLocation(entries, signaturesLoc); + const signaturePath = `${location.directory}/Signature.xml`; + const sealPath = `${location.directory}/Seal.esl`; + const signedValuePath = `${location.directory}/SignedValue.dat`; + const list = updateSignatureList({ + existing: entries.get(signaturesLoc), + entry: { id: location.id, type: 'Seal', baseLoc: `/${signaturePath}` }, + maxSignId: location.maxSignId, + }); + entries.set(signaturesLoc, list); + + const protectedEntries = collectProtectedEntries(entries, signaturesLoc, allowAdditional); + const now = options.now?.() ?? new Date(); + const signatureXml = buildSignatureXml({ + provider: options.provider ?? { name: 'jsOFD', version: '1.0' }, + signatureMethod: OID_SM2_SIGN, + signatureDateTime: signatureDateTime(now), + checkMethod: OID_SM3, + references: protectedEntries.map((entry) => ({ + fileRef: `/${entry.name}`, + checkValue: base64Encode(new Sm3().update(entry.data).digest()), + })), + stamp: options.stamp + ? { + pageRef: pageIdAt(entries, body.docRoot, options.stamp.page), + id: location.id, + boundary: `${fmt(options.stamp.x)} ${fmt(options.stamp.y)} ${fmt(options.stamp.w)} ${fmt(options.stamp.h)}`, + clip: options.stamp.clip, + } + : undefined, + sealLoc: `/${sealPath}`, + signedValueLoc: `/${signedValuePath}`, + }); + + const toSign = { + version: 4, + eseal: parseSesSeal(options.eSeal), + timeInfo: now, + dataHash: new Sm3().update(signatureXml).digest(), + propertyInfo: 'OFD', + }; + const signature = await requestSm2Signature(options.signer, { + tbs: encodeTbsSign(toSign), + certificate: options.signingCertificate, + userId: options.userId, + signal: options.signal, + }); + const signedValue = encodeSesSignature({ + toSign, + signingCertificate: options.signingCertificate, + signatureAlgId: OID_SM2_SIGN, + signature, + }); + + entries.set(signaturePath, signatureXml); + entries.set(sealPath, options.eSeal.slice()); + entries.set(signedValuePath, signedValue); + return repack(entries); +} diff --git a/packages/core/src/crypto/ofd-signature-xml.ts b/packages/core/src/crypto/ofd-signature-xml.ts new file mode 100644 index 0000000..ddea617 --- /dev/null +++ b/packages/core/src/crypto/ofd-signature-xml.ts @@ -0,0 +1,82 @@ +import { OFD_NAMESPACE } from '../xml'; +import { escapeXmlAttr, escapeXmlText, utf8Encode } from '../utils'; +import { kids, parseXml } from '../xml-parse'; + +export interface SignatureReference { + fileRef: string; + checkValue: string; +} + +export interface SignatureXmlInput { + provider: { name: string; company?: string; version?: string }; + signatureMethod: string; + signatureDateTime: string; + checkMethod: string; + references: SignatureReference[]; + stamp?: { pageRef: string; id: string; boundary: string; clip?: string }; + sealLoc?: string; + signedValueLoc: string; +} + +export interface SignatureListEntry { + id: string; + type: 'Seal' | 'Sign'; + baseLoc: string; +} + +export function buildSignatureXml(input: SignatureXmlInput): Uint8Array { + const provider = + `ProviderName="${escapeXmlAttr(input.provider.name)}"` + + (input.provider.company ? ` Company="${escapeXmlAttr(input.provider.company)}"` : '') + + (input.provider.version ? ` Version="${escapeXmlAttr(input.provider.version)}"` : ''); + let xml = `\n\n`; + xml += ` \n \n`; + xml += ` ${escapeXmlText(input.signatureMethod)}\n`; + xml += ` ${escapeXmlText(input.signatureDateTime)}\n`; + xml += ` \n`; + for (const reference of input.references) { + xml += ` ${escapeXmlText(reference.checkValue)}\n`; + } + xml += ' \n'; + if (input.stamp) { + xml += ` ${escapeXmlText(input.sealLoc)}\n`; + } + xml += ' \n'; + xml += ` ${escapeXmlText(input.signedValueLoc)}\n\n`; + return utf8Encode(xml); +} + +export function updateSignatureList(input: { + existing?: Uint8Array; + entry: SignatureListEntry; + maxSignId: string; +}): Uint8Array { + let prefix = 'ofd:'; + const entries: SignatureListEntry[] = []; + if (input.existing) { + const source = new TextDecoder().decode(input.existing); + prefix = /\n<${prefix}Signatures ${ns}>\n`; + for (const entry of entries) { + xml += ` <${prefix}Signature ID="${escapeXmlAttr(entry.id)}" Type="${escapeXmlAttr(entry.type)}" BaseLoc="${escapeXmlAttr(entry.baseLoc)}"/>\n`; + } + xml += ` <${prefix}MaxSignId>${escapeXmlText(input.maxSignId)}\n\n`; + return utf8Encode(xml); +} diff --git a/packages/core/src/crypto/ofd-verify.ts b/packages/core/src/crypto/ofd-verify.ts new file mode 100644 index 0000000..3fe3e7e --- /dev/null +++ b/packages/core/src/crypto/ofd-verify.ts @@ -0,0 +1,200 @@ +import { base64Encode } from './base64'; +import { readOfdPackage, resolveOfdPath, selectDocBody } from './ofd-package'; +import { verifySesSeal } from './ofd-seal'; +import { encodeSesSeal, parseSesSignature } from './ses-v4'; +import { sm2Verify } from './sm2'; +import { Sm3 } from './sm3'; +import { + parseSm2Certificate, + verifyCertificateChain, + type CertificateTrustResult, + type VerifyCertificateOptions, +} from './x509-sm2'; +import { kid, kids, parseXml, textOf } from '../xml-parse'; + +const OID_SM2_SIGN = '1.2.156.10197.1.501'; +const OID_SM3 = '1.2.156.10197.1.401'; + +export type SignatureStatus = 'valid' | 'invalid' | 'untrusted' | 'indeterminate'; + +export interface OfdSignatureVerification { + id: string; + referencesValid: boolean; + signatureValid: boolean; + sealSignatureValid: boolean; + signerTrust: CertificateTrustResult; + makerTrust: CertificateTrustResult; + status: SignatureStatus; + reasons: string[]; +} + +export interface OfdVerifyResult { + signatures: OfdSignatureVerification[]; + errors: string[]; +} + +export interface OfdVerifyOptions extends VerifyCertificateOptions { + userId?: string; + docBodyIndex?: number; +} + +function pendingTrust(): CertificateTrustResult { + return { status: 'indeterminate', chain: [], reasons: [] }; +} + +function sameBytes(a: Uint8Array, b: Uint8Array): boolean { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false; + return true; +} + +async function verifyOne( + entries: Map, + signaturesLoc: string, + item: { id: string; baseLoc: string }, + options: OfdVerifyOptions, +): Promise { + let referencesValid = false; + let signatureValid = false; + let sealSignatureValid = false; + let signerTrust = pendingTrust(); + let makerTrust = pendingTrust(); + const reasons: string[] = []; + let status: SignatureStatus | undefined; + try { + const signaturePath = resolveOfdPath(signaturesLoc, item.baseLoc); + const signatureXml = entries.get(signaturePath); + if (!signatureXml) throw new Error(`Signature.xml is missing: ${signaturePath}`); + const root = parseXml(new TextDecoder().decode(signatureXml)); + const signedInfo = kid(root, 'SignedInfo'); + if (!signedInfo) throw new Error('SES SignedInfo is missing'); + const references = kid(signedInfo, 'References'); + if (!references) throw new Error('SES References are missing'); + if (references.attrs.CheckMethod !== OID_SM3) { + throw new Error(`SES unsupported check method: ${references.attrs.CheckMethod}`); + } + referencesValid = true; + const seen = new Set(); + for (const reference of kids(references, 'Reference')) { + const fileRef = reference.attrs.FileRef; + if (!fileRef) throw new Error('SES Reference FileRef is missing'); + const path = resolveOfdPath('', fileRef); + if (seen.has(path)) throw new Error(`SES duplicate Reference: ${path}`); + seen.add(path); + const data = entries.get(path); + const checkValue = kid(reference, 'CheckValue'); + if (!data || !checkValue || base64Encode(new Sm3().update(data).digest()) !== textOf(checkValue)) { + referencesValid = false; + reasons.push(`SES Reference mismatch: ${path}`); + } + } + + const signedValueElement = kid(root, 'SignedValue'); + if (!signedValueElement) throw new Error('SES SignedValue location is missing'); + const signedValuePath = resolveOfdPath(signaturePath, textOf(signedValueElement)); + const signedValueBytes = entries.get(signedValuePath); + if (!signedValueBytes) throw new Error(`SES SignedValue.dat is missing: ${signedValuePath}`); + const signature = parseSesSignature(signedValueBytes); + const signingCertificate = parseSm2Certificate(signature.signingCertificate); + const hashMatches = sameBytes( + signature.toSign.dataHash, + new Sm3().update(signatureXml).digest(), + ); + if (!hashMatches) reasons.push('SES Signature.xml dataHash mismatch'); + signatureValid = + hashMatches && + signature.signatureAlgId === OID_SM2_SIGN && + sm2Verify( + signingCertificate.publicKey, + signature.toSign.rawDer, + signature.signature, + options.userId, + ); + signerTrust = await verifyCertificateChain(signature.signingCertificate, options); + + const seal = kid(signedInfo, 'Seal'); + const sealLoc = seal && kid(seal, 'BaseLoc'); + if (!sealLoc) throw new Error('SES Seal location is missing'); + const sealPath = resolveOfdPath(signaturePath, textOf(sealLoc)); + const sealBytes = entries.get(sealPath); + if (!sealBytes) throw new Error(`SES Seal.esl is missing: ${sealPath}`); + const sealResult = await verifySesSeal(sealBytes, options); + makerTrust = sealResult.trust; + const sealMatches = sameBytes(sealBytes, encodeSesSeal(signature.toSign.eseal)); + if (!sealMatches) reasons.push('SES Seal.esl does not match the SignedValue seal'); + sealSignatureValid = sealResult.signatureValid && sealMatches; + reasons.push(...sealResult.reasons); + + const signingUsageValid = + signingCertificate.keyUsage !== null && (signingCertificate.keyUsage & 3) !== 0; + if (!signingUsageValid) reasons.push('SES signing certificate lacks a signing KeyUsage'); + if (!sealResult.validity) reasons.push('SES seal is not valid at the verification time'); + if (signature.toSign.extensions) reasons.push('SES TBS extensions are not supported'); + + const trustStates = [signerTrust.status, makerTrust.status]; + const hardFailure = + !referencesValid || + !signatureValid || + !sealSignatureValid || + !sealResult.validity || + !signingUsageValid || + Boolean(signature.toSign.extensions) || + sealResult.reasons.some((reason) => /unknown critical/i.test(reason)); + if (trustStates.some((state) => state === 'expired' || state === 'revoked')) { + status = 'invalid'; + } else if (trustStates.includes('untrusted')) { + status = 'untrusted'; + } else if (hardFailure) { + status = 'invalid'; + } else if (trustStates.includes('indeterminate')) { + status = 'indeterminate'; + } else { + status = 'valid'; + } + } catch (error) { + reasons.push(error instanceof Error ? error.message : String(error)); + status = 'invalid'; + } + return { + id: item.id, + referencesValid, + signatureValid, + sealSignatureValid, + signerTrust, + makerTrust, + status: status ?? 'invalid', + reasons, + }; +} + +export async function verifyOfd( + bytes: Uint8Array | ArrayBuffer, + options: OfdVerifyOptions, +): Promise { + try { + const input = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes); + const entries = await readOfdPackage(input); + const body = selectDocBody(entries, options.docBodyIndex ?? 0); + if (!body.signaturesLoc || !entries.has(body.signaturesLoc)) { + return { signatures: [], errors: ['OFD package contains no signatures'] }; + } + const list = parseXml(new TextDecoder().decode(entries.get(body.signaturesLoc))); + const signatures = []; + for (const signature of kids(list, 'Signature')) { + signatures.push( + await verifyOne( + entries, + body.signaturesLoc, + { id: signature.attrs.ID ?? '', baseLoc: signature.attrs.BaseLoc ?? '' }, + options, + ), + ); + } + return { signatures, errors: [] }; + } catch (error) { + return { + signatures: [], + errors: [error instanceof Error ? error.message : String(error)], + }; + } +} diff --git a/packages/core/src/crypto/revocation.ts b/packages/core/src/crypto/revocation.ts new file mode 100644 index 0000000..16b99c4 --- /dev/null +++ b/packages/core/src/crypto/revocation.ts @@ -0,0 +1,9 @@ +export type RevocationStatus = 'good' | 'revoked' | 'unknown'; + +export interface RevocationProvider { + check( + certificate: Uint8Array, + issuer: Uint8Array, + at: Date, + ): Promise; +} diff --git a/packages/core/src/crypto/ses-v4.ts b/packages/core/src/crypto/ses-v4.ts new file mode 100644 index 0000000..2d421ac --- /dev/null +++ b/packages/core/src/crypto/ses-v4.ts @@ -0,0 +1,298 @@ +import * as asn1js from 'asn1js'; +import { parseDer, readSequence } from './der'; + +export interface SesHeader { + id: string; + version: number; + vid: string; +} + +export interface SesProperty { + type: number; + name: string; + certList: Uint8Array[]; + createDate: Date; + validStart: Date; + validEnd: Date; +} + +export interface SesPicture { + type: string; + data: Uint8Array; + width: number; + height: number; +} + +export interface SesSealInfo { + header: SesHeader; + esId: string; + property: SesProperty; + picture: SesPicture; + extensions?: Uint8Array; + rawDer: Uint8Array; +} + +export type SesSealInfoInput = Omit & { rawDer?: Uint8Array }; + +export interface SesSeal { + info: SesSealInfo; + makerCertificate: Uint8Array; + signAlgId: string; + signedValue: Uint8Array; +} + +export type SesSealInput = Omit & { info: SesSealInfoInput }; + +export interface SesTbsSign { + version: number; + eseal: SesSeal; + timeInfo: Date; + dataHash: Uint8Array; + propertyInfo: string; + extensions?: Uint8Array; + rawDer: Uint8Array; +} + +export type SesTbsSignInput = Omit & { + eseal: SesSealInput; + rawDer?: Uint8Array; +}; + +export interface SesSignature { + toSign: SesTbsSign; + signingCertificate: Uint8Array; + signatureAlgId: string; + signature: Uint8Array; + timeStamp?: Uint8Array; +} + +export type SesSignatureInput = Omit & { toSign: SesTbsSignInput }; + +function raw(block: asn1js.BaseBlock): Uint8Array { + return block.valueBeforeDecodeView.slice(); +} + +function hex(block: asn1js.BaseBlock): Uint8Array { + return (block.valueBlock as unknown as { valueHexView: Uint8Array }).valueHexView.slice(); +} + +function tagged(block: asn1js.BaseBlock, tag: number): boolean { + return block.idBlock.tagClass === 3 && block.idBlock.tagNumber === tag; +} + +function seq(...items: asn1js.BaseBlock[]): asn1js.Sequence { + return new asn1js.Sequence({ value: items }); +} + +function ia5(value: string): asn1js.IA5String { + return new asn1js.IA5String({ value }); +} + +function utf8(value: string): asn1js.Utf8String { + return new asn1js.Utf8String({ value }); +} + +function integer(value: number): asn1js.Integer { + return asn1js.Integer.fromBigInt(value); +} + +function octets(value: Uint8Array): asn1js.OctetString { + return new asn1js.OctetString({ valueHex: value }); +} + +function bits(value: Uint8Array): asn1js.BitString { + return new asn1js.BitString({ valueHex: value }); +} + +function oid(value: string): asn1js.ObjectIdentifier { + return new asn1js.ObjectIdentifier({ value }); +} + +function encoded(block: asn1js.BaseBlock): Uint8Array { + return new Uint8Array(block.toBER()); +} + +function stringField(block: asn1js.BaseBlock, expected: 'ia5' | 'utf8'): string { + if (expected === 'ia5' && !(block instanceof asn1js.IA5String)) throw new Error('SES IA5String expected'); + if (expected === 'utf8' && !(block instanceof asn1js.Utf8String)) throw new Error('SES UTF8String expected'); + return (block as asn1js.BaseStringBlock).getValue(); +} + +function integerField(block: asn1js.BaseBlock): number { + if (!(block instanceof asn1js.Integer)) throw new Error('SES INTEGER expected'); + return Number(block.toBigInt()); +} + +function timeField(block: asn1js.BaseBlock, generalized = false): Date { + if (generalized && !(block instanceof asn1js.GeneralizedTime)) { + throw new Error('SES GeneralizedTime expected'); + } + if (!generalized && !(block instanceof asn1js.UTCTime) && !(block instanceof asn1js.GeneralizedTime)) { + throw new Error('SES time expected'); + } + return (block as asn1js.UTCTime).toDate(); +} + +function parseHeader(block: asn1js.BaseBlock): SesHeader { + const fields = readSequence(block, 3, 3).valueBlock.value; + return { + id: stringField(fields[0]!, 'ia5'), + version: integerField(fields[1]!), + vid: stringField(fields[2]!, 'ia5'), + }; +} + +function encodeHeader(header: SesHeader): asn1js.Sequence { + return seq(ia5(header.id), integer(header.version), ia5(header.vid)); +} + +function parseProperty(block: asn1js.BaseBlock): SesProperty { + const fields = readSequence(block, 6, 6).valueBlock.value; + const certList = readSequence(fields[2]!, 0, Number.MAX_SAFE_INTEGER).valueBlock.value.map((cert) => { + if (!(cert instanceof asn1js.OctetString)) throw new Error('SES certificate OCTET STRING expected'); + return hex(cert); + }); + return { + type: integerField(fields[0]!), + name: stringField(fields[1]!, 'utf8'), + certList, + createDate: timeField(fields[3]!), + validStart: timeField(fields[4]!), + validEnd: timeField(fields[5]!), + }; +} + +function encodeProperty(property: SesProperty): asn1js.Sequence { + return seq( + integer(property.type), + utf8(property.name), + seq(...property.certList.map(octets)), + new asn1js.UTCTime({ valueDate: property.createDate }), + new asn1js.UTCTime({ valueDate: property.validStart }), + new asn1js.UTCTime({ valueDate: property.validEnd }), + ); +} + +function parsePicture(block: asn1js.BaseBlock): SesPicture { + const fields = readSequence(block, 4, 4).valueBlock.value; + if (!(fields[1] instanceof asn1js.OctetString)) throw new Error('SES picture OCTET STRING expected'); + return { + type: stringField(fields[0]!, 'ia5'), + data: hex(fields[1]), + width: integerField(fields[2]!), + height: integerField(fields[3]!), + }; +} + +function encodePicture(picture: SesPicture): asn1js.Sequence { + return seq(ia5(picture.type), octets(picture.data), integer(picture.width), integer(picture.height)); +} + +function parseSealInfo(block: asn1js.BaseBlock): SesSealInfo { + const fields = readSequence(block, 4, 5).valueBlock.value; + return { + header: parseHeader(fields[0]!), + esId: stringField(fields[1]!, 'ia5'), + property: parseProperty(fields[2]!), + picture: parsePicture(fields[3]!), + extensions: fields[4] ? raw(fields[4]) : undefined, + rawDer: raw(block), + }; +} + +export function encodeSesSealInfo(info: SesSealInfoInput): Uint8Array { + return encoded( + seq( + encodeHeader(info.header), + ia5(info.esId), + encodeProperty(info.property), + encodePicture(info.picture), + ...(info.extensions ? [parseDer(info.extensions)] : []), + ), + ); +} + +export function parseSesSeal(der: Uint8Array): SesSeal { + const fields = readSequence(parseDer(der), 4, 4).valueBlock.value; + if (!(fields[1] instanceof asn1js.OctetString) || !(fields[2] instanceof asn1js.ObjectIdentifier) || !(fields[3] instanceof asn1js.BitString)) { + throw new Error('SES invalid seal fields'); + } + return { + info: parseSealInfo(fields[0]!), + makerCertificate: hex(fields[1]), + signAlgId: fields[2].getValue(), + signedValue: hex(fields[3]), + }; +} + +export function encodeSesSeal(seal: SesSealInput): Uint8Array { + const info = parseDer(encodeSesSealInfo(seal.info)); + return encoded(seq(info, octets(seal.makerCertificate), oid(seal.signAlgId), bits(seal.signedValue))); +} + +function parseTbsSign(block: asn1js.BaseBlock): SesTbsSign { + const fields = readSequence(block, 5, 6).valueBlock.value; + if (!(fields[2] instanceof asn1js.GeneralizedTime) || !(fields[3] instanceof asn1js.BitString)) { + throw new Error('SES invalid TBS fields'); + } + const sealDer = raw(fields[1]!); + return { + version: integerField(fields[0]!), + eseal: parseSesSeal(sealDer), + timeInfo: timeField(fields[2], true), + dataHash: hex(fields[3]), + propertyInfo: stringField(fields[4]!, 'ia5'), + extensions: fields[5] && tagged(fields[5], 0) + ? raw((fields[5].valueBlock as unknown as { value: asn1js.BaseBlock[] }).value[0]!) + : undefined, + rawDer: raw(block), + }; +} + +export function encodeTbsSign(tbs: SesTbsSignInput): Uint8Array { + const items: asn1js.BaseBlock[] = [ + integer(tbs.version), + parseDer(encodeSesSeal(tbs.eseal)), + new asn1js.GeneralizedTime({ valueDate: tbs.timeInfo }), + bits(tbs.dataHash), + ia5(tbs.propertyInfo), + ]; + if (tbs.extensions) { + items.push(new asn1js.Constructed({ idBlock: { tagClass: 3, tagNumber: 0 }, value: [parseDer(tbs.extensions)] })); + } + return encoded(seq(...items)); +} + +export function parseSesSignature(der: Uint8Array): SesSignature { + const fields = readSequence(parseDer(der), 4, 5).valueBlock.value; + if (!(fields[1] instanceof asn1js.OctetString) || !(fields[2] instanceof asn1js.ObjectIdentifier) || !(fields[3] instanceof asn1js.BitString)) { + throw new Error('SES invalid signature fields'); + } + let timeStamp: Uint8Array | undefined; + if (fields[4]) { + if (!tagged(fields[4], 0)) throw new Error('SES invalid timestamp tag'); + const content = (fields[4].valueBlock as unknown as { value: asn1js.BaseBlock[] }).value[0]; + if (!(content instanceof asn1js.BitString)) throw new Error('SES timestamp BIT STRING expected'); + timeStamp = hex(content); + } + return { + toSign: parseTbsSign(fields[0]!), + signingCertificate: hex(fields[1]), + signatureAlgId: fields[2].getValue(), + signature: hex(fields[3]), + timeStamp, + }; +} + +export function encodeSesSignature(signature: SesSignatureInput): Uint8Array { + const items: asn1js.BaseBlock[] = [ + parseDer(encodeTbsSign(signature.toSign)), + octets(signature.signingCertificate), + oid(signature.signatureAlgId), + bits(signature.signature), + ]; + if (signature.timeStamp) { + items.push(new asn1js.Constructed({ idBlock: { tagClass: 3, tagNumber: 0 }, value: [bits(signature.timeStamp)] })); + } + return encoded(seq(...items)); +} diff --git a/packages/core/src/crypto/signer.ts b/packages/core/src/crypto/signer.ts new file mode 100644 index 0000000..d6961c5 --- /dev/null +++ b/packages/core/src/crypto/signer.ts @@ -0,0 +1,59 @@ +import { sm2Sign, sm2SignatureFromDer, sm2SignatureToDer } from './sm2'; + +export interface Sm2SignRequest { + tbs: Uint8Array; + certificate: Uint8Array; + userId?: string; + signal?: AbortSignal; +} + +export interface Sm2Signer { + sign(request: Sm2SignRequest): Promise; +} + +function abortError(): Error { + const error = new Error('SM2 signing was aborted'); + error.name = 'AbortError'; + return error; +} + +export async function requestSm2Signature( + signer: Sm2Signer, + request: Sm2SignRequest, + timeoutMs = 30000, +): Promise { + if (request.signal?.aborted) throw abortError(); + const controller = new AbortController(); + const onAbort = () => controller.abort(request.signal?.reason); + request.signal?.addEventListener('abort', onAbort, { once: true }); + let timer: ReturnType | undefined; + try { + const work = signer.sign({ ...request, signal: controller.signal }); + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => { + reject(new Error(`SM2 signing timed out after ${timeoutMs}ms`)); + controller.abort(); + }, timeoutMs); + }); + const aborted = new Promise((_, reject) => { + controller.signal.addEventListener('abort', () => reject(abortError()), { once: true }); + }); + const signature = await Promise.race([work, timeout, aborted]); + sm2SignatureFromDer(signature); + return signature; + } finally { + if (timer !== undefined) clearTimeout(timer); + request.signal?.removeEventListener('abort', onAbort); + } +} + +/** Test-only adapter. Production private keys should remain in a UKey, KMS, HSM, or signing service. */ +export function createLocalTestSm2Signer(privateKey: Uint8Array, userId?: string): Sm2Signer { + const key = privateKey.slice(); + return { + async sign(request) { + if (request.signal?.aborted) throw abortError(); + return sm2SignatureToDer(sm2Sign(key, request.tbs, request.userId ?? userId)); + }, + }; +} diff --git a/packages/core/src/crypto/sm-crypto.d.ts b/packages/core/src/crypto/sm-crypto.d.ts new file mode 100644 index 0000000..8de232d --- /dev/null +++ b/packages/core/src/crypto/sm-crypto.d.ts @@ -0,0 +1,17 @@ +declare module 'sm-crypto' { + const smCrypto: { + sm4: { + encrypt( + data: Uint8Array, + key: Uint8Array, + options: { mode: 'cbc'; iv: Uint8Array; padding: 'none'; output: 'array' }, + ): number[]; + decrypt( + data: Uint8Array, + key: Uint8Array, + options: { mode: 'cbc'; iv: Uint8Array; padding: 'none'; output: 'array' }, + ): number[]; + }; + }; + export default smCrypto; +} diff --git a/packages/core/src/crypto/sm2.ts b/packages/core/src/crypto/sm2.ts new file mode 100644 index 0000000..769ab43 --- /dev/null +++ b/packages/core/src/crypto/sm2.ts @@ -0,0 +1,252 @@ +/** + * SM2 digital signature (GB/T 32918-2016) over the sm2p256v1 curve. + * + * Pure TypeScript on BigInt field arithmetic. Exposes key generation, sign, + * verify, and conversions between raw r‖s and DER signatures. + */ + +import * as asn1js from 'asn1js'; +import { Sm3, kdf, hexToBytes, bytesToHex } from './sm3'; +import { derSequence, parseDer, readSequence } from './der'; + +/* ---- sm2p256v1 curve parameters (GB/T 32918 §5) ---- */ +const P = BigInt('0xFFFFFFFEFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF00000000FFFFFFFFFFFFFFFF'); +const A = BigInt('0xFFFFFFFEFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF00000000FFFFFFFFFFFFFFFC'); +const B = BigInt('0x28E9FA9E9D9F5E344D5A9E4BCF6509A7F39789F515AB8F92DDBCBD414D940E93'); +const N = BigInt('0xFFFFFFFEFFFFFFFFFFFFFFFFFFFFFFFF7203DF6B21C6052B53BBF40939D54123'); +const GX = BigInt('0x32C4AE2C1F1981195F9904466A39C9948FE30BBFF2660BE1715A4589334C74C7'); +const GY = BigInt('0xBC3736A2F4F6779C59BDCEE36B692153D0A9877CC62A474002DF32E52139F0A0'); + +export interface Sm2Point { + x: bigint; + y: bigint; +} +export interface Sm2KeyPair { + /** Private scalar d as 32-byte big-endian. */ + privateKey: Uint8Array; + /** Public key P = [d]G as uncompressed point (0x04 ‖ x ‖ y). */ + publicKey: Uint8Array; +} +export interface Sm2Signature { + r: Uint8Array; + s: Uint8Array; +} + +const mod = (n: bigint, m: bigint = P): bigint => ((n % m) + m) % m; + +/** Modular inverse modulo a prime via extended Euclid. */ +function inverse(n: bigint, m: bigint = P): bigint { + let [a, b] = [mod(n, m), m]; + // 不变量:a ≡ x0·n(mod m),b ≡ x1·n(mod m) + let [x0, x1] = [1n, 0n]; + while (b !== 0n) { + const q = a / b; + [a, b] = [b, a - q * b]; + [x0, x1] = [x1, x0 - q * x1]; + } + return mod(x0, m); +} + +/** Affine point; null is the point at infinity. */ +type Affine = Sm2Point | null; + +function addPoints(p: Affine, q: Sm2Point): Affine { + if (!p) return q; + if (p.x === q.x) { + if (mod(p.y + q.y) === 0n) return null; // p + (−p) + return doublePoint(p); + } + const slope = mod((q.y - p.y) * inverse(mod(q.x - p.x))); + const x = mod(slope * slope - p.x - q.x); + const y = mod(slope * (p.x - x) - p.y); + return { x, y }; +} + +function doublePoint(p: Sm2Point): Affine { + if (mod(p.y) === 0n) return null; + const slope = mod((3n * p.x * p.x + A) * inverse(mod(2n * p.y))); + const x = mod(slope * slope - 2n * p.x); + const y = mod(slope * (p.x - x) - p.y); + return { x, y }; +} + +/** Elliptic-curve scalar multiplication [k]P. Returns null for [0]P. */ +export function sm2ScalarMult(k: bigint, p: Sm2Point): Affine { + let result: Affine = null; + let addend: Sm2Point = p; + let scalar = mod(k, N); + while (scalar > 0n) { + if (scalar & 1n) result = addPoints(result, addend); + addend = doublePoint(addend) ?? { x: 0n, y: 0n }; + scalar >>= 1n; + } + return result; +} + +export function sm2IsOnCurve(p: Sm2Point): boolean { + return mod(p.y * p.y) === mod(p.x * p.x * p.x + A * p.x + B); +} + +const pad32 = (hex: string): Uint8Array => hexToBytes(hex.padStart(64, '0')); +const bigTo32 = (n: bigint): Uint8Array => pad32(n.toString(16)); +const bytesToBig = (b: Uint8Array): bigint => BigInt('0x' + (bytesToHex(b) || '0')); +const pubToBytes = (p: Sm2Point): Uint8Array => { + const out = new Uint8Array(65); + out[0] = 0x04; + out.set(bigTo32(p.x), 1); + out.set(bigTo32(p.y), 33); + return out; +}; + +/* ---- user identity / ZA per GB/T 32918 §5.5 ---- */ + +/** ZA = SM3(ENTL ‖ ID ‖ a ‖ b ‖ xG ‖ yG ‖ xA ‖ yA). */ +export function za(pub: Uint8Array, userId: Uint8Array | string = '1234567812345678'): Uint8Array { + const id = typeof userId === 'string' ? new TextEncoder().encode(userId) : userId; + const entl = new Uint8Array([(id.length * 8) >> 8, (id.length * 8) & 0xff]); + const fields = [ + hexToBytes(A.toString(16).padStart(64, '0')), + hexToBytes(B.toString(16).padStart(64, '0')), + hexToBytes(GX.toString(16).padStart(64, '0')), + hexToBytes(GY.toString(16).padStart(64, '0')), + pub.subarray(1, 33), + pub.subarray(33, 65), + ]; + const abxy = new Uint8Array(192); + let off = 0; + for (const f of fields) { + abxy.set(f, off); + off += f.length; + } + return new Sm3().update(entl).update(id).update(abxy).digest(); +} + +/* ---- public API ---- */ + +function randomScalar(maxInclusive: bigint): bigint { + for (;;) { + const bytes = new Uint8Array(32); + crypto.getRandomValues(bytes); + const candidate = bytesToBig(bytes); + if (candidate < maxInclusive) return candidate + 1n; + } +} + +/** Generate an SM2 key pair. */ +export function sm2Generate(): Sm2KeyPair { + for (;;) { + const d = randomScalar(N - 2n); + const pub = sm2ScalarMult(d, { x: GX, y: GY }); + if (pub) return { privateKey: bigTo32(d), publicKey: pubToBytes(pub) }; + } +} + +/** + * Sign a message (SM2, default ID '1234567812345678'). + * Returns the 64-byte r‖s signature. + */ +export function sm2Sign( + privateKey: Uint8Array, + message: Uint8Array, + userId: Uint8Array | string = '1234567812345678', +): Sm2Signature { + for (;;) { + const sig = sm2SignWithK(privateKey, message, randomScalar(N - 1n), userId); + if (sig) return sig; + } +} + +/** Deterministic signing with a caller-provided nonce k. */ +export function sm2SignWithK( + privateKey: Uint8Array, + message: Uint8Array, + k: bigint, + userId: Uint8Array | string = '1234567812345678', +): Sm2Signature | null { + const d = bytesToBig(privateKey); + if (privateKey.length !== 32 || d < 1n || d >= N - 1n) { + throw new Error('sm2: private key out of range'); + } + if (k < 1n || k >= N) throw new Error('sm2: nonce out of range'); + const pub = sm2ScalarMult(d, { x: GX, y: GY }); + if (!pub) throw new Error('sm2: invalid private key'); + const z = za(pubToBytes(pub), userId); + const e = bytesToBig(new Sm3().update(z).update(message).digest()); + + const point = sm2ScalarMult(k, { x: GX, y: GY }); + if (!point) return null; + const r = mod(e + point.x, N); + if (r === 0n || r + k === N) return null; + const s = mod(inverse(1n + d, N) * (k - r * d), N); + if (s === 0n) return null; + return { r: bigTo32(r), s: bigTo32(s) }; +} + +export function sm2SignatureToDer(sig: Sm2Signature): Uint8Array { + if (sig.r.length !== 32 || sig.s.length !== 32) { + throw new Error('sm2: signature components must be 32 bytes'); + } + const r = bytesToBig(sig.r); + const s = bytesToBig(sig.s); + if (r < 1n || r >= N || s < 1n || s >= N) throw new Error('sm2: signature out of range'); + return derSequence([asn1js.Integer.fromBigInt(r), asn1js.Integer.fromBigInt(s)]); +} + +export function sm2SignatureFromDer(der: Uint8Array): Sm2Signature { + const sequence = readSequence(parseDer(der, { maxBytes: 80 }), 2, 2); + const [rBlock, sBlock] = sequence.valueBlock.value; + if (!(rBlock instanceof asn1js.Integer) || !(sBlock instanceof asn1js.Integer)) { + throw new Error('sm2: DER signature must contain two INTEGER values'); + } + const r = rBlock.toBigInt(); + const s = sBlock.toBigInt(); + if (r < 1n || r >= N || s < 1n || s >= N) throw new Error('sm2: signature out of range'); + return { r: bigTo32(r), s: bigTo32(s) }; +} + +/** Verify a raw 64-byte r‖s or DER signature over a message. */ +export function sm2Verify( + publicKey: Uint8Array, + message: Uint8Array, + sig: Sm2Signature | Uint8Array, + userId: Uint8Array | string = '1234567812345678', +): boolean { + if (publicKey.length !== 65 || publicKey[0] !== 0x04) return false; + let parts: Sm2Signature; + if (sig instanceof Uint8Array) { + if (sig.length === 64) { + parts = { r: sig.subarray(0, 32), s: sig.subarray(32, 64) }; + } else { + try { + parts = sm2SignatureFromDer(sig); + } catch { + return false; + } + } + } else { + parts = sig; + } + if (parts.r.length !== 32 || parts.s.length !== 32) return false; + const r = bytesToBig(parts.r); + const s = bytesToBig(parts.s); + if (r < 1n || r >= N || s < 1n || s >= N) return false; + + const point = { + x: bytesToBig(publicKey.subarray(1, 33)), + y: bytesToBig(publicKey.subarray(33, 65)), + }; + if (point.x >= P || point.y >= P || !sm2IsOnCurve(point)) return false; + + const z = za(publicKey, userId); + const e = bytesToBig(new Sm3().update(z).update(message).digest()); + const t = mod(r + s, N); + if (t === 0n) return false; + const sG = sm2ScalarMult(s, { x: GX, y: GY }); + const tP = sm2ScalarMult(t, point); + if (!sG || !tP) return false; + const sum = addPoints(sG, tP); + if (!sum) return false; + return mod(e + sum.x, N) === r; +} + +export { Sm3, kdf, bytesToHex, hexToBytes }; diff --git a/packages/core/src/crypto/sm3.ts b/packages/core/src/crypto/sm3.ts new file mode 100644 index 0000000..6005730 --- /dev/null +++ b/packages/core/src/crypto/sm3.ts @@ -0,0 +1,148 @@ +/** + * SM3 cryptographic hash (GB/T 32905-2016). + * + * Pure TypeScript, Uint32 core — no dependencies. Output is a 256-bit digest. + */ + +const IV = [ + 0x7380166f, 0x4914b2b9, 0x172442d7, 0xda8a0600, 0xa96f30bc, 0x163138aa, 0xe38dee4d, 0xb0fb0e4e, +]; +const T0 = 0x79cc4519; +const T1 = 0x7a879d8a; + +const rotl = (x: number, n: number): number => ((x << n) | (x >>> (32 - n))) >>> 0; + +function p0(x: number): number { + return (x ^ rotl(x, 9) ^ rotl(x, 17)) >>> 0; +} +function p1(x: number): number { + return (x ^ rotl(x, 15) ^ rotl(x, 23)) >>> 0; +} + +/** Compress one 512-bit block into the state. */ +function compress(state: Uint32Array, block: Uint8Array, blockOff: number): void { + const w = new Uint32Array(68); + const w1 = new Uint32Array(64); + for (let i = 0; i < 16; i++) { + const at = blockOff + i * 4; + w[i] = + ((block[at]! << 24) | (block[at + 1]! << 16) | (block[at + 2]! << 8) | block[at + 3]!) >>> 0; + } + for (let j = 16; j < 68; j++) { + w[j] = + (p1(w[j - 16]! ^ w[j - 9]! ^ rotl(w[j - 3]!, 15)) ^ rotl(w[j - 13]!, 7) ^ w[j - 6]!) >>> 0; + } + for (let j = 0; j < 64; j++) w1[j] = (w[j]! ^ w[j + 4]!) >>> 0; + + let [a, b, c, d, e, f, g, h] = state; + for (let j = 0; j < 64; j++) { + const t = j < 16 ? T0 : T1; + const ss1 = rotl((rotl(a, 12) + e + rotl(t, j % 32)) >>> 0, 7); + const ss2 = (ss1 ^ rotl(a, 12)) >>> 0; + const ff = j < 16 ? a ^ b ^ c : (a & b) | (a & c) | (b & c); + const gg = j < 16 ? e ^ f ^ g : (e & f) | (~e & g); + const tt1 = (ff + d + ss2 + w1[j]!) >>> 0; + const tt2 = (gg + h + ss1 + w[j]!) >>> 0; + d = c; + c = rotl(b, 9); + b = a; + a = tt1; + h = g; + g = rotl(f, 19); + f = e; + e = p0(tt2); + } + const next = [a, b, c, d, e, f, g, h]; + for (let i = 0; i < 8; i++) state[i] = (state[i]! ^ next[i]!) >>> 0; +} + +export class Sm3 { + private state = new Uint32Array(IV); + private buffer = new Uint8Array(64); + private buffered = 0; + private total = 0; + + update(data: Uint8Array | string): this { + const bytes = typeof data === 'string' ? new TextEncoder().encode(data) : data; + this.total += bytes.length; + let pos = 0; + if (this.buffered) { + const need = 64 - this.buffered; + const take = Math.min(need, bytes.length); + this.buffer.set(bytes.subarray(0, take), this.buffered); + this.buffered += take; + pos = take; + if (this.buffered === 64) { + compress(this.state, this.buffer, 0); + this.buffered = 0; + } + } + for (; pos + 64 <= bytes.length; pos += 64) compress(this.state, bytes, pos); + if (pos < bytes.length) { + this.buffer.set(bytes.subarray(pos), 0); + this.buffered = bytes.length - pos; + } + return this; + } + + digest(): Uint8Array { + const bitLenHi = Math.floor((this.total / 0x20000000) >>> 0); + const bitLenLo = (this.total << 3) >>> 0; + const pad = new Uint8Array(this.buffered < 56 ? 64 - this.buffered : 128 - this.buffered); + pad[0] = 0x80; + const tail = pad.length - 8; + const dv = new DataView(pad.buffer); + dv.setUint32(tail, bitLenHi >>> 0); + dv.setUint32(tail + 4, bitLenLo >>> 0); + const final = new Uint8Array(this.buffered + pad.length); + final.set(this.buffer.subarray(0, this.buffered)); + final.set(pad, this.buffered); + const state = Uint32Array.from(this.state); + for (let off = 0; off < final.length; off += 64) compress(state, final, off); + + const out = new Uint8Array(32); + for (let i = 0; i < 8; i++) { + out[i * 4] = state[i]! >>> 24; + out[i * 4 + 1] = (state[i]! >>> 16) & 0xff; + out[i * 4 + 2] = (state[i]! >>> 8) & 0xff; + out[i * 4 + 3] = state[i]! & 0xff; + } + return out; + } +} + +/** SM3 digest of a string (UTF-8) or bytes, hex-encoded. */ +export function sm3(data: Uint8Array | string, hex = true): string | Uint8Array { + const out = new Sm3().update(data).digest(); + return hex ? Array.from(out, (b) => b.toString(16).padStart(2, '0')).join('') : out; +} + +/** SM3-based key derivation per GB/T 32918 §5.4.3. */ +export function kdf(z: Uint8Array, klenBytes: number): Uint8Array { + const out = new Uint8Array(klenBytes); + const ct = new Uint8Array(4); + let off = 0; + for (let ctv = 1; off < klenBytes; ctv++) { + ct[0] = (ctv >>> 24) & 0xff; + ct[1] = (ctv >>> 16) & 0xff; + ct[2] = (ctv >>> 8) & 0xff; + ct[3] = ctv & 0xff; + const h = new Sm3().update(z).update(ct).digest(); + const take = Math.min(32, klenBytes - off); + out.set(h.subarray(0, take), off); + off += take; + } + return out; +} + +export const hexToBytes = (hex: string): Uint8Array => { + if (!/^[0-9a-fA-F]*$/.test(hex)) throw new Error('Invalid hexadecimal input'); + const clean = hex.length % 2 ? '0' + hex : hex; + const out = new Uint8Array(clean.length / 2); + for (let i = 0; i < out.length; i++) out[i] = parseInt(clean.slice(i * 2, i * 2 + 2), 16); + return out; +}; + +export const bytesToBig = (b: Uint8Array): bigint => BigInt('0x' + (bytesToHex(b) || '0')); +export const bytesToHex = (b: Uint8Array): string => + Array.from(b, (x) => x.toString(16).padStart(2, '0')).join(''); diff --git a/packages/core/src/crypto/sm4.ts b/packages/core/src/crypto/sm4.ts new file mode 100644 index 0000000..55a6305 --- /dev/null +++ b/packages/core/src/crypto/sm4.ts @@ -0,0 +1,35 @@ +import smCrypto from 'sm-crypto'; + +const { sm4 } = smCrypto; + +function validate(key: Uint8Array, iv: Uint8Array): void { + if (key.length !== 16) throw new Error('SM4 key must be 16 bytes'); + if (iv.length !== 16) throw new Error('SM4 IV must be 16 bytes'); +} + +export function sm4CbcEncrypt(plaintext: Uint8Array, key: Uint8Array, iv: Uint8Array): Uint8Array { + validate(key, iv); + const padding = 16 - (plaintext.length % 16); + const padded = new Uint8Array(plaintext.length + padding); + padded.set(plaintext); + padded.fill(padding, plaintext.length); + return Uint8Array.from(sm4.encrypt(padded, key, { mode: 'cbc', iv, padding: 'none', output: 'array' })); +} + +export function sm4CbcDecrypt(ciphertext: Uint8Array, key: Uint8Array, iv: Uint8Array): Uint8Array { + validate(key, iv); + if (!ciphertext.length || ciphertext.length % 16) { + throw new Error('SM4 ciphertext must contain complete blocks'); + } + const padded = Uint8Array.from( + sm4.decrypt(ciphertext, key, { mode: 'cbc', iv, padding: 'none', output: 'array' }), + ); + const padding = padded[padded.length - 1]!; + if (padding < 1 || padding > 16 || padding > padded.length) { + throw new Error('SM4 invalid PKCS#7 padding'); + } + for (let i = padded.length - padding; i < padded.length; i++) { + if (padded[i] !== padding) throw new Error('SM4 invalid PKCS#7 padding'); + } + return padded.slice(0, padded.length - padding); +} diff --git a/packages/core/src/crypto/x509-sm2.ts b/packages/core/src/crypto/x509-sm2.ts new file mode 100644 index 0000000..9447e92 --- /dev/null +++ b/packages/core/src/crypto/x509-sm2.ts @@ -0,0 +1,355 @@ +import * as asn1js from 'asn1js'; +import { parseDer, readSequence } from './der'; +import { sm2Verify } from './sm2'; +import type { RevocationProvider } from './revocation'; + +const OID_SM2_SIGN = '1.2.156.10197.1.501'; +const OID_SM2_ECC = '1.2.156.10197.1.301'; +const KEY_CERT_SIGN = 1 << 5; +const KNOWN_EXTENSIONS = new Set([ + '2.5.29.19', + '2.5.29.15', + '2.5.29.37', + '2.5.29.14', + '2.5.29.35', + '2.5.29.31', + '2.5.29.32', + '1.3.6.1.5.5.7.1.1', +]); + +export interface Sm2X509Certificate { + der: Uint8Array; + tbsDer: Uint8Array; + signature: Uint8Array; + publicKey: Uint8Array; + subject: string; + issuer: string; + subjectDer: Uint8Array; + issuerDer: Uint8Array; + notBefore: Date; + notAfter: Date; + isCa: boolean; + pathLength?: number; + keyUsage: number | null; + unknownCriticalExtensions: string[]; +} + +export interface CertificateTrustResult { + status: 'trusted' | 'untrusted' | 'expired' | 'revoked' | 'indeterminate'; + chain: string[]; + reasons: string[]; +} + +export interface VerifyCertificateOptions { + trustAnchors: Uint8Array[]; + intermediates?: Uint8Array[]; + at?: Date; + revocationProvider?: RevocationProvider; + requireRevocation?: boolean; +} + +function raw(block: asn1js.BaseBlock): Uint8Array { + return block.valueBeforeDecodeView.slice(); +} + +function hex(block: asn1js.BaseBlock): Uint8Array { + return (block.valueBlock as unknown as { valueHexView: Uint8Array }).valueHexView.slice(); +} + +function tagged(block: asn1js.BaseBlock, tag: number): boolean { + return block.idBlock.tagClass === 3 && block.idBlock.tagNumber === tag; +} + +function sameBytes(a: Uint8Array, b: Uint8Array): boolean { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false; + return true; +} + +function algorithmOid(block: asn1js.BaseBlock): string { + const sequence = readSequence(block, 1, 2); + const [oid] = sequence.valueBlock.value; + if (!(oid instanceof asn1js.ObjectIdentifier)) throw new Error('X.509 algorithm OID expected'); + return oid.getValue(); +} + +function nameCn(block: asn1js.BaseBlock): string { + const sequence = readSequence(block, 0, Number.MAX_SAFE_INTEGER); + for (const rdn of sequence.valueBlock.value) { + if (!(rdn instanceof asn1js.Set)) continue; + for (const attribute of rdn.valueBlock.value) { + const pair = readSequence(attribute, 2, 2); + const [oid, value] = pair.valueBlock.value; + if ( + oid instanceof asn1js.ObjectIdentifier && + oid.getValue() === '2.5.4.3' && + value instanceof asn1js.BaseStringBlock + ) { + return value.getValue(); + } + } + } + return ''; +} + +function parseBasicConstraints(bytes: Uint8Array): { isCa: boolean; pathLength?: number } { + const sequence = readSequence(parseDer(bytes), 0, 2); + let isCa = false; + let pathLength: number | undefined; + for (const item of sequence.valueBlock.value) { + if (item instanceof asn1js.Boolean) isCa = item.getValue(); + else if (item instanceof asn1js.Integer) pathLength = Number(item.toBigInt()); + else throw new Error('X.509 invalid BasicConstraints'); + } + return { isCa, pathLength }; +} + +function parseKeyUsage(bytes: Uint8Array): number { + const block = parseDer(bytes); + if (!(block instanceof asn1js.BitString)) throw new Error('X.509 invalid KeyUsage'); + const data = hex(block); + let usage = 0; + for (let bit = 0; bit < data.length * 8 - block.valueBlock.unusedBits; bit++) { + if (data[bit >> 3]! & (0x80 >> (bit & 7))) usage |= 1 << bit; + } + return usage; +} + +function parseExtensions( + block: asn1js.BaseBlock, + target: { isCa: boolean; pathLength?: number; keyUsage: number | null; unknownCriticalExtensions: string[] }, +): void { + const contents = (block.valueBlock as unknown as { value: asn1js.BaseBlock[] }).value; + const wrapper = readSequence(contents[0]!, 0, Number.MAX_SAFE_INTEGER); + for (const item of wrapper.valueBlock.value) { + const extension = readSequence(item, 2, 3); + const [oidBlock, ...rest] = extension.valueBlock.value; + if (!(oidBlock instanceof asn1js.ObjectIdentifier)) throw new Error('X.509 extension OID expected'); + const oid = oidBlock.getValue(); + let critical = false; + let valueBlock: asn1js.BaseBlock; + if (rest[0] instanceof asn1js.Boolean) { + critical = rest[0].getValue(); + valueBlock = rest[1]!; + } else { + valueBlock = rest[0]!; + } + if (!(valueBlock instanceof asn1js.OctetString)) throw new Error('X.509 extension value expected'); + const value = hex(valueBlock); + if (oid === '2.5.29.19') { + const basic = parseBasicConstraints(value); + target.isCa = basic.isCa; + target.pathLength = basic.pathLength; + } else if (oid === '2.5.29.15') { + target.keyUsage = parseKeyUsage(value); + } else if (critical && !KNOWN_EXTENSIONS.has(oid)) { + target.unknownCriticalExtensions.push(oid); + } + } +} + +export function parseSm2Certificate(der: Uint8Array): Sm2X509Certificate { + const certificate = readSequence(parseDer(der, { maxBytes: 1024 * 1024 }), 3, 3); + const [tbs, signatureAlgorithm, signatureBlock] = certificate.valueBlock.value; + if (algorithmOid(signatureAlgorithm!) !== OID_SM2_SIGN) { + throw new Error('X.509 unsupported signature algorithm'); + } + if (!(signatureBlock instanceof asn1js.BitString)) throw new Error('X.509 signature expected'); + const tbsSequence = readSequence(tbs!, 6, 20); + const fields = tbsSequence.valueBlock.value; + let index = tagged(fields[0]!, 0) ? 1 : 0; + if (!(fields[index] instanceof asn1js.Integer)) throw new Error('X.509 serial number expected'); + index++; + if (algorithmOid(fields[index]!) !== OID_SM2_SIGN) throw new Error('X.509 TBS algorithm mismatch'); + index++; + const issuerBlock = fields[index++]!; + const validity = readSequence(fields[index++]!, 2, 2); + const subjectBlock = fields[index++]!; + const spki = readSequence(fields[index++]!, 2, 2); + const [notBeforeBlock, notAfterBlock] = validity.valueBlock.value; + if ( + !(notBeforeBlock instanceof asn1js.UTCTime) && + !(notBeforeBlock instanceof asn1js.GeneralizedTime) + ) { + throw new Error('X.509 validity time expected'); + } + if ( + !(notAfterBlock instanceof asn1js.UTCTime) && + !(notAfterBlock instanceof asn1js.GeneralizedTime) + ) { + throw new Error('X.509 validity time expected'); + } + const [spkiAlgorithm, publicKeyBlock] = spki.valueBlock.value; + if (algorithmOid(spkiAlgorithm!) !== OID_SM2_ECC) throw new Error('X.509 SM2 public key expected'); + if (!(publicKeyBlock instanceof asn1js.BitString)) throw new Error('X.509 public key expected'); + const publicKey = hex(publicKeyBlock); + if (publicKey.length !== 65 || publicKey[0] !== 4) throw new Error('X.509 invalid SM2 public key'); + const extensions = { + isCa: false, + pathLength: undefined as number | undefined, + keyUsage: null as number | null, + unknownCriticalExtensions: [] as string[], + }; + for (; index < fields.length; index++) { + if (tagged(fields[index]!, 3)) parseExtensions(fields[index]!, extensions); + } + return { + der: der.slice(), + tbsDer: raw(tbs!), + signature: hex(signatureBlock), + publicKey, + subject: nameCn(subjectBlock), + issuer: nameCn(issuerBlock), + subjectDer: raw(subjectBlock), + issuerDer: raw(issuerBlock), + notBefore: notBeforeBlock.toDate(), + notAfter: notAfterBlock.toDate(), + ...extensions, + }; +} + +function verifyParsedCertificateSignature(cert: Sm2X509Certificate, issuer: Sm2X509Certificate): boolean { + try { + return sm2Verify(issuer.publicKey, cert.tbsDer, cert.signature); + } catch { + return false; + } +} + +export function verifySm2CertificateSignature(cert: Uint8Array, issuer: Uint8Array): boolean { + try { + return verifyParsedCertificateSignature(parseSm2Certificate(cert), parseSm2Certificate(issuer)); + } catch { + return false; + } +} + +interface ChainNode { + cert: Sm2X509Certificate; + anchor: boolean; +} + +interface ChainBuild { + chain?: Sm2X509Certificate[]; + expired: boolean; + reasons: string[]; +} + +function validityProblem(cert: Sm2X509Certificate, at: Date): string | null { + if (cert.notBefore > at || cert.notAfter < at) return `${cert.subject} is outside its validity period`; + if (cert.unknownCriticalExtensions.length) { + return `${cert.subject} has unknown critical extensions: ${cert.unknownCriticalExtensions.join(',')}`; + } + return null; +} + +function buildChain( + current: ChainNode, + nodes: ChainNode[], + at: Date, + visited: Set, + caBelow: number, +): ChainBuild { + const currentProblem = validityProblem(current.cert, at); + if (currentProblem) { + return { expired: /validity period/.test(currentProblem), reasons: [currentProblem] }; + } + if (current.anchor) return { chain: [current.cert], expired: false, reasons: [] }; + const result: ChainBuild = { expired: false, reasons: [] }; + for (const issuer of nodes) { + if (issuer === current || !sameBytes(current.cert.issuerDer, issuer.cert.subjectDer)) continue; + const key = `${issuer.cert.subject}:${issuer.cert.der.length}`; + if (visited.has(key)) continue; + if (!issuer.cert.isCa || issuer.cert.keyUsage === null || !(issuer.cert.keyUsage & KEY_CERT_SIGN)) { + result.reasons.push(`${issuer.cert.subject} cannot issue certificates`); + continue; + } + if ( + issuer.cert.pathLength !== undefined && + caBelow + (current.cert.isCa ? 1 : 0) > issuer.cert.pathLength + ) { + result.reasons.push(`${issuer.cert.subject} path length constraint exceeded`); + continue; + } + const issuerProblem = validityProblem(issuer.cert, at); + if (issuerProblem) { + result.expired ||= /validity period/.test(issuerProblem); + result.reasons.push(issuerProblem); + continue; + } + if (!verifyParsedCertificateSignature(current.cert, issuer.cert)) { + result.reasons.push(`${current.cert.subject} issuer signature is invalid`); + continue; + } + visited.add(key); + const built = buildChain(issuer, nodes, at, visited, caBelow + (current.cert.isCa ? 1 : 0)); + visited.delete(key); + if (built.chain) return { chain: [current.cert, ...built.chain], expired: false, reasons: [] }; + result.expired ||= built.expired; + result.reasons.push(...built.reasons); + } + if (!result.reasons.length) result.reasons.push(`${current.cert.subject} has no trusted issuer`); + return result; +} + +export async function verifyCertificateChain( + certificate: Uint8Array, + options: VerifyCertificateOptions, +): Promise { + const at = options.at ?? new Date(); + try { + const leaf = parseSm2Certificate(certificate); + const anchors = options.trustAnchors.map((der) => parseSm2Certificate(der)); + const intermediates = (options.intermediates ?? []).map((der) => parseSm2Certificate(der)); + const nodes = [ + ...anchors.map((cert) => ({ cert, anchor: true })), + ...intermediates.map((cert) => ({ cert, anchor: false })), + ]; + const leafIsAnchor = anchors.find((anchor) => sameBytes(anchor.der, leaf.der)); + const built = buildChain( + { cert: leaf, anchor: Boolean(leafIsAnchor) }, + nodes, + at, + new Set(), + 0, + ); + if (!built.chain) { + return { + status: built.expired ? 'expired' : 'untrusted', + chain: [], + reasons: built.reasons, + }; + } + const chainNames = built.chain.map((cert) => cert.subject); + if (options.revocationProvider) { + for (let i = 0; i < built.chain.length - 1; i++) { + const cert = built.chain[i]!; + const issuer = built.chain[i + 1]!; + const status = await options.revocationProvider.check(cert.der, issuer.der, at); + if (status === 'revoked') { + return { status: 'revoked', chain: chainNames, reasons: [`${cert.subject} is revoked`] }; + } + if (status === 'unknown' && options.requireRevocation) { + return { + status: 'indeterminate', + chain: chainNames, + reasons: [`${cert.subject} revocation status is unknown`], + }; + } + } + } else if (options.requireRevocation) { + return { + status: 'indeterminate', + chain: chainNames, + reasons: ['Revocation evidence is required but no provider was supplied'], + }; + } + return { status: 'trusted', chain: chainNames, reasons: [] }; + } catch (error) { + return { + status: 'indeterminate', + chain: [], + reasons: [error instanceof Error ? error.message : String(error)], + }; + } +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 6740a27..2333e3e 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -54,6 +54,8 @@ export type { AnnotationOptions, AnnotationType } from './modules/annotate'; export type { AutoPagingOptions, HeaderFooterOptions, PageInfo } from './modules/flow'; export type { HtmlOptions } from './modules/html'; export type { SvgOptions } from './modules/svg'; +export type { RedactOptions, RedactionHit } from './modules/redact'; +export type { WatermarkOptions } from './modules/watermark'; export type { TableCell, TableCellInput, @@ -66,6 +68,71 @@ export { BUILTIN_FONTS, FONT_ALIASES } from './metrics'; export type { FontDef, FontWidths } from './metrics'; export { PAGE_FORMATS_MM, UNIT_FACTORS } from './formats'; export { OFD_NAMESPACE } from './xml'; + +// ---- OFD reading (GB/T 33190 packages → object model) ---- +export { parseOfd, extractText } from './ofd-reader'; +export { openOfd } from './ofd-open'; +export { pageToSvg, pageToPng } from './ofd-svg'; +export type { + OfdDocument, + OfdPage, + OfdObject, + OfdTextObj, + OfdPathObj, + OfdImageObj, + OfdTextCode, + OfdPathOp, + OfdColor, + OfdCtm, + OfdBoundary, + OfdOutlineNode, + OfdAttachment, + OfdSignatureInfo, +} from './ofd-reader'; +export type { OfdSvgOptions, OfdPngOptions, OfdPngResult } from './ofd-svg'; + +// ---- SES V4 electronic seals and trusted OFD signing ---- +export { signOfd } from './crypto/ofd-sign'; +export type { SignOfdOptions } from './crypto/ofd-sign'; +export { verifyOfd } from './crypto/ofd-verify'; +export type { + OfdVerifyOptions, + OfdVerifyResult, + OfdSignatureVerification, + SignatureStatus, +} from './crypto/ofd-verify'; +export { createSesSeal, verifySesSeal } from './crypto/ofd-seal'; +export type { + CreateSesSealOptions, + VerifySesSealOptions, + SesSealVerification, +} from './crypto/ofd-seal'; +export type { + SesHeader, + SesProperty, + SesPicture, + SesSealInfo, + SesSealInfoInput, + SesSeal, + SesSignature, +} from './crypto/ses-v4'; +export { createLocalTestSm2Signer, requestSm2Signature } from './crypto/signer'; +export type { Sm2Signer, Sm2SignRequest } from './crypto/signer'; +export { parseSm2Certificate, verifyCertificateChain } from './crypto/x509-sm2'; +export type { + Sm2X509Certificate, + CertificateTrustResult, + VerifyCertificateOptions, +} from './crypto/x509-sm2'; +export type { RevocationProvider, RevocationStatus } from './crypto/revocation'; + +// ---- OFD password encryption (GM/T 0099, SM4-CBC) ---- +export { encryptOfdPassword, decryptOfdPassword } from './crypto/ofd-encryption'; +export type { + OfdPasswordEncryptionOptions, + OfdPasswordDecryptionOptions, +} from './crypto/ofd-encryption'; + export const version = JSOFD_VERSION; export default jsOFD; diff --git a/packages/core/src/jsofd.ts b/packages/core/src/jsofd.ts index 9fedfc7..3edbd6f 100644 --- a/packages/core/src/jsofd.ts +++ b/packages/core/src/jsofd.ts @@ -44,6 +44,8 @@ import { SvgApi } from './modules/svg'; import { TableApi } from './modules/table'; import { TextApi } from './modules/text'; import { ViewApi } from './modules/view'; +import { WatermarkApi } from './modules/watermark'; +import { RedactApi } from './modules/redact'; export const JSOFD_VERSION = '1.0.0'; @@ -321,4 +323,6 @@ Object.assign( HtmlApi, SvgApi, ViewApi, + WatermarkApi, + RedactApi, ); diff --git a/packages/core/src/modules/redact.ts b/packages/core/src/modules/redact.ts new file mode 100644 index 0000000..9ec81b8 --- /dev/null +++ b/packages/core/src/modules/redact.ts @@ -0,0 +1,155 @@ +/** + * Keyword redaction (15.9.1): locate keywords inside laid-out text runs and + * mask them without disturbing the layout. + * + * Both modes rewrite the run's text in place — block fills the gap with + * spaces, asterisk replaces the keyword characters with `*` — while the + * per-glyph advances (glyphWs) stay untouched, so surrounding glyphs keep + * their exact positions. 'block' additionally draws an opaque rectangle over + * the keyword's bounding box. + * + * Rotated runs (angle ≠ 0) are skipped: their mask would need a rotated + * rectangle, which PathRun cannot express. + */ + +import type { jsOFD } from '../jsofd'; +import { glyphWidth } from '../metrics'; +import type { PathRun } from '../model'; +import type { RGB } from '../types'; + +export interface RedactOptions { + /** + * 'block' draws an opaque rectangle over the keyword and blanks its text + * (default); 'asterisk' replaces the keyword characters with `*`. + */ + mode?: 'block' | 'asterisk'; + /** Rectangle fill colour for 'block' (default black). */ + color?: RGB; + /** Which pages to process (default 'all'). */ + pages?: 'all' | 'current' | number[]; +} + +export interface RedactionHit { + keyword: string; + /** One-based page index; 0 when the keyword never matched. */ + page: number; + count: number; +} + +export interface RedactApi { + /** + * Redact keyword occurrences across laid-out text. + * + * @example + * doc.redact(['王小明', '138****8000']); + * const hits = doc.redact('绝密', { mode: 'asterisk' }); + */ + redact(this: jsOFD, keywords: string | string[], options?: RedactOptions): RedactionHit[]; +} + +function pageIndices(this: jsOFD, sel: RedactOptions['pages']): number[] { + if (typeof sel === 'number') return [sel - 1]; + if (Array.isArray(sel)) return sel.map((n) => n - 1); + if (sel === 'current') return [this.page]; + return this.pages.map((_, i) => i); +} + +/** Advance of one character in pt, mirroring the builder's DeltaX formula. */ +function charAdvance( + doc: jsOFD, + run: { fontKey: string; style: string; size: number; charSpace: number; glyphWs?: number[] }, + index: number, + ch: string, +): number { + if (run.glyphWs && run.glyphWs[index] !== undefined) return run.glyphWs[index]!; + const def = doc.getFontDef(run.fontKey); + return (glyphWidth(def, run.style as 'normal', ch) / 1000) * run.size + run.charSpace; +} + +export const RedactApi = { + redact(this: jsOFD, keywords: string | string[], options: RedactOptions = {}): RedactionHit[] { + const list = Array.isArray(keywords) ? keywords : [keywords]; + const mode = options.mode ?? 'block'; + const color: RGB = options.color ?? [0, 0, 0]; + const hits: RedactionHit[] = []; + + for (const keyword of list) { + if (!keyword) continue; + const perPage = new Map(); + + for (const idx of pageIndices.call(this, options.pages)) { + const page = this.pages[idx]; + if (!page) continue; + const rects: PathRun[] = []; + + for (const obj of page.objects) { + if (obj.t !== 'text' || obj.angle) continue; + let from = 0; + for (;;) { + const at = obj.text.indexOf(keyword, from); + if (at === -1) break; + + // x of the keyword start / end along the baseline + let startX = 0; + for (let i = 0; i < at; i++) { + startX += charAdvance(this, obj, i, obj.text[i]!); + } + let width = 0; + for (let i = at; i < at + keyword.length; i++) { + width += charAdvance(this, obj, i, obj.text[i]!); + } + const hScale = obj.hScale || 1; + const x0 = obj.x + startX * hScale; + const w = width * hScale; + const y0 = obj.y - (obj.ascent || obj.size * 0.8); + const h = (obj.ascent || obj.size * 0.8) + (obj.descent || obj.size * 0.2); + + if (mode === 'block') { + rects.push({ + t: 'path', + ops: [ + { op: 'M', x: x0, y: y0 }, + { op: 'L', x: x0 + w, y: y0 }, + { op: 'L', x: x0 + w, y: y0 + h }, + { op: 'L', x: x0, y: y0 + h }, + { op: 'Z' }, + ], + fill: true, + stroke: false, + fillColor: color, + strokeColor: color, + lineWidth: 0, + dash: null, + cap: 0, + join: 0, + miterLimit: 3.528, + opacity: null, + }); + } + + // Mask the text itself so extraction/copy no longer leaks it. + const mask = mode === 'block' ? ' '.repeat(keyword.length) : '*'.repeat(keyword.length); + obj.text = obj.text.slice(0, at) + mask + obj.text.slice(at + keyword.length); + from = at + mask.length; + perPage.set(idx, (perPage.get(idx) ?? 0) + 1); + } + } + + // Rects go on top of the (masked) text. + page.objects.push(...rects); + } + + for (const [pageIndex, count] of perPage) { + hits.push({ keyword, page: pageIndex + 1, count }); + } + if (!perPage.size) hits.push({ keyword, page: 0, count: 0 }); + } + + return hits; + }, +}; + +declare module '../jsofd' { + // eslint-disable-next-line @typescript-eslint/no-empty-object-type -- declaration merging + interface jsOFD extends RedactApi {} +} diff --git a/packages/core/src/modules/watermark.ts b/packages/core/src/modules/watermark.ts new file mode 100644 index 0000000..298cd08 --- /dev/null +++ b/packages/core/src/modules/watermark.ts @@ -0,0 +1,192 @@ +/** + * Watermarks: text or image stamps applied to pages (15.2.4). + * + * Two placements — a single centred stamp or a tiled grid — rendered as + * ordinary page objects (rotated translucent text / images) either above or + * below the existing content ("over" appends, "under" prepends the page's + * object list). + */ + +import type { jsOFD } from '../jsofd'; +import type { ImageRun, PathRun, TextRun } from '../model'; +import type { RGB } from '../types'; +import { sniffImage } from '../image'; +import { glyphWidth } from '../metrics'; + +export interface WatermarkOptions { + /** Text watermark content (type "text"). Rendered with the named font. */ + text?: string; + /** Image watermark bytes (type "image"). PNG/JPEG recommended (alpha). */ + image?: Uint8Array; + /** Image format when `image` is used (default sniffed). */ + imageFormat?: 'PNG' | 'JPEG'; + /** Font registry key for text watermarks (default the active font). */ + fontKey?: string; + /** Text size in points (default 48). */ + fontSize?: number; + /** Text colour (default light grey). */ + color?: RGB; + /** Opacity 0–1 (default 0.15). */ + opacity?: number; + /** Rotation in degrees, clockwise (default 45). */ + angle?: number; + /** 'center' places one stamp per page; 'tile' fills the page (default center). */ + mode?: 'center' | 'tile'; + /** Tiling pitch in points (default 1.6 × fontSize). */ + tile?: { dx?: number; dy?: number }; + /** Which pages receive the watermark (default 'all'). */ + pages?: 'all' | 'current' | number[]; + /** Draw above (default) or below the existing page content. */ + layer?: 'over' | 'under'; + /** Image stamp width in points; height follows the pixel aspect (default 40% page width). */ + imageWidth?: number; +} + +export interface WatermarkApi { + /** + * Add a text or image watermark to pages. Returns the number of pages stamped. + * + * @example + * doc.addWatermark({ text: '绝密', opacity: 0.12 }); + * doc.addWatermark({ image: pngBytes, mode: 'tile', pages: [1] }); + */ + addWatermark(this: jsOFD, options: WatermarkOptions | string): number; +} + +function resolvePages(this: jsOFD, sel: WatermarkOptions['pages']): number[] { + if (typeof sel === 'number') return [sel - 1]; + if (Array.isArray(sel)) return sel.map((n) => n - 1); + if (sel === 'current') return [this.page]; + return this.pages.map((_, i) => i); +} + +function textWidthPt( + this: jsOFD, + text: string, + fontKey: string, + style: 'normal', + size: number, +): number { + const def = this.getFontDef(fontKey); + let w = 0; + for (const ch of text) w += (glyphWidth(def, style, ch) / 1000) * size; + return w; +} + +export const WatermarkApi = { + addWatermark(this: jsOFD, options: WatermarkOptions | string): number { + const o: WatermarkOptions = typeof options === 'string' ? { text: options } : options; + const type = o.image ? 'image' : 'text'; + const text = (o.text ?? '').toString(); + const fontKey = o.fontKey ?? this.activeFontKey ?? 'simsun'; + const fontSize = o.fontSize ?? 48; + const color: RGB = o.color ?? [176, 176, 176]; + const opacity = o.opacity ?? 0.15; + const angle = o.angle ?? 45; + const mode = o.mode ?? 'center'; + const layer = o.layer ?? 'over'; + const rad = (angle * Math.PI) / 180; + const targets = resolvePages.call(this, o.pages); + let stamped = 0; + + for (const idx of targets) { + const page = this.pages[idx]; + if (!page) continue; + const created: (TextRun | PathRun | ImageRun)[] = []; + + if (type === 'image' && o.image) { + const info = sniffImage(o.image) ?? { format: undefined, ext: 'png', width: 1, height: 1 }; + const pw = ptWidth(page); + const ph = ptHeight(page); + const w = o.imageWidth ?? pw * 0.4; + const h = info.width && info.height ? (w * info.height) / info.width : w; + const stamp = (x: number, y: number): void => { + created.push({ + t: 'image', + x, + y, + w, + h, + imageRef: { + alias: `wm${created.length}`, + resId: 0, + data: o.image!, + format: o.imageFormat ?? info.format ?? 'PNG', + ext: (info?.ext ?? 'png') as string, + width: info.width, + height: info.height, + }, + angle, + opacity, + }); + }; + if (mode === 'tile') { + const dx = o.tile?.dx ?? w * 1.6; + const dy = o.tile?.dy ?? h * 2.2; + for (let y = -h; y < ph; y += dy) { + for (let x = -w; x < pw; x += dx) stamp(x, y); + } + } else { + stamp((pw - w) / 2, (ph - h) / 2); + } + } else if (text) { + const style = 'normal' as const; + const wPt = textWidthPt.call(this, text, fontKey, style, fontSize); + const pw = ptWidth(page); + const ph = ptHeight(page); + const stamp = (cx: number, cy: number): void => { + // Baseline origin so the rotated text centres on (cx, cy): + // rotate the half-extent vector by the angle. + const hx = (wPt / 2) * Math.cos(rad); + const hy = (wPt / 2) * Math.sin(rad); + created.push({ + t: 'text', + x: cx - hx, + y: cy - hy, + text, + size: fontSize, + fontKey, + style, + color, + charSpace: 0, + angle, + ascent: fontSize * 0.8, + descent: fontSize * 0.2, + renderingMode: 'fill', + opacity, + justifyWidth: null, + hScale: 1, + }); + }; + if (mode === 'tile') { + const dx = o.tile?.dx ?? fontSize * 3; + const dy = o.tile?.dy ?? fontSize * 3; + for (let y = fontSize; y < ph + fontSize; y += dy) { + for (let x = -wPt; x < pw; x += dx) stamp(x + wPt / 2, y); + } + } else { + stamp(pw / 2, ph / 2); + } + } + + if (created.length) { + if (layer === 'under') page.objects.unshift(...created); + else page.objects.push(...created); + stamped++; + } + } + return stamped; + }, +}; + +function ptWidth(page: { width: number }): number { + return page.width; // pages are stored in pt when created via the public API +} +function ptHeight(page: { height: number }): number { + return page.height; +} + +declare module '../jsofd' { + // eslint-disable-next-line @typescript-eslint/no-empty-object-type -- declaration merging + interface jsOFD extends WatermarkApi {} +} diff --git a/packages/core/src/ofd-open.ts b/packages/core/src/ofd-open.ts new file mode 100644 index 0000000..a32f9c9 --- /dev/null +++ b/packages/core/src/ofd-open.ts @@ -0,0 +1,232 @@ +/** + * Open an OFD package as an editable jsOFD document. + * + * parseOfd() produces the read-only object model; openOfd() goes one step + * further and maps it back onto the jsOFD writing API (pages, runs, embedded + * fonts, outlines, attachments, metadata). The returned document previews, + * edits and re-serialises like any hand-built jsOFD document — enabling + * OFD → preview → modify → OFD round-trips. + * + * All model coordinates are points (pt); the OFD native millimetres convert + * via PT_PER_MM. Object-level CTMs decompose into per-point transforms + * (paths) and rotation + centre re-anchoring (text/images). + */ + +import { jsOFD } from './jsofd'; +import { parseOfd, type OfdCtm, type OfdPage, type OfdPathOp } from './ofd-reader'; +import { hasCJK } from './metrics'; +import type { PathOp, PathRun, TextRun } from './model'; + +const PT_PER_MM = 72 / 25.4; + +const pt = (mm: number): number => mm * PT_PER_MM; + +/** Apply an OFD CTM to a point (OFD row-major [a b c d e f]). */ +function applyCtm(ctm: OfdCtm, x: number, y: number): [number, number] { + return [ctm.a * x + ctm.c * y + ctm.e, ctm.b * x + ctm.d * y + ctm.f]; +} + +/** Rotation-like CTM: [a b; -b a] (possibly scaled). */ +function isRotationLike(ctm: OfdCtm): boolean { + const eps = 1e-6; + return ( + Math.abs(ctm.c + ctm.b) < eps && + Math.abs(ctm.a - ctm.d) < eps && + (Math.abs(ctm.a) > eps || Math.abs(ctm.b) > eps) + ); +} + +/** Convert OFD path ops (boundary-relative mm) into page-space pt PathOps. */ +function toPathOps(ops: OfdPathOp[], boundary: { x: number; y: number }, ctm?: OfdCtm): PathOp[] { + const out: PathOp[] = []; + const map = (x: number, y: number): [number, number] => { + const px = boundary.x + x; + const py = boundary.y + y; + const [tx, ty] = ctm ? applyCtm(ctm, px, py) : [px, py]; + return [pt(tx), pt(ty)]; + }; + for (const op of ops) { + if (op.op === 'M' || op.op === 'L') { + const [x, y] = map(op.x, op.y); + out.push({ op: op.op, x, y }); + } else if (op.op === 'Q') { + const [x1, y1] = map(op.x1, op.y1); + const [x, y] = map(op.x, op.y); + // Quadratic → cubic (control points at 2/3 towards the pole). + const last = out[out.length - 1]; + const lx = last && last.op !== 'Z' ? last.x : 0; + const ly = last && last.op !== 'Z' ? last.y : 0; + out.push({ + op: 'C', + x1: lx + (2 / 3) * (x1 - lx), + y1: ly + (2 / 3) * (y1 - ly), + x2: x + (2 / 3) * (x1 - x), + y2: y + (2 / 3) * (y1 - y), + x, + y, + }); + } else if (op.op === 'C') { + const [x1, y1] = map(op.x1, op.y1); + const [x2, y2] = map(op.x2, op.y2); + const [x, y] = map(op.x, op.y); + out.push({ op: 'C', x1, y1, x2, y2, x, y }); + } else out.push({ op: 'Z' }); + } + return out; +} + +/** + * Open an OFD package as an editable jsOFD document. + * + * @example + * const doc = await openOfd(fileBytes); + * doc.save('edited.ofd'); + */ +export async function openOfd( + data: Uint8Array | ArrayBuffer, + options: { + /** Invoked per parsed page. */ onProgress?: (page: number, total: number) => void; + } = {}, +): Promise { + const ofd = await parseOfd(data); + const first = ofd.pages[0]; + const doc = new jsOFD({ + unit: 'pt', + format: first ? [pt(first.width), pt(first.height)] : 'a4', + }); + + // ---- metadata ---- + doc.setProperties({ + title: ofd.meta.title, + author: ofd.meta.author, + subject: ofd.meta.subject, + keywords: ofd.meta.keywords, + creator: ofd.meta.creator ?? 'jsOFD', + }); + + // ---- embedded fonts: register under namespaced keys ---- + const fontKeys = new Map(); + for (const [id, font] of Object.entries(ofd.fonts)) { + if (!font.data.length) continue; + const key = `ofd_${id.toLowerCase()}`; + try { + doc.addFontTtf(key, font.name, font.data); + fontKeys.set(id, key); + } catch { + // Malformed font program: runs fall back to builtin metrics below. + } + } + + // ---- pages ---- + ofd.pages.forEach((page: OfdPage, index: number) => { + options.onProgress?.(index, ofd.pages.length); + if (index > 0) doc.addPage([pt(page.width), pt(page.height)]); + const target = doc.pages[doc.page]!; + + for (const obj of page.objects) { + if (obj.kind === 'text') { + const rot = obj.ctm && isRotationLike(obj.ctm) ? Math.atan2(obj.ctm.b, obj.ctm.a) : 0; + const scale = obj.ctm && isRotationLike(obj.ctm) ? Math.hypot(obj.ctm.a, obj.ctm.b) : 1; + for (const code of obj.codes) { + const fallbackKey = hasCJK(code.text) ? 'simsun' : 'helvetica'; + const fontKey = (obj.fontId && fontKeys.get(obj.fontId)) || fallbackKey; + const run: TextRun = { + t: 'text', + x: pt(obj.boundary.x + code.x), + y: pt(obj.boundary.y + code.y), + text: code.text, + size: pt(obj.size) * (scale === 1 ? 1 : scale), + fontKey, + style: 'normal', + color: [Math.round(obj.color.r), Math.round(obj.color.g), Math.round(obj.color.b)], + charSpace: 0, + angle: rot ? +((rot * 180) / Math.PI).toFixed(4) : 0, + ascent: obj.size * PT_PER_MM * 0.8, + descent: -obj.size * PT_PER_MM * 0.2, + renderingMode: + obj.stroke && !obj.fill ? 'stroke' : obj.stroke ? 'fillThenStroke' : 'fill', + opacity: obj.alpha ?? (obj.color.alpha < 1 ? obj.color.alpha : null), + justifyWidth: null, + hScale: 1, + glyphWs: code.deltaX?.map((d) => pt(d)), + }; + target.objects.push(run); + } + } else if (obj.kind === 'path') { + const run: PathRun = { + t: 'path', + ops: toPathOps(obj.ops, obj.boundary, obj.ctm), + fill: obj.fill, + stroke: obj.stroke, + fillColor: obj.fillColor + ? [ + Math.round(obj.fillColor.r), + Math.round(obj.fillColor.g), + Math.round(obj.fillColor.b), + ] + : [0, 0, 0], + strokeColor: obj.strokeColor + ? [ + Math.round(obj.strokeColor.r), + Math.round(obj.strokeColor.g), + Math.round(obj.strokeColor.b), + ] + : [0, 0, 0], + lineWidth: pt(obj.lineWidth ?? 0.353), + dash: obj.dashPattern ? { pattern: obj.dashPattern.map((v) => pt(v)), phase: 0 } : null, + cap: 0, + join: 0, + miterLimit: 3.528, + opacity: obj.alpha ?? null, + }; + target.objects.push(run); + } else if (obj.kind === 'image' && obj.data?.length) { + // Rotation-like CTM: unrotated size = column/row scales, centre from + // the CTM applied to the unit-square centre. + let w = pt(obj.boundary.w); + let h = pt(obj.boundary.h); + let angle = 0; + let x = pt(obj.boundary.x); + let y = pt(obj.boundary.y); + if (obj.ctm && isRotationLike(obj.ctm)) { + angle = +((Math.atan2(obj.ctm.b, obj.ctm.a) * 180) / Math.PI).toFixed(4); + const sx = Math.hypot(obj.ctm.a, obj.ctm.b); + const sy = Math.hypot(obj.ctm.c, obj.ctm.d); + const [cx, cy] = applyCtm(obj.ctm, 0.5, 0.5); + w = pt(sx); + h = pt(sy); + x = pt(cx) - w / 2; + y = pt(cy) - h / 2; + } + doc.addImage(obj.data, obj.format ?? 'PNG', x, y, w, h); + const last = target.objects[target.objects.length - 1]; + if (last && last.t === 'image') { + last.angle = angle; + if (obj.alpha !== undefined && obj.alpha < 1) last.opacity = obj.alpha; + } + } + } + }); + + // ---- outlines ---- + const addOutlines = ( + nodes: typeof ofd.outlines, + parent: Parameters[0], + ): void => { + for (const n of nodes) { + const node = doc.outline.add(parent, n.title, { pageNumber: n.pageIndex + 1 }); + if (n.children.length) addOutlines(n.children, node); + } + }; + addOutlines(ofd.outlines, null); + + // ---- attachments ---- + for (const a of ofd.attachments) { + if (a.data.length) doc.addFileAsAttachment(a.name, a.data, { format: a.format }); + } + + return doc; +} + +// Re-exported so one import serves the whole read path. +export { parseOfd, extractText } from './ofd-reader'; diff --git a/packages/core/src/ofd-reader.ts b/packages/core/src/ofd-reader.ts new file mode 100644 index 0000000..2ad1f4e --- /dev/null +++ b/packages/core/src/ofd-reader.ts @@ -0,0 +1,525 @@ +/** + * OFD package reader — parse GB/T 33190-2016 files into a plain object model. + * + * Counterpart of builder.ts (write side). Supported today: + * - package structure: OFD.xml → Doc_N/Document.xml → Pages/Page_N/Content.xml + * - page objects: TextObject / PathObject / ImageObject (with CTM, colors) + * - metadata (DocInfo), DocType (OFD / OFD-H), Outlines, Attachments, + * embedded font resources, signature names + * Skipped (recorded in `limitations`): CompositeObject nesting, clips, page + * annotations (StampAnnot appearance), Actions other than GoTo. + */ + +import { parseXml, all, kid, kids, textOf, type XmlElement } from './xml-parse'; +import { unzip, findEntry, resolvePath } from './zip-read'; + +/* ---------------- public model ---------------- */ + +export interface OfdColor { + r: number; + g: number; + b: number; + /** 0–1, from the Alpha attribute (default 1). */ + alpha: number; +} + +export interface OfdCtm { + a: number; + b: number; + c: number; + d: number; + e: number; + f: number; +} + +export type OfdPathOp = + | { op: 'M' | 'L'; x: number; y: number } + | { op: 'C'; x1: number; y1: number; x2: number; y2: number; x: number; y: number } + | { op: 'Q'; x1: number; y1: number; x: number; y: number } + | { op: 'Z' }; + +export interface OfdBoundary { + x: number; + y: number; + w: number; + h: number; +} + +export interface OfdTextCode { + /** Text content, entities decoded. */ + text: string; + /** Offset of the first glyph from the Boundary origin, in mm. */ + x: number; + y: number; + /** Per-glyph x offsets (length = char count − 1) in mm, when pinned. */ + deltaX?: number[]; + deltaY?: number[]; +} + +export interface OfdTextObj { + kind: 'text'; + boundary: OfdBoundary; + size: number; + fontId?: string; + fill: boolean; + stroke: boolean; + color: OfdColor; + strokeColor?: OfdColor; + alpha?: number; + ctm?: OfdCtm; + codes: OfdTextCode[]; +} + +export interface OfdPathObj { + kind: 'path'; + boundary: OfdBoundary; + ops: OfdPathOp[]; + fill: boolean; + stroke: boolean; + fillColor?: OfdColor; + strokeColor?: OfdColor; + lineWidth?: number; + dashPattern?: number[]; + alpha?: number; + ctm?: OfdCtm; +} + +export interface OfdImageObj { + kind: 'image'; + boundary: OfdBoundary; + resId: string; + format?: string; + /** Image bytes, resolved from the package resources. */ + data?: Uint8Array; + alpha?: number; + ctm?: OfdCtm; +} + +export type OfdObject = OfdTextObj | OfdPathObj | OfdImageObj; + +export interface OfdOutlineNode { + title: string; + /** Zero-based page index the outline entry points to. */ + pageIndex: number; + children: OfdOutlineNode[]; +} + +export interface OfdAttachment { + id: number; + name: string; + format?: string; + data: Uint8Array; +} + +export interface OfdSignatureInfo { + /** Signature.xml location inside the package. */ + name: string; + /** Base name (SES_Signature / SES_Profile / ...). */ + baseName: string; +} + +export interface OfdPage { + index: number; + /** Page box in mm. */ + width: number; + height: number; + objects: OfdObject[]; +} + +export interface OfdDocument { + /** DocType from OFD.xml — "OFD" or "OFD-H" (GB/T XXXXX archive profile). */ + docType: string; + meta: { + docId?: string; + title?: string; + author?: string; + subject?: string; + keywords?: string; + creator?: string; + creationDate?: string; + modDate?: string; + }; + pages: OfdPage[]; + outlines: OfdOutlineNode[]; + attachments: OfdAttachment[]; + signatures: OfdSignatureInfo[]; + /** Embedded font programs by resource id (FontName + raw bytes). */ + fonts: Record; + /** Features present in the file but not modelled by this reader. */ + limitations: string[]; +} + +/* ---------------- helpers ---------------- */ + +const SVG_NS_RE = /^-?\d+(\.\d+)?$/; + +function nums(s: string | undefined): number[] { + if (!s) return []; + return s + .trim() + .split(/\s+/) + .filter((t) => SVG_NS_RE.test(t)) + .map(Number); +} + +function boundary(el: XmlElement): OfdBoundary { + const v = nums(el.attrs.Boundary); + return { x: v[0] ?? 0, y: v[1] ?? 0, w: v[2] ?? 0, h: v[3] ?? 0 }; +} + +function ctm(el: XmlElement): OfdCtm | undefined { + const v = nums(el.attrs.CTM); + if (v.length < 6) return undefined; + return { a: v[0]!, b: v[1]!, c: v[2]!, d: v[3]!, e: v[4]!, f: v[5]! }; +} + +/** Parse a FillColor/StrokeColor element (RGB / CMYK / gray → RGB + alpha). */ +function ofdColor(el: XmlElement | undefined): OfdColor | undefined { + if (!el) return undefined; + const v = nums(el.attrs.Value); + const cs = Number(el.attrs.ColorSpace ?? '1'); + let r: number; + let g: number; + let b: number; + if (cs === 2 && v.length >= 4) { + // CMYK (0–255 each) → RGB + const c = v[0]! / 255; + const m = v[1]! / 255; + const y = v[2]! / 255; + const k = v[3]! / 255; + r = Math.round(255 * (1 - c) * (1 - k)); + g = Math.round(255 * (1 - m) * (1 - k)); + b = Math.round(255 * (1 - y) * (1 - k)); + } else if (v.length === 1) { + r = g = b = v[0]!; + } else { + r = v[0] ?? 0; + g = v[1] ?? 0; + b = v[2] ?? 0; + } + const alpha = el.attrs.Alpha !== undefined ? Number(el.attrs.Alpha) / 255 : 1; + return { r, g, b, alpha }; +} + +/** Parse OFD AbbreviatedData. Note the OFD operators differ from SVG: + * `B` is the cubic Bézier (6 numbers) and `C` (bare) closes the path. */ +function parseAbbreviated(data: string): OfdPathOp[] { + const tokens = data.trim().split(/\s+/).filter(Boolean); + const ops: OfdPathOp[] = []; + let i = 0; + const num = (): number => { + const t = tokens[i++]; + if (t === undefined || !SVG_NS_RE.test(t)) throw new Error(`path: bad number '${t}'`); + return Number(t); + }; + const nextIsNumber = (): boolean => i < tokens.length && SVG_NS_RE.test(tokens[i]!); + while (i < tokens.length) { + const op = tokens[i++]; + if (op === 'M' || op === 'L') ops.push({ op, x: num(), y: num() }); + else if (op === 'B' || (op === 'C' && nextIsNumber())) + ops.push({ op: 'C', x1: num(), y1: num(), x2: num(), y2: num(), x: num(), y: num() }); + else if (op === 'Q') ops.push({ op: 'Q', x1: num(), y1: num(), x: num(), y: num() }); + else if (op === 'Z' || op === 'C') ops.push({ op: 'Z' }); + else throw new Error(`path: unsupported operator '${op}'`); + } + return ops; +} + +/** OFD TextObject → text codes (content + offsets). */ +function parseTextCodes(el: XmlElement): OfdTextCode[] { + return kids(el, 'TextCode').map((code) => { + const deltaX = nums(code.attrs.DeltaX); + const deltaY = nums(code.attrs.DeltaY); + return { + text: textOf(code), + x: Number(code.attrs.X ?? 0) || 0, + y: Number(code.attrs.Y ?? 0) || 0, + deltaX: deltaX.length ? deltaX : undefined, + deltaY: deltaY.length ? deltaY : undefined, + }; + }); +} + +/* ---------------- reader ---------------- */ + +export async function parseOfd(data: Uint8Array | ArrayBuffer): Promise { + const bytes = data instanceof Uint8Array ? data : new Uint8Array(data); + const entries = await unzip(bytes); + const limitations: string[] = []; + + // ---- OFD.xml: entry point, DocInfo, DocRoot, DocType ---- + const ofdXmlBytes = findEntry(entries, 'OFD.xml'); + if (!ofdXmlBytes) throw new Error('OFD: OFD.xml not found in package'); + const ofdXml = parseXml(new TextDecoder('utf-8').decode(ofdXmlBytes)); + const docType = ofdXml.attrs.DocType ?? 'OFD'; + const docBody = kid(ofdXml, 'DocBody'); + const docInfoEl = docBody ? kid(docBody, 'DocInfo') : undefined; + const meta: OfdDocument['meta'] = {}; + if (docInfoEl) { + for (const field of [ + 'DocID', + 'Title', + 'Author', + 'Subject', + 'Keywords', + 'Creator', + 'CreationDate', + 'ModDate', + ] as const) { + const el = kid(docInfoEl, field); + if (el) { + const value = textOf(el); + meta[ + field === 'DocID' + ? 'docId' + : field === 'CreationDate' + ? 'creationDate' + : field === 'ModDate' + ? 'modDate' + : (field.toLowerCase() as 'title') + ] = value; + } + } + } + const docRoot = ( + docBody && kid(docBody, 'DocRoot') ? textOf(kid(docBody, 'DocRoot')!) : 'Doc_0/Document.xml' + ).replace(/^\/+/, ''); + const docDir = docRoot.includes('/') ? docRoot.slice(0, docRoot.lastIndexOf('/') + 1) : ''; + + const readXml = (loc: string, relativeToDoc = true): XmlElement | undefined => { + const path = + loc.startsWith('/') || !relativeToDoc ? loc.replace(/^\/+/, '') : resolvePath(docDir, loc); + const bytes = findEntry(entries, path); + return bytes ? parseXml(new TextDecoder('utf-8').decode(bytes)) : undefined; + }; + + const documentXml = readXml(docRoot, false); + if (!documentXml) throw new Error(`OFD: document root '${docRoot}' not found in package`); + + // ---- CommonData: page box + resource locations ---- + const common = kid(documentXml, 'CommonData'); + const pageArea = common ? kid(common, 'PageArea') : undefined; + const boxEl = pageArea + ? (kid(pageArea, 'ApplicationBox') ?? kid(pageArea, 'PhysicalBox')) + : undefined; + const box = nums(boxEl?.attrs.Value ?? boxEl?.text); + const pw = Math.abs((box[2] ?? 0) - (box[0] ?? 0)) || 210; + const ph = Math.abs((box[3] ?? 0) - (box[1] ?? 0)) || 297; + const publicResLoc = + common && kid(common, 'PublicRes') ? textOf(kid(common, 'PublicRes')!) : undefined; + const documentResLoc = + common && kid(common, 'DocumentRes') ? textOf(kid(common, 'DocumentRes')!) : undefined; + + // ---- resources: fonts (public) + multimedia (document) ---- + const fonts: OfdDocument['fonts'] = {}; + const media = new Map(); + const fontLocs = new Map(); + + if (publicResLoc) { + const res = readXml(publicResLoc); + const resBase = resolvePath(docDir, res?.attrs.BaseLoc ?? ''); + if (res) { + const fontsEl = kid(res, 'Fonts'); + for (const f of fontsEl ? kids(fontsEl, 'Font') : []) { + const id = f.attrs.ID ?? ''; + const name = f.attrs.FontName ?? id; + const fileEl = kid(f, 'FontFile'); + const loc = fileEl ? textOf(fileEl) : undefined; + if (loc) { + fontLocs.set(id, { name, loc: resolvePath(resBase, loc) }); + } else { + fonts[id] = { name, data: new Uint8Array(0) }; + } + } + } + } + for (const [id, { name, loc }] of fontLocs) { + const bytes = findEntry(entries, loc); + if (bytes) fonts[id] = { name, data: bytes }; + else limitations.push(`font resource '${loc}' missing from package`); + } + + if (documentResLoc) { + const res = readXml(documentResLoc); + const resBase = resolvePath(docDir, res?.attrs.BaseLoc ?? ''); + const mmEl = res ? kid(res, 'MultiMedias') : undefined; + for (const m of mmEl ? kids(mmEl, 'MultiMedia') : []) { + const id = m.attrs.ID ?? ''; + const fileEl = kid(m, 'MediaFile'); + const loc = fileEl ? textOf(fileEl) : undefined; + if (!loc) continue; + const bytes = findEntry(entries, resolvePath(resBase, loc)); + if (bytes) media.set(id, { format: m.attrs.Format, data: bytes }); + } + } + + // ---- page objects ---- + const pagesEl = kid(documentXml, 'Pages'); + const pageEls = pagesEl ? kids(pagesEl, 'Page') : []; + const pageIdToIndex = new Map(); + pageEls.forEach((p, i) => pageIdToIndex.set(p.attrs.ID ?? String(i + 1), i)); + + const parseObject = (el: XmlElement): OfdObject | undefined => { + if (el.name === 'TextObject') { + return { + kind: 'text', + boundary: boundary(el), + size: Number(el.attrs.Size ?? 0) || 0, + fontId: el.attrs.Font, + fill: el.attrs.Fill !== 'false', + stroke: el.attrs.Stroke === 'true', + color: ofdColor(kid(el, 'FillColor')) ?? { r: 0, g: 0, b: 0, alpha: 1 }, + strokeColor: ofdColor(kid(el, 'StrokeColor')), + alpha: el.attrs.Alpha !== undefined ? Number(el.attrs.Alpha) / 255 : undefined, + ctm: ctm(el), + codes: parseTextCodes(el), + }; + } + if (el.name === 'PathObject') { + let ops: OfdPathOp[] = []; + const abbr = kid(el, 'AbbreviatedData'); + if (abbr) { + try { + ops = parseAbbreviated(textOf(abbr)); + } catch (e) { + limitations.push(`path ops skipped: ${(e as Error).message}`); + } + } + const dash = nums(el.attrs.DashPattern); + return { + kind: 'path', + boundary: boundary(el), + ops, + fill: el.attrs.Fill !== 'false', + stroke: el.attrs.Stroke === 'true', + fillColor: ofdColor(kid(el, 'FillColor')), + strokeColor: ofdColor(kid(el, 'StrokeColor')), + lineWidth: el.attrs.LineWidth !== undefined ? Number(el.attrs.LineWidth) : undefined, + dashPattern: dash.length ? dash : undefined, + alpha: el.attrs.Alpha !== undefined ? Number(el.attrs.Alpha) / 255 : undefined, + ctm: ctm(el), + }; + } + if (el.name === 'ImageObject') { + const resId = el.attrs.ResourceID ?? ''; + const res = media.get(resId); + if (!res && resId) { + limitations.push(`image resource '${resId}' missing from package resources`); + } + return { + kind: 'image', + boundary: boundary(el), + resId, + format: res?.format, + data: res?.data, + alpha: el.attrs.Alpha !== undefined ? Number(el.attrs.Alpha) / 255 : undefined, + ctm: ctm(el), + }; + } + if (el.name !== 'CompositeObject') limitations.push(`page object '${el.name}' skipped`); + return undefined; + }; + + const pages: OfdDocument['pages'] = pageEls.map((p, index) => { + const loc = p.attrs.BaseLoc ?? `Pages/Page_${index}/Content.xml`; + const content = readXml(loc); + // Per-page Area overrides the CommonData template size (GB/T 33190 7.5). + const areaEl = kid(p, 'Area'); + const areaBox = areaEl + ? nums(kid(areaEl, 'PhysicalBox')?.attrs.Value ?? kid(areaEl, 'PhysicalBox')?.text) + : []; + const width = areaBox.length >= 4 ? Math.abs(areaBox[2]! - areaBox[0]!) : pw; + const height = areaBox.length >= 4 ? Math.abs(areaBox[3]! - areaBox[1]!) : ph; + const objects: OfdObject[] = []; + if (content) { + for (const layer of all(content, 'Layer')) { + for (const child of layer.children) { + const obj = parseObject(child); + if (obj) objects.push(obj); + } + } + } else { + limitations.push(`page ${index + 1} content '${loc}' missing from package`); + } + return { index, width, height, objects }; + }); + + // ---- outlines (bookmarks) ---- + let outlines: OfdOutlineNode[] = []; + const outlinesEl = kid(documentXml, 'Outlines'); + if (outlinesEl) { + const parseNode = (el: XmlElement): OfdOutlineNode => { + let pageIndex = 0; + for (const action of all(el, 'Goto')) { + const dest = kid(action, 'Dest'); + if (!dest) continue; + const pageId = + dest.attrs.PageID ?? (kid(dest, 'PageRef') ? kid(dest, 'PageRef')!.attrs.ID : undefined); + if (pageId !== undefined && pageId !== '') { + const idx = pageIdToIndex.get(pageId); + if (idx !== undefined) pageIndex = idx; + } + break; + } + return { + title: el.attrs.Title ?? '', + pageIndex, + children: kids(el, 'Outline').map(parseNode), + }; + }; + outlines = kids(outlinesEl, 'Outline').map(parseNode); + } + + // ---- attachments ---- + const attachments: OfdAttachment[] = []; + const attachmentsLoc = + kid(documentXml, 'Attachments') !== undefined + ? textOf(kid(documentXml, 'Attachments')!) + : undefined; + if (attachmentsLoc) { + const attXml = readXml(attachmentsLoc); + const baseDir = attachmentsLoc.includes('/') + ? attachmentsLoc.slice(0, attachmentsLoc.lastIndexOf('/') + 1) + : ''; + if (attXml) { + for (const a of kids(attXml, 'Attachment')) { + const fileEl = kid(a, 'FileLoc'); + const loc = fileEl ? textOf(fileEl) : undefined; + const bytes = loc + ? findEntry(entries, loc.startsWith('/') ? loc : resolvePath(baseDir, loc)) + : undefined; + attachments.push({ + id: Number(a.attrs.ID ?? 0) || attachments.length + 1, + name: a.attrs.Name ?? '', + format: a.attrs.Format, + data: bytes ?? new Uint8Array(0), + }); + if (!bytes) limitations.push(`attachment '${a.attrs.Name}' file '${loc}' missing`); + } + } + } + + // ---- signatures: list Sign*/Signature.xml entries ---- + const signatures: OfdSignatureInfo[] = []; + for (const name of entries.keys()) { + if (/Signs\/.*Signature\.xml$/i.test(name)) { + signatures.push({ name, baseName: name.slice(name.lastIndexOf('/') + 1) }); + } + } + + return { docType, meta, pages, outlines, attachments, signatures, fonts, limitations }; +} + +/* ---------------- text extraction ---------------- */ + +/** Per-page plain text (TextObject contents in draw order). */ +export function extractText(ofd: OfdDocument): string[] { + return ofd.pages.map((page) => { + const parts: string[] = []; + for (const o of page.objects) { + if (o.kind !== 'text') continue; + for (const code of o.codes) parts.push(code.text); + } + return parts.join(''); + }); +} diff --git a/packages/core/src/ofd-svg.ts b/packages/core/src/ofd-svg.ts new file mode 100644 index 0000000..0963dc6 --- /dev/null +++ b/packages/core/src/ofd-svg.ts @@ -0,0 +1,236 @@ +/** + * OFD page → SVG renderer. + * + * Turns a parsed OfdPage (see ofd-reader.ts) into a standalone SVG string. + * Coordinates stay in millimetres (the OFD native unit); text remains real, + * selectable text (a full-text SVG layer, not a raster), and images embed as + * data URLs. Optional per-object CTMs map to SVG matrix() transforms. + */ + +import type { + OfdColor, + OfdCtm, + OfdImageObj, + OfdObject, + OfdPage, + OfdPathObj, + OfdPathOp, + OfdTextObj, +} from './ofd-reader'; +import { sniffImage } from './image'; + +export interface OfdSvgOptions { + /** Output zoom: 1 = 1 SVG user unit per mm (default). */ + zoom?: number; + /** Page background fill (default '#fff'; empty string disables the rect). */ + background?: string; + /** + * Font family applied to text objects whose embedded font is unknown. + * Per-object families still win when resolvable. + */ + fallbackFont?: string; + /** Render text as real (default true; false = hide text layer). */ + includeText?: boolean; +} + +function fmt(n: number): string { + return String(Math.round(n * 1e4) / 1e4); +} + +function esc(s: string): string { + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +function colorCss(c: OfdColor | undefined, fallback: string): string { + if (!c) return fallback; + const base = `rgb(${c.r},${c.g},${c.b})`; + return c.alpha < 1 ? `rgba(${c.r},${c.g},${c.b},${+c.alpha.toFixed(3)})` : base; +} + +function ctmAttr(ctm: OfdCtm | undefined): string { + if (!ctm) return ''; + return ` transform="matrix(${fmt(ctm.a)} ${fmt(ctm.b)} ${fmt(ctm.c)} ${fmt(ctm.d)} ${fmt(ctm.e)} ${fmt(ctm.f)})"`; +} + +function pathD(ops: OfdPathOp[], origin: { x: number; y: number }): string { + // AbbreviatedData coordinates are relative to the object Boundary origin. + let d = ''; + const px = (n: number): string => fmt(origin.x + n); + const py = (n: number): string => fmt(origin.y + n); + for (const op of ops) { + if (op.op === 'M') d += `M${px(op.x)} ${py(op.y)} `; + else if (op.op === 'L') d += `L${px(op.x)} ${py(op.y)} `; + else if (op.op === 'C') + d += `C${px(op.x1)} ${py(op.y1)} ${px(op.x2)} ${py(op.y2)} ${px(op.x)} ${py(op.y)} `; + else if (op.op === 'Q') d += `Q${px(op.x1)} ${py(op.y1)} ${px(op.x)} ${py(op.y)} `; + else d += 'Z '; + } + return d.trim(); +} + +function imageDataUrl(img: OfdImageObj): string { + const sniffed = img.data ? sniffImage(img.data) : null; + const mime = + sniffed?.format === 'JPEG' + ? 'image/jpeg' + : sniffed?.format === 'GIF' + ? 'image/gif' + : sniffed?.format === 'BMP' + ? 'image/bmp' + : sniffed?.format === 'TIFF' + ? 'image/tiff' + : 'image/png'; + const data = img.data; + if (!data?.length) return ''; + let binary = ''; + const CH = 0x8000; + for (let i = 0; i < data.length; i += CH) { + binary += String.fromCharCode(...data.subarray(i, i + CH)); + } + return `data:${mime};base64,${btoa(binary)}`; +} + +function renderText(o: OfdTextObj, fallbackFont: string): string { + const family = esc(fallbackFont); + const weight = ''; + const sizeAttr = ` font-size="${fmt(o.size)}"`; + const fill = colorCss(o.color, '#000'); + const stroke = o.stroke && o.strokeColor ? colorCss(o.strokeColor, fill) : ''; + const body: string[] = []; + for (const code of o.codes) { + const x = o.boundary.x + code.x; + const y = o.boundary.y + code.y; + if (code.deltaX && code.text.length > 1) { + // Per-glyph start positions — exact DeltaX semantics, no compression. + let cur = x; + let spans = ''; + for (let i = 0; i < code.text.length; i++) { + spans += `${esc(code.text[i]!)}`; + cur += code.deltaX[i] ?? 0; + } + body.push( + `${spans}`, + ); + } else { + const ls = + code.deltaX && code.deltaX.length === 1 ? ` letter-spacing="${fmt(code.deltaX[0]!)}"` : ''; + body.push( + `${esc(code.text)}`, + ); + } + } + return body.join(''); +} + +function renderPath(o: OfdPathObj): string { + if (!o.ops.length) return ''; + const fill = o.fill ? colorCss(o.fillColor, '#000') : 'none'; + const stroke = o.stroke ? colorCss(o.strokeColor, '#000') : undefined; + let extra = ''; + if (o.stroke) { + extra += ` stroke="${stroke}" stroke-width="${fmt(o.lineWidth ?? 0.353)}"`; + if (o.dashPattern?.length) extra += ` stroke-dasharray="${o.dashPattern.map(fmt).join(' ')}"`; + } + return ``; +} + +function renderImage(o: OfdImageObj): string { + if (!o.data?.length) return ''; + const opacity = o.alpha !== undefined && o.alpha < 1 ? ` opacity="${+o.alpha.toFixed(3)}"` : ''; + return ( + `` + ); +} + +function renderObject(o: OfdObject, fallbackFont: string): string { + switch (o.kind) { + case 'text': + return renderText(o, fallbackFont); + case 'path': + return renderPath(o); + case 'image': + return renderImage(o); + } +} + +/** Render one parsed OFD page into a standalone SVG string. */ +export function pageToSvg(page: OfdPage, opts: OfdSvgOptions = {}): string { + const zoom = opts.zoom ?? 1; + const W = page.width; + const H = page.height; + const fallbackFont = esc(opts.fallbackFont ?? 'SimSun, serif'); + const includeText = opts.includeText !== false; + const parts: string[] = [ + ``, + ]; + const bg = opts.background ?? '#fff'; + if (bg) parts.push(``); + for (const o of page.objects) { + if (o.kind === 'text' && !includeText) continue; + const xml = renderObject(o, fallbackFont); + if (xml) parts.push(xml); + } + parts.push(''); + return parts.join(''); +} + +export interface OfdPngResult { + data: Uint8Array; + /** Pixel dimensions. */ + width: number; + height: number; +} + +export interface OfdPngOptions extends OfdSvgOptions { + /** Raster resolution in dots per inch (default 144). */ + dpi?: number; +} + +/** + * Rasterise one parsed OFD page to PNG bytes (15.1.2). + * + * Browser/worker runtimes only — the SVG is decoded into an Image and drawn + * onto a canvas at `dpi`. Falls back to an explicit error in pure-Node + * environments (no canvas implementation bundled). + */ +export async function pageToPng(page: OfdPage, opts: OfdPngOptions = {}): Promise { + const dpi = opts.dpi ?? 144; + const zoom = dpi / 25.4; // px per mm + const svg = pageToSvg(page, { ...opts, zoom }); + const width = Math.max(1, Math.round(page.width * zoom)); + const height = Math.max(1, Math.round(page.height * zoom)); + + const svgBlob = new Blob([svg], { type: 'image/svg+xml;charset=utf-8' }); + const url = URL.createObjectURL(svgBlob); + try { + const img = new Image(); + img.decoding = 'sync'; + const decoded = new Promise((resolve, reject) => { + img.onload = () => resolve(); + img.onerror = () => reject(new Error('ofd-svg: failed to decode page SVG')); + img.src = url; + }); + await decoded; + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext('2d'); + if (!ctx) throw new Error('ofd-svg: canvas 2d context unavailable'); + ctx.drawImage(img, 0, 0, width, height); + URL.revokeObjectURL(url); + const blob: Blob | null = await new Promise((resolve) => + canvas.toBlob((b) => resolve(b), 'image/png'), + ); + if (!blob) throw new Error('ofd-svg: canvas.toBlob returned null'); + const buf = new Uint8Array(await blob.arrayBuffer()); + return { data: buf, width, height }; + } catch (e) { + URL.revokeObjectURL(url); + throw e; + } +} diff --git a/packages/core/src/xml-parse.ts b/packages/core/src/xml-parse.ts new file mode 100644 index 0000000..5a48109 --- /dev/null +++ b/packages/core/src/xml-parse.ts @@ -0,0 +1,193 @@ +/** + * Minimal XML DOM parser — dependency-free, works in Node and browsers. + * + * Sufficient for well-formed machine-generated XML (OFD packages). Namespace + * prefixes are stripped: `` parses as name "TextObject". + */ + +export interface XmlElement { + /** Local tag name (namespace prefix removed). */ + name: string; + attrs: Record; + children: XmlElement[]; + /** Direct text inside this element (entities decoded, whitespace kept). */ + text: string; +} + +const DECODE: Record = { + '<': '<', + '>': '>', + '&': '&', + '"': '"', + ''': "'", +}; + +function decodeEntities(s: string): string { + if (!s.includes('&')) return s; + return s + .replace(/&#x([0-9a-fA-F]+);/g, (_, h) => String.fromCodePoint(parseInt(h, 16))) + .replace(/&#(\d+);/g, (_, d) => String.fromCodePoint(parseInt(d, 10))) + .replace(/&(lt|gt|amp|quot|apos);/g, (m) => DECODE[m] ?? m); +} + +/** Local name of a possibly prefixed tag (`ofd:TextObject` → `TextObject`). */ +function localName(raw: string): string { + const i = raw.indexOf(':'); + return i === -1 ? raw : raw.slice(i + 1); +} + +class P { + i = 0; + constructor(readonly s: string) {} + + /** Skip comments, PIs, doctype and CDATA; CDATA text lands in the return. */ + misc(): string { + let text = ''; + const s = this.s; + for (;;) { + if (s.startsWith('', this.i); + this.i = end === -1 ? s.length : end + 3; + } else if (s.startsWith('', this.i); + text += s.slice(this.i + 9, end === -1 ? s.length : end); + this.i = end === -1 ? s.length : end + 3; + } else if (s[this.i] === '<' && (s[this.i + 1] === '?' || s.startsWith('', this.i); + this.i = end === -1 ? s.length : end + 1; + } else break; + } + return text; + } + + atTag(): boolean { + return this.s[this.i] === '<' && this.s[this.i + 1] !== '!' && this.s[this.i + 1] !== '?'; + } + + eof(): boolean { + this.misc(); + return this.i >= this.s.length; + } + + /** Consume character data up to the next '<' (decoded). */ + takeText(): string { + const next = this.s.indexOf('<', this.i); + const end = next === -1 ? this.s.length : next; + const raw = this.s.slice(this.i, end); + this.i = end; + return decodeEntities(raw); + } + + /** Parse a tag at '<' — returns { name, attrs, selfClosing }. */ + tag(): { name: string; attrs: Record; selfClosing: boolean } { + this.i++; // '<' + const m = /^[^\s/>]+/.exec(this.s.slice(this.i)); + if (!m) throw new Error(`xml: bad tag at offset ${this.i}`); + const name = localName(m[0]); + this.i += m[0].length; + const attrs: Record = {}; + for (;;) { + while (this.i < this.s.length && /\s/.test(this.s[this.i]!)) this.i++; + if (this.s[this.i] === '/') { + this.i++; + if (this.s[this.i] !== '>') throw new Error(`xml: expected '>' at offset ${this.i}`); + this.i++; + return { name, attrs, selfClosing: true }; + } + if (this.s[this.i] === '>') { + this.i++; + return { name, attrs, selfClosing: false }; + } + const am = /^[^\s=/>]+/.exec(this.s.slice(this.i)); + if (!am) throw new Error(`xml: bad attribute at offset ${this.i}`); + const key = am[0]; + this.i += key.length; + while (this.i < this.s.length && /\s/.test(this.s[this.i]!)) this.i++; + if (this.s[this.i] !== '=') throw new Error(`xml: expected '=' at offset ${this.i}`); + this.i++; + while (this.i < this.s.length && /\s/.test(this.s[this.i]!)) this.i++; + const quote = this.s[this.i]; + if (quote !== '"' && quote !== "'") + throw new Error(`xml: bad attribute value at offset ${this.i}`); + this.i++; + const close = this.s.indexOf(quote, this.i); + if (close === -1) throw new Error('xml: unterminated attribute value'); + attrs[localName(key)] = decodeEntities(this.s.slice(this.i, close)); + this.i = close + 1; + } + } + + closeTag(): void { + this.i += 2; // ']+/.exec(this.s.slice(this.i)); + this.i += m ? m[0].length : 0; + while (this.i < this.s.length && /\s/.test(this.s[this.i]!)) this.i++; + if (this.s[this.i] !== '>') throw new Error(`xml: expected '>' at offset ${this.i}`); + this.i++; + } +} + +/** Parse an XML string into a root element. Throws on malformed input. */ +export function parseXml(xml: string): XmlElement { + const src = xml.charCodeAt(0) === 0xfeff ? xml.slice(1) : xml; + const p = new P(src); + const stack: XmlElement[] = []; + let root: XmlElement | null = null; + for (;;) { + const cd = p.misc(); + if (cd) { + const top = stack[stack.length - 1]; + if (top) top.text += cd; + } + if (p.i >= src.length) break; + if (root && stack.length === 0) break; // trailing junk after the root + if (!p.atTag()) { + const top = stack[stack.length - 1]; + const text = p.takeText(); + if (top && text) top.text += text; + continue; + } + if (src.startsWith(' c.name === name) : el.children; +} + +/** First direct child with the given local name. */ +export function kid(el: XmlElement, name: string): XmlElement | undefined { + return el.children.find((c) => c.name === name); +} + +/** All descendants in document order, optionally filtered by local name. */ +export function all(el: XmlElement, name: string): XmlElement[] { + const out: XmlElement[] = []; + const walk = (n: XmlElement): void => { + for (const c of n.children) { + if (c.name === name) out.push(c); + walk(c); + } + }; + walk(el); + return out; +} + +/** Concatenated text of the element subtree, trimmed. */ +export function textOf(el: XmlElement): string { + return (el.text + el.children.map((c) => textOf(c)).join('')).trim(); +} diff --git a/packages/core/src/zip-read.ts b/packages/core/src/zip-read.ts new file mode 100644 index 0000000..7e8ca3d --- /dev/null +++ b/packages/core/src/zip-read.ts @@ -0,0 +1,146 @@ +/** + * Dependency-free ZIP reader. + * + * Reads central-directory listings and entry bytes. STORE entries pass + * through; DEFLATE entries inflate via DecompressionStream('deflate-raw') + * (Node ≥ 18, Chrome ≥ 103, Safari ≥ 16.4). This is the read counterpart of + * zip.ts, which only writes STORE — other OFD producers emit compressed + * packages, so inflate is required for real-world files. + */ + +export interface ZipEntry { + name: string; + data: Uint8Array; +} + +interface CentralRecord { + name: string; + method: number; + compressedSize: number; + uncompressedSize: number; + localOffset: number; +} + +const U16 = (b: Uint8Array, at: number): number => b[at]! | (b[at + 1]! << 8); +const U32 = (b: Uint8Array, at: number): number => + (b[at]! | (b[at + 1]! << 8) | (b[at + 2]! << 16) | (b[at + 3]! << 24)) >>> 0; + +function findEocd(b: Uint8Array): number { + // EOCD is at least 22 bytes; the comment can add up to 65535. + const min = Math.max(0, b.length - 22 - 65535); + for (let i = b.length - 22; i >= min; i--) { + if (U32(b, i) === 0x06054b50) return i; + } + throw new Error('zip: end-of-central-directory not found (not a ZIP file?)'); +} + +function readCentral(b: Uint8Array): CentralRecord[] { + const eocd = findEocd(b); + const count = U16(b, eocd + 10); + const cdStart = U32(b, eocd + 16); + const records: CentralRecord[] = []; + let at = cdStart; + for (let i = 0; i < count; i++) { + if (U32(b, at) !== 0x02014b50) throw new Error('zip: bad central directory record'); + const flags = U16(b, at + 8); + const method = U16(b, at + 10); + const compressedSize = U32(b, at + 20); + const nameLen = U16(b, at + 28); + const extraLen = U16(b, at + 30); + const commentLen = U16(b, at + 32); + const localOffset = U32(b, at + 42); + const rawName = b.subarray(at + 46, at + 46 + nameLen); + const name = new TextDecoder(flags & 0x800 ? 'utf-8' : 'latin1').decode(rawName); + records.push({ name, method, compressedSize, uncompressedSize: U32(b, at + 24), localOffset }); + at += 46 + nameLen + extraLen + commentLen; + } + return records; +} + +async function inflateRaw(data: Uint8Array, expected: number): Promise { + const DS = (globalThis as { DecompressionStream?: typeof DecompressionStream }) + .DecompressionStream; + if (!DS) { + throw new Error( + 'zip: DecompressionStream is unavailable in this runtime; ' + + 'only stored (uncompressed) ZIP entries can be read', + ); + } + const stream = new Blob([data as BlobPart]) + .stream() + .pipeThrough(new DS('deflate-raw') as unknown as ReadableWritablePair); + const buf = await new Response(stream).arrayBuffer(); + const out = new Uint8Array(buf); + if (expected && out.length !== expected) { + throw new Error(`zip: inflate size mismatch (${out.length} vs ${expected})`); + } + return out; +} + +export interface UnzipOptions { + maxEntries?: number; + maxEntryBytes?: number; + maxTotalBytes?: number; + rejectDuplicates?: boolean; +} + +/** Read all file entries of a ZIP package. Directory entries are skipped. */ +export async function unzip( + data: Uint8Array, + options: UnzipOptions = {}, +): Promise> { + const records = readCentral(data); + if (options.maxEntries !== undefined && records.length > options.maxEntries) { + throw new Error('zip: too many entries'); + } + const out = new Map(); + let totalBytes = 0; + for (const rec of records) { + if (rec.name.endsWith('/')) continue; + if (options.rejectDuplicates && out.has(rec.name)) { + throw new Error(`zip: duplicate entry ${rec.name}`); + } + if (options.maxEntryBytes !== undefined && rec.uncompressedSize > options.maxEntryBytes) { + throw new Error(`zip: entry too large ${rec.name}`); + } + const lo = rec.localOffset; + if (U32(data, lo) !== 0x04034b50) throw new Error(`zip: bad local header for ${rec.name}`); + const nameLen = U16(data, lo + 26); + const extraLen = U16(data, lo + 28); + const start = lo + 30 + nameLen + extraLen; + const raw = data.subarray(start, start + rec.compressedSize); + let content: Uint8Array; + if (rec.method === 0) { + content = raw.slice(); + } else if (rec.method === 8) { + content = await inflateRaw(raw, rec.uncompressedSize); + } else { + throw new Error(`zip: unsupported compression method ${rec.method} for ${rec.name}`); + } + totalBytes += content.length; + if (options.maxTotalBytes !== undefined && totalBytes > options.maxTotalBytes) { + throw new Error('zip: total uncompressed size limit exceeded'); + } + out.set(rec.name, content); + } + return out; +} + +/** Look up an entry by path, case-insensitive and tolerant to a leading '/'. */ +export function findEntry(entries: Map, path: string): Uint8Array | undefined { + const norm = path.replace(/^\/+/, ''); + const direct = entries.get(norm) ?? entries.get('/' + norm); + if (direct) return direct; + const lower = norm.toLowerCase(); + for (const [name, bytes] of entries) { + if (name.toLowerCase() === lower || '/' + name.toLowerCase() === '/' + lower) return bytes; + } + return undefined; +} + +/** Resolve a relative path against a base directory (`Doc_0/`, `Pages/`…). */ +export function resolvePath(baseDir: string, loc: string): string { + const clean = loc.replace(/^\/+/, ''); + if (!baseDir) return clean; + return baseDir.replace(/\/+$/, '') + '/' + clean; +} diff --git a/packages/core/tests/crypto-boundary.test.ts b/packages/core/tests/crypto-boundary.test.ts new file mode 100644 index 0000000..03c0f91 --- /dev/null +++ b/packages/core/tests/crypto-boundary.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from 'vitest'; +import { + sm2Generate, + sm2Sign, + sm2SignWithK, + sm2SignatureFromDer, + sm2SignatureToDer, + sm2Verify, +} from '../src/crypto/sm2'; +import { bytesToHex, hexToBytes } from '../src/crypto/sm3'; +import { base64Decode, base64Encode } from '../src/crypto/base64'; + +const N = BigInt('0xFFFFFFFEFFFFFFFFFFFFFFFFFFFFFFFF7203DF6B21C6052B53BBF40939D54123'); +const MSG = new TextEncoder().encode('SM2 boundary tests'); + +function scalar(value: bigint): Uint8Array { + return hexToBytes(value.toString(16).padStart(64, '0')); +} + +describe('SM2 boundary validation', () => { + it('rejects private keys outside the signing range', () => { + expect(() => sm2SignWithK(scalar(0n), MSG, 1n)).toThrow(/private key/i); + expect(() => sm2SignWithK(scalar(N - 1n), MSG, 1n)).toThrow(/private key/i); + expect(() => sm2SignWithK(scalar(N), MSG, 1n)).toThrow(/private key/i); + }); + + it('rejects invalid nonces instead of reducing them modulo N', () => { + const { privateKey } = sm2Generate(); + for (const k of [0n, N, N + 1n, -1n]) { + expect(() => sm2SignWithK(privateKey, MSG, k)).toThrow(/nonce|range/i); + } + }); + + it('rejects malformed keys and signatures', () => { + const { publicKey, privateKey } = sm2Generate(); + const sig = sm2Sign(privateKey, MSG); + const raw = new Uint8Array([...sig.r, ...sig.s]); + expect(sm2Verify(publicKey.subarray(1), MSG, raw)).toBe(false); + expect(sm2Verify(new Uint8Array(65), MSG, raw)).toBe(false); + expect(sm2Verify(publicKey, MSG, new Uint8Array(1))).toBe(false); + }); +}); + +describe('SM2 DER signatures', () => { + it('encodes r and s as a canonical DER sequence', () => { + const r = new Uint8Array(32); + const s = new Uint8Array(32); + r[31] = 1; + s[31] = 2; + const der = sm2SignatureToDer({ r, s }); + expect(bytesToHex(der)).toBe('3006020101020102'); + const decoded = sm2SignatureFromDer(der); + expect(decoded.r).toEqual(r); + expect(decoded.s).toEqual(s); + }); + + it('verifies a DER signature and rejects malformed DER', () => { + const { publicKey, privateKey } = sm2Generate(); + const sig = sm2Sign(privateKey, MSG); + const der = sm2SignatureToDer(sig); + expect(sm2Verify(publicKey, MSG, der)).toBe(true); + expect(() => sm2SignatureFromDer(new Uint8Array([0x30, 0x00]))).toThrow(/DER|SEQUENCE/i); + expect(sm2Verify(publicKey, MSG, new Uint8Array([0x30, 0x00]))).toBe(false); + }); +}); + +describe('strict binary encodings', () => { + it('rejects invalid hexadecimal input', () => { + expect(() => hexToBytes('zz')).toThrow(/hex/i); + expect(() => hexToBytes('00xz')).toThrow(/hex/i); + }); + + it('encodes and decodes Base64 without platform-specific APIs', () => { + const bytes = new Uint8Array([0, 1, 2, 253, 254, 255]); + expect(base64Encode(bytes)).toBe('AAEC/f7/'); + expect(base64Decode('AAEC/f7/')).toEqual(bytes); + expect(() => base64Decode('AA$=')).toThrow(/base64/i); + }); +}); diff --git a/packages/core/tests/crypto.test.ts b/packages/core/tests/crypto.test.ts new file mode 100644 index 0000000..0942072 --- /dev/null +++ b/packages/core/tests/crypto.test.ts @@ -0,0 +1,131 @@ +/** + * 国密算法测试:SM3 官方向量 + SM2 与 sm-crypto 参照实现双向互验。 + */ + +import { describe, expect, it } from 'vitest'; +// @ts-expect-error 参照实现(devDependency,交叉互验用) +import { sm2 as smcrypto } from 'sm-crypto'; +import { sm3, Sm3, kdf, hexToBytes, bytesToHex } from '../src/crypto/sm3'; +import { sm2Generate, sm2Sign, sm2Verify, sm2ScalarMult, sm2IsOnCurve } from '../src/crypto/sm2'; + +const MSG = new TextEncoder().encode('jsOFD 电子签章互验测试'); +const USER_ID = '1234567812345678'; + +describe('sm3', () => { + it('GB/T 32905-2016 标准向量 A.1/A.2', () => { + expect(sm3('abc')).toBe('66c7f0f462eeedd9d1f2d46bdc10e4e24167c4875cf2f7a2297da02b8f4ba8e0'); + expect(sm3('abcd'.repeat(16))).toBe( + 'debe9ff92275b8a138604889c18e5a4d6fdb70e5387e5765293dcba39c0c5732', + ); + }); + + it('长消息(1,000,000 个 "a",官方 A.3 全量)', () => { + const hex = bytesToHex(new Sm3().update(new Uint8Array(1000000).fill(0x61)).digest()); + expect(hex.startsWith('c8aaf89429554029')).toBe(true); + }); + + it('流式与一次性更新一致', () => { + const data = hexToBytes('6162636465661234567890'); + const one = new Sm3().update(data).digest(); + const stream = new Sm3().update(data.subarray(0, 4)).update(data.subarray(4)).digest(); + expect(bytesToHex(one)).toBe(bytesToHex(stream)); + }); + + it('kdf 输出长度', () => { + expect(kdf(new Uint8Array(32), 16).length).toBe(16); + expect(kdf(new Uint8Array(32), 100).length).toBe(100); + }); +}); + +describe('sm2 与 sm-crypto 参照实现互验', () => { + it('曲线:基点在曲线上且 [n]G 为无穷远', () => { + const g = { + x: 0x32c4ae2c1f1981195f9904466a39c9948fe30bbff2660be1715a4589334c74c7n, + y: 0xbc3736a2f4f6779c59bdcee36b692153d0a9877cc62a474002df32e52139f0a0n, + }; + expect(sm2IsOnCurve(g)).toBe(true); + const n = BigInt('0xFFFFFFFEFFFFFFFFFFFFFFFFFFFFFFFF7203DF6B21C6052B53BBF40939D54123'); + expect(sm2ScalarMult(n, g)).toBeNull(); + }); + + it('公钥推导:随机私钥的 [d]G 与 sm-crypto 一致', () => { + for (const d of [ + '01', + '02', + '3945208F7B2144B13F36E38AC6D39F9588AEE0F3E5D4B7AF8A5A8CBB8FDB2068', + ]) { + const dh = d.padStart(64, '0'); + const mine = sm2ScalarMult(BigInt('0x' + dh), { + x: 0x32c4ae2c1f1981195f9904466a39c9948fe30bbff2660be1715a4589334c74c7n, + y: 0xbc3736a2f4f6779c59bdcee36b692153d0a9877cc62a474002df32e52139f0a0n, + })!; + const mineHex = bytesToHex( + hexToBytes( + '04' + mine.x.toString(16).padStart(64, '0') + mine.y.toString(16).padStart(64, '0'), + ), + ); + const ref = smcrypto.getPublicKeyFromPrivateKey(dh); + expect(mineHex).toBe(ref); + } + }); + + it('签名互验①:我的签名 → sm-crypto 验签通过', () => { + const { privateKey, publicKey } = sm2Generate(); + const sig = sm2Sign(privateKey, MSG, USER_ID); + const sigHex = bytesToHex(sig.r) + bytesToHex(sig.s); + expect( + smcrypto.doVerifySignature(Array.from(MSG), sigHex, bytesToHex(publicKey), { + hash: true, + userId: USER_ID, + der: false, + }), + ).toBe(true); + }); + + it('签名互验②:sm-crypto 签名 → 我的验签通过,篡改失败', () => { + const privateKey = '2b8b7a1a6a9c4b8f7c2e1d0a9b8c7d6e5f4a3b2c1d0e9f8a7b6c5d4e3f2a1b0c'; + const pubHex = smcrypto.getPublicKeyFromPrivateKey(privateKey); + const sigHex = smcrypto.doSignature(Array.from(MSG), privateKey, { + hash: true, + userId: USER_ID, + der: false, + }); + expect(sm2Verify(hexToBytes(pubHex), MSG, hexToBytes(sigHex), USER_ID)).toBe(true); + const bad = sigHex.slice(0, -2) + (sigHex.endsWith('00') ? '01' : '00'); + expect(sm2Verify(hexToBytes(pubHex), MSG, hexToBytes(bad), USER_ID)).toBe(false); + }); + + it('签名互验③:我的签名 + 我验签 + 篡改/错钥检测', () => { + const { privateKey, publicKey } = sm2Generate(); + const sig = sm2Sign(privateKey, MSG, USER_ID); + expect(sm2Verify(publicKey, MSG, sig, USER_ID)).toBe(true); + const tampered = new TextEncoder().encode('jsOFD 电子签章互验测试!'); + expect(sm2Verify(publicKey, tampered, sig, USER_ID)).toBe(false); + const other = sm2Generate(); + expect(sm2Verify(other.publicKey, MSG, sig, USER_ID)).toBe(false); + }); + + it('自定义 UserID:签名与验签须使用相同 ID,且与参照实现互通', () => { + const { privateKey, publicKey } = sm2Generate(); + const sig = sm2Sign(privateKey, MSG, 'client-001'); + expect(sm2Verify(publicKey, MSG, sig, 'client-001')).toBe(true); + expect(sm2Verify(publicKey, MSG, sig, 'client-002')).toBe(false); + const sigHex = bytesToHex(sig.r) + bytesToHex(sig.s); + expect( + smcrypto.doVerifySignature(Array.from(MSG), sigHex, bytesToHex(publicKey), { + hash: true, + userId: 'client-001', + der: false, + }), + ).toBe(true); + }); + + it('随机密钥对:100 轮签名/验签全通过', () => { + for (let i = 0; i < 100; i++) { + const { privateKey, publicKey } = sm2Generate(); + const sig = sm2Sign(privateKey, MSG, USER_ID); + if (!sm2Verify(publicKey, MSG, sig, USER_ID)) throw new Error(`round ${i} failed`); + } + expect(true).toBe(true); + }, 60000); +}); diff --git a/packages/core/tests/der.test.ts b/packages/core/tests/der.test.ts new file mode 100644 index 0000000..d2ea683 --- /dev/null +++ b/packages/core/tests/der.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest'; +import { + DEFAULT_DER_LIMITS, + derSequence, + expectDerReencode, + parseDer, + readSequence, +} from '../src/crypto/der'; + +describe('strict DER', () => { + it('encodes and reads a sequence', () => { + const encoded = derSequence([]); + expect(Array.from(encoded)).toEqual([0x30, 0x00]); + const sequence = readSequence(parseDer(encoded), 0, 0); + expect(sequence.valueBlock.value).toHaveLength(0); + expect(() => expectDerReencode(sequence, encoded)).not.toThrow(); + }); + + it('rejects trailing bytes', () => { + const encoded = derSequence([]); + expect(() => parseDer(new Uint8Array([...encoded, 0]))).toThrow(/trailing/i); + }); + + it('rejects non-canonical DER lengths', () => { + expect(() => parseDer(new Uint8Array([0x30, 0x81, 0x00]))).toThrow(/DER|length/i); + expect(() => + parseDer(new Uint8Array([0x30, 0x04, 0x02, 0x81, 0x01, 0x01])), + ).toThrow(/DER|length/i); + }); + + it('enforces byte, depth, and element limits', () => { + const empty = derSequence([]); + expect(() => parseDer(empty, { maxBytes: 1 })).toThrow(/size|limit/i); + + const nested = new Uint8Array([0x30, 0x02, 0x30, 0x00]); + expect(() => parseDer(nested, { maxDepth: 1 })).toThrow(/depth/i); + + const twoIntegers = new Uint8Array([0x30, 0x06, 0x02, 0x01, 0x01, 0x02, 0x01, 0x02]); + expect(() => parseDer(twoIntegers, { maxElements: 2 })).toThrow(/elements|limit/i); + }); + + it('checks sequence type and child counts', () => { + const sequence = parseDer(derSequence([])); + expect(() => readSequence(sequence, 1)).toThrow(/children|length/i); + expect(() => readSequence(sequence, 0, 0)).not.toThrow(); + expect(() => readSequence(parseDer(new Uint8Array([0x02, 0x01, 0x01])), 1)).toThrow( + /sequence/i, + ); + }); + + it('has defensive default limits', () => { + expect(DEFAULT_DER_LIMITS.maxBytes).toBeGreaterThan(0); + expect(DEFAULT_DER_LIMITS.maxDepth).toBeGreaterThan(0); + expect(DEFAULT_DER_LIMITS.maxElements).toBeGreaterThan(0); + }); +}); diff --git a/packages/core/tests/fixtures/ses-v4.ts b/packages/core/tests/fixtures/ses-v4.ts new file mode 100644 index 0000000..922017e --- /dev/null +++ b/packages/core/tests/fixtures/ses-v4.ts @@ -0,0 +1,9 @@ +import { hexToBytes } from '../../src/crypto/sm3'; + +export const SES_V4_SEAL_DER = hexToBytes( + '30818d3071300e1602455302010416054a534f464416085345414c2d30303130420201010c0954657374205365616c30050403010203170d3236303130313030303030305a170d3236303130313030303030305a170d3237313233313233353935395a30111603504e47040489504e47020128020128040301020306082a811ccf550183750309003006020101020102', +); + +export const SES_V4_SIGNATURE_DER = hexToBytes( + '3081f03081cc02010430818d3071300e1602455302010416054a534f464416085345414c2d30303130420201010c0954657374205365616c30050403010203170d3236303130313030303030305a170d3236303130313030303030305a170d3237313233313233353935395a30111603504e47040489504e47020128020128040301020306082a811ccf550183750309003006020101020102180f32303236313031373030303030305a032100000000000000000000000000000000000000000000000000000000000000000016034f4644040304050606082a811ccf550183750309003006020101020102a0050303000102', +); diff --git a/packages/core/tests/helpers/ses-material.ts b/packages/core/tests/helpers/ses-material.ts new file mode 100644 index 0000000..9025d04 --- /dev/null +++ b/packages/core/tests/helpers/ses-material.ts @@ -0,0 +1,56 @@ +import { createSesSeal } from '../../src/crypto/ofd-seal'; +import type { SesSealInfoInput } from '../../src/crypto/ses-v4'; +import { createLocalTestSm2Signer, type Sm2Signer } from '../../src/crypto/signer'; +import { createTestChain, type TestChain } from './test-ca'; + +export interface SesTestMaterial { + chain: TestChain; + eSeal: Uint8Array; + signingCertificate: Uint8Array; + signer: Sm2Signer; + trustOptions: () => { + trustAnchors: Uint8Array[]; + intermediates: Uint8Array[]; + at: Date; + }; +} + +export async function createSesTestMaterial( + picture?: SesSealInfoInput['picture'], +): Promise { + const chain = createTestChain(); + const info: SesSealInfoInput = { + header: { id: 'ES', version: 4, vid: 'JSOFD' }, + esId: 'SEAL-001', + property: { + type: 1, + name: '测试印章', + certList: [chain.leaf.der], + createDate: new Date('2026-01-01T00:00:00Z'), + validStart: new Date('2026-01-01T00:00:00Z'), + validEnd: new Date('2027-01-01T00:00:00Z'), + }, + picture: picture ?? { + type: 'PNG', + data: new Uint8Array([0x89, 0x50, 0x4e, 0x47]), + width: 40, + height: 40, + }, + }; + const eSeal = await createSesSeal({ + info, + makerCertificate: chain.leaf.der, + signer: createLocalTestSm2Signer(chain.leaf.privateKey), + }); + return { + chain, + eSeal, + signingCertificate: chain.leaf.der, + signer: createLocalTestSm2Signer(chain.leaf.privateKey), + trustOptions: () => ({ + trustAnchors: [chain.root.der], + intermediates: [chain.intermediate.der], + at: new Date('2026-10-17T00:00:00Z'), + }), + }; +} diff --git a/packages/core/tests/helpers/test-ca.ts b/packages/core/tests/helpers/test-ca.ts new file mode 100644 index 0000000..4025ef7 --- /dev/null +++ b/packages/core/tests/helpers/test-ca.ts @@ -0,0 +1,139 @@ +import * as asn1js from 'asn1js'; +import { + sm2Generate, + sm2Sign, + sm2SignatureToDer, + type Sm2KeyPair, +} from '../../src/crypto/sm2'; + +const OID_SM2_SIGN = '1.2.156.10197.1.501'; +const OID_SM2_ECC = '1.2.156.10197.1.301'; + +export interface TestCertificate extends Sm2KeyPair { + der: Uint8Array; + subject: string; +} + +export interface IssueCertificateOptions { + subject: string; + subjectKeys: Sm2KeyPair; + issuer: TestCertificate; + notBefore?: Date; + notAfter?: Date; + ca?: boolean; + pathLength?: number; + usage?: 'ca' | 'sign' | 'no-cert-sign'; + unknownCriticalExtension?: boolean; +} + +function seq(...items: asn1js.BaseBlock[]): asn1js.Sequence { + return new asn1js.Sequence({ value: items }); +} + +function name(cn: string): asn1js.Sequence { + return seq( + new asn1js.Set({ + value: [seq(new asn1js.ObjectIdentifier({ value: '2.5.4.3' }), new asn1js.Utf8String({ value: cn }))], + }), + ); +} + +function algorithm(oid: string): asn1js.Sequence { + return seq(new asn1js.ObjectIdentifier({ value: oid })); +} + +function extension(oid: string, critical: boolean, value: Uint8Array): asn1js.Sequence { + const items: asn1js.BaseBlock[] = [new asn1js.ObjectIdentifier({ value: oid })]; + if (critical) items.push(new asn1js.Boolean({ value: true })); + items.push(new asn1js.OctetString({ valueHex: value })); + return seq(...items); +} + +function encoded(block: asn1js.BaseBlock): Uint8Array { + return new Uint8Array(block.toBER()); +} + +export function issueTestCertificate(options: IssueCertificateOptions): TestCertificate { + const notBefore = options.notBefore ?? new Date('2026-01-01T00:00:00Z'); + const notAfter = options.notAfter ?? new Date('2027-01-01T00:00:00Z'); + const usage = options.usage ?? (options.ca ? 'ca' : 'sign'); + const basicConstraints = seq( + new asn1js.Boolean({ value: options.ca ?? false }), + ...(options.pathLength === undefined ? [] : [asn1js.Integer.fromBigInt(options.pathLength)]), + ); + const keyUsageBits = usage === 'sign' || usage === 'no-cert-sign' ? 0xc0 : 0x06; + const keyUsageUnused = usage === 'sign' || usage === 'no-cert-sign' ? 6 : 1; + const extensions = [ + extension('2.5.29.19', true, encoded(basicConstraints)), + extension( + '2.5.29.15', + true, + encoded(new asn1js.BitString({ valueHex: new Uint8Array([keyUsageBits]), unusedBits: keyUsageUnused })), + ), + ]; + if (options.unknownCriticalExtension) { + extensions.push(extension('1.3.6.1.4.1.55555.1', true, new Uint8Array([1]))); + } + const serialBytes = new Uint8Array(8); + crypto.getRandomValues(serialBytes); + const serial = BigInt('0x' + Array.from(serialBytes, (b) => b.toString(16).padStart(2, '0')).join('')); + const tbs = seq( + new asn1js.Constructed({ idBlock: { tagClass: 3, tagNumber: 0 }, value: [asn1js.Integer.fromBigInt(2)] }), + asn1js.Integer.fromBigInt(serial), + algorithm(OID_SM2_SIGN), + name(options.issuer.subject), + seq(new asn1js.UTCTime({ valueDate: notBefore }), new asn1js.UTCTime({ valueDate: notAfter })), + name(options.subject), + seq( + algorithm(OID_SM2_ECC), + new asn1js.BitString({ valueHex: options.subjectKeys.publicKey }), + ), + new asn1js.Constructed({ idBlock: { tagClass: 3, tagNumber: 3 }, value: [seq(...extensions)] }), + ); + const tbsDer = encoded(tbs); + const signature = sm2SignatureToDer(sm2Sign(options.issuer.privateKey, tbsDer)); + const der = encoded( + seq(tbs, algorithm(OID_SM2_SIGN), new asn1js.BitString({ valueHex: signature })), + ); + return { + der, + subject: options.subject, + privateKey: options.subjectKeys.privateKey, + publicKey: options.subjectKeys.publicKey, + }; +} + +export function createTestRoot(subject = 'Test Root', pathLength?: number): TestCertificate { + const keys = sm2Generate(); + const placeholder = { ...keys, der: new Uint8Array(), subject } as TestCertificate; + return issueTestCertificate({ + subject, + subjectKeys: keys, + issuer: placeholder, + ca: true, + pathLength, + }); +} + +export interface TestChain { + root: TestCertificate; + intermediate: TestCertificate; + leaf: TestCertificate; +} + +export function createTestChain(): TestChain { + const root = createTestRoot('Test Root', 1); + const intermediate = issueTestCertificate({ + subject: 'Test Intermediate', + subjectKeys: sm2Generate(), + issuer: root, + ca: true, + pathLength: 0, + }); + const leaf = issueTestCertificate({ + subject: 'Test Signer', + subjectKeys: sm2Generate(), + issuer: intermediate, + }); + return { root, intermediate, leaf }; +} diff --git a/packages/core/tests/manual/ses-interop.test.ts b/packages/core/tests/manual/ses-interop.test.ts new file mode 100644 index 0000000..33a6728 --- /dev/null +++ b/packages/core/tests/manual/ses-interop.test.ts @@ -0,0 +1,100 @@ +import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { jsOFD } from '../../src/jsofd'; +import { createSesSeal } from '../../src/crypto/ofd-seal'; +import { signOfd } from '../../src/crypto/ofd-sign'; +import { verifyOfd } from '../../src/crypto/ofd-verify'; +import { createLocalTestSm2Signer } from '../../src/crypto/signer'; +import { hexToBytes } from '../../src/crypto/sm3'; +import { unzip } from '../../src/zip-read'; +import { zipStore } from '../../src/zip'; + +const directory = process.env.JSOFD_SES_TEST_DIR; +const itWithMaterials = directory ? it : it.skip; + +function sampleDoc(): Uint8Array { + const doc = new jsOFD({ unit: 'mm', format: 'a4' }); + doc.text('jsOFD SES interoperability', 20, 20); + return doc.output('uint8array') as Uint8Array; +} + +describe('external SES interoperability materials', () => { + itWithMaterials('generates single, multiple, and tampered packages', async () => { + const root = directory!; + const read = (name: string): Uint8Array => readFileSync(join(root, name)); + const readKey = (name: string): Uint8Array => + hexToBytes(readFileSync(join(root, name), 'utf8').trim()); + const optional = (name: string): Uint8Array | undefined => { + try { + return read(name); + } catch { + return undefined; + } + }; + const makerRoot = optional('maker-root.der') ?? read('root.der'); + const signerRoot = optional('signer-root.der') ?? read('root.der'); + const makerIntermediate = optional('maker-intermediate.der') ?? optional('intermediate.der'); + const signerIntermediate = optional('signer-intermediate.der') ?? optional('intermediate.der'); + const makerCertificate = read('maker.der'); + const signingCertificate = read('signer.der'); + const now = new Date(); + const day = 86400000; + const eSeal = await createSesSeal({ + info: { + header: { id: 'ES', version: 4, vid: 'JSOFD' }, + esId: `JSOFD-${now.getTime()}`, + property: { + type: 1, + name: 'jsOFD Interop Seal', + certList: [signingCertificate], + createDate: now, + validStart: new Date(now.getTime() - day), + validEnd: new Date(now.getTime() + 365 * day), + }, + picture: { + type: 'PNG', + data: read('seal.png'), + width: 40, + height: 40, + }, + }, + makerCertificate, + signer: createLocalTestSm2Signer(readKey('maker-key.hex')), + }); + const options = { + eSeal, + signingCertificate, + signer: createLocalTestSm2Signer(readKey('signer-key.hex')), + stamp: { page: 1, x: 120, y: 240, w: 40, h: 40 }, + }; + const trustOptions = { + trustAnchors: [...new Map([makerRoot, signerRoot].map((der) => [der.join(','), der])).values()], + intermediates: [makerIntermediate, signerIntermediate].filter( + (der): der is Uint8Array => Boolean(der), + ), + at: now, + }; + const single = await signOfd(sampleDoc(), options); + const multiple = await signOfd(single, options); + const singleReport = await verifyOfd(single, trustOptions); + const multipleReport = await verifyOfd(multiple, trustOptions); + expect(singleReport.signatures.map((item) => item.status)).toEqual(['valid']); + expect(multipleReport.signatures.map((item) => item.status)).toEqual(['valid', 'valid']); + + const entries = await unzip(single); + const content = entries.get('Doc_0/Pages/Page_0/Content.xml')!.slice(); + content[content.length - 2] ^= 1; + entries.set('Doc_0/Pages/Page_0/Content.xml', content); + const tampered = zipStore([...entries].map(([name, data]) => ({ name, data }))); + const tamperedReport = await verifyOfd(tampered, trustOptions); + expect(tamperedReport.signatures[0]!.status).toBe('invalid'); + + const output = mkdtempSync(join(tmpdir(), 'jsofd-ses-interop-')); + writeFileSync(join(output, 'single.ofd'), single); + writeFileSync(join(output, 'multiple.ofd'), multiple); + writeFileSync(join(output, 'tampered.ofd'), tampered); + console.info(`SES interoperability samples written to ${output}`); + }); +}); diff --git a/packages/core/tests/ofd-encryption.test.ts b/packages/core/tests/ofd-encryption.test.ts new file mode 100644 index 0000000..b1fb064 --- /dev/null +++ b/packages/core/tests/ofd-encryption.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest'; +import { jsOFD } from '../src/jsofd'; +import { decryptOfdPassword, encryptOfdPassword } from '../src/crypto/ofd-encryption'; +import { unzip } from '../src/zip-read'; +import { parseXml } from '../src/xml-parse'; + +function sampleDoc(): Uint8Array { + const doc = new jsOFD({ unit: 'mm', format: 'a4' }); + doc.text('需要保密的 OFD 内容', 20, 20); + return doc.output('uint8array') as Uint8Array; +} + +async function expectSameEntries(actual: Uint8Array, expected: Uint8Array): Promise { + const a = await unzip(actual); + const e = await unzip(expected); + expect([...a.keys()].sort()).toEqual([...e.keys()].sort()); + for (const [name, data] of e) expect(a.get(name)).toEqual(data); +} + +describe('OFD password encryption', () => { + it('writes the standard encryption files and removes full-package plaintext', async () => { + const encrypted = await encryptOfdPassword(sampleDoc(), { + userName: 'demo', + password: '12345678', + }); + const entries = await unzip(encrypted); + expect(entries.has('Encryptions.xml')).toBe(true); + expect(entries.has('decryptseed.dat')).toBe(true); + expect(entries.has('entriesmap.dat')).toBe(true); + expect(entries.has('OFD.xml')).toBe(false); + expect(entries.has('Doc_0/Pages/Page_0/Content.xml')).toBe(false); + + const seed = parseXml(new TextDecoder().decode(entries.get('decryptseed.dat'))); + expect(seed.name).toBe('DecyptSeed'); + expect(seed.attrs.EncryptCaseId).toBe('1.1.1'); + expect(seed.children[0]?.attrs.UserName).toBe('demo'); + expect(new TextDecoder().decode(entries.get('entriesmap.dat'))).not.toContain('EncryptEntries'); + }); + + it('round-trips the complete package and rejects a wrong password', async () => { + const original = sampleDoc(); + const encrypted = await encryptOfdPassword(original, { + userName: 'demo', + password: '12345678', + }); + const decrypted = await decryptOfdPassword(encrypted, { + userName: 'demo', + password: '12345678', + }); + await expectSameEntries(decrypted, original); + await expect( + decryptOfdPassword(encrypted, { userName: 'demo', password: 'wrong-password' }), + ).rejects.toThrow(/decrypt|password|padding|XML/i); + }); + + it('supports partial encryption without changing unselected plaintext', async () => { + const original = sampleDoc(); + const encrypted = await encryptOfdPassword(original, { + userName: 'demo', + password: '12345678', + filter: (name) => name === 'Doc_0/Pages/Page_0/Content.xml', + }); + const entries = await unzip(encrypted); + expect(entries.has('OFD.xml')).toBe(true); + expect(entries.has('Doc_0/Document.xml')).toBe(true); + expect(entries.has('Doc_0/Pages/Page_0/Content.xml')).toBe(false); + const decrypted = await decryptOfdPassword(encrypted, { + userName: 'demo', + password: '12345678', + }); + await expectSameEntries(decrypted, original); + }); +}); diff --git a/packages/core/tests/ofd-package.test.ts b/packages/core/tests/ofd-package.test.ts new file mode 100644 index 0000000..1ea129e --- /dev/null +++ b/packages/core/tests/ofd-package.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from 'vitest'; +import { + collectProtectedEntries, + readOfdPackage, + resolveOfdPath, + selectDocBody, +} from '../src/crypto/ofd-package'; +import { + buildSignatureXml, + updateSignatureList, +} from '../src/crypto/ofd-signature-xml'; +import { parseXml, textOf } from '../src/xml-parse'; +import { utf8Encode } from '../src/utils'; +import { zipStore } from '../src/zip'; + +describe('OFD signature package paths', () => { + it('resolves absolute and relative package locations safely', () => { + expect(resolveOfdPath('Signs/Sign_0/Signature.xml', 'SignedValue.dat')).toBe( + 'Signs/Sign_0/SignedValue.dat', + ); + expect(resolveOfdPath('Signs/Sign_0/Signature.xml', '/OFD.xml')).toBe('OFD.xml'); + expect(resolveOfdPath('Doc_0/Document.xml', '../OFD.xml')).toBe('OFD.xml'); + expect(() => resolveOfdPath('Doc_0/Document.xml', '../../etc/passwd')).toThrow(/path|traversal/i); + expect(() => resolveOfdPath('Doc_0/Document.xml', 'C:\\secret')).toThrow(/path/i); + }); + + it('selects a DocBody and existing signatures entry', () => { + const entries = new Map([ + ['OFD.xml', utf8Encode(`Doc_0/Document.xmlDoc_1/Document.xmlDoc_1/Signs/Signatures.xml`)], + ]); + const body = selectDocBody(entries, 1); + expect(body.docRoot).toBe('Doc_1/Document.xml'); + expect(body.signaturesLoc).toBe('Doc_1/Signs/Signatures.xml'); + expect(() => selectDocBody(entries, 2)).toThrow(/DocBody|index/i); + }); + + it('excludes only the signature list when additional signatures are allowed', () => { + const entries = new Map([ + ['OFD.xml', new Uint8Array([1])], + ['Signs/Signatures.xml', new Uint8Array([2])], + ['Signs/Sign_0/Signature.xml', new Uint8Array([3])], + ['Signs/Sign_0/SignedValue.dat', new Uint8Array([4])], + ]); + const incremental = collectProtectedEntries(entries, 'Signs/Signatures.xml', true); + expect(incremental.map((entry) => entry.name)).toEqual([ + 'OFD.xml', + 'Signs/Sign_0/Signature.xml', + 'Signs/Sign_0/SignedValue.dat', + ]); + const complete = collectProtectedEntries(entries, 'Signs/Signatures.xml', false); + expect(complete.map((entry) => entry.name)).toContain('Signs/Signatures.xml'); + }); + + it('rejects duplicate ZIP entries and unsafe package names', async () => { + const duplicate = zipStore([ + { name: 'OFD.xml', data: utf8Encode('') }, + { name: 'OFD.xml', data: utf8Encode('') }, + ]); + await expect(readOfdPackage(duplicate)).rejects.toThrow(/duplicate/i); + const traversal = zipStore([{ name: '../secret', data: new Uint8Array([1]) }]); + await expect(readOfdPackage(traversal)).rejects.toThrow(/path/i); + }); +}); + +describe('OFD signature XML', () => { + it('builds standard references, seal, and stamp appearance with escaping', () => { + const bytes = buildSignatureXml({ + provider: { name: 'A&B ', company: '测试', version: '1' }, + signatureMethod: '1.2.156.10197.1.501', + signatureDateTime: '20261017120000', + checkMethod: '1.2.156.10197.1.401', + references: [{ fileRef: '/OFD.xml', checkValue: 'AAEC/f7/' }], + stamp: { pageRef: '1', id: 's001', boundary: '120 240 40 40' }, + sealLoc: '/Signs/Sign_0/Seal.esl', + signedValueLoc: '/Signs/Sign_0/SignedValue.dat', + }); + const xml = new TextDecoder().decode(bytes); + expect(xml).toContain('A&B <Provider>'); + const root = parseXml(xml); + expect(root.name).toBe('Signature'); + expect(root.children.find((child) => child.name === 'SignedValue')?.text).toBe( + '/Signs/Sign_0/SignedValue.dat', + ); + const references = root.children[0]?.children.find((child) => child.name === 'References'); + expect(references?.attrs.CheckMethod).toBe('1.2.156.10197.1.401'); + expect(textOf(references!.children[0]!.children[0]!)).toBe('AAEC/f7/'); + const stamp = root.children[0]?.children.find((child) => child.name === 'StampAnnot'); + expect(stamp?.attrs).toMatchObject({ PageRef: '1', ID: 's001', Boundary: '120 240 40 40' }); + }); + + it('updates signature lists with either prefixed or default namespaces', () => { + const updated = updateSignatureList({ + entry: { id: 's002', type: 'Seal', baseLoc: '/Signs/Sign_1/Signature.xml' }, + maxSignId: 's003', + }); + const root = parseXml(new TextDecoder().decode(updated)); + expect(root.name).toBe('Signatures'); + expect(root.children[0]?.attrs).toMatchObject({ ID: 's002', Type: 'Seal' }); + expect(root.children.find((child) => child.name === 'MaxSignId')?.text).toBe('s003'); + + const existing = utf8Encode(`s002`); + const merged = new TextDecoder().decode( + updateSignatureList({ + existing, + entry: { id: 's002', type: 'Seal', baseLoc: '/Signs/Sign_1/Signature.xml' }, + maxSignId: 's003', + }), + ); + expect(merged).toContain(' child.name === 'Signature')).toHaveLength(2); + }); +}); diff --git a/packages/core/tests/ofd-reader.test.ts b/packages/core/tests/ofd-reader.test.ts new file mode 100644 index 0000000..da5c1a0 --- /dev/null +++ b/packages/core/tests/ofd-reader.test.ts @@ -0,0 +1,238 @@ +/** + * OFD 读取器测试:jsOFD 生成 → parseOfd 解析 → 断言(往返一致), + * 外加手写畸形包的容错用例。 + */ + +import { describe, expect, it } from 'vitest'; +import { jsOFD } from '../src/jsofd'; +import { parseOfd, extractText } from '../src/ofd-reader'; +import { openOfd } from '../src/ofd-open'; +import { pageToSvg, pageToPng } from '../src/ofd-svg'; +import type { OfdDocument } from '../src/ofd-reader'; +import type { TextRun } from '../src/model'; +import { zipStore } from '../src/zip'; +import { utf8Encode } from '../src/utils'; + +async function sampleOfd(): Promise<{ ofd: OfdDocument; bytes: Uint8Array }> { + const doc = new jsOFD({ unit: 'mm', format: 'a4' }); + doc.setProperties({ title: '对照样张', author: '王小明', subject: '往返测试' }); + doc.setFont('simsun'); + doc.setFontSize(12); + doc.text('你好,OFD', 30, 40); + doc.setFont('helvetica'); + doc.text('Hello Reader', 30, 60); + doc.setDrawColor(255, 0, 0); + doc.setLineWidth(0.5); + doc.line(20, 70, 180, 70); + doc.addPage('a5'); + doc.text('第二页', 20, 30); + doc.outline.add(null, '第一页', { pageNumber: 1 }); + doc.outline.add(null, '第二页', { pageNumber: 2 }); + const png = new Uint8Array([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0, 1, 0, 0, + 0, 1, 8, 6, 0, 0, 0, 31, 21, 196, 137, 0, 0, 0, 10, 73, 68, 65, 84, 120, 156, 99, 0, 1, 0, 0, 5, + 0, 1, 13, 10, 45, 180, 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130, + ]); + doc.addImage(png, 'PNG', 100, 100, 20, 20); + doc.addFileAsAttachment('清单.txt', utf8Encode('附件内容')); + const bytes = doc.output('uint8array') as Uint8Array; + const ofd = await parseOfd(bytes); + return { ofd, bytes }; +} + +describe('parseOfd', () => { + it('往返:元数据与 DocType', async () => { + const { ofd } = await sampleOfd(); + expect(ofd.docType).toBe('OFD'); + expect(ofd.meta.title).toBe('对照样张'); + expect(ofd.meta.author).toBe('王小明'); + expect(ofd.meta.subject).toBe('往返测试'); + expect(ofd.meta.docId).toBeTruthy(); + }); + + it('往返:页面尺寸与页数(A4 + A5)', async () => { + const { ofd } = await sampleOfd(); + expect(ofd.pages.length).toBe(2); + expect(ofd.pages[0]!.width).toBeCloseTo(210, 0); + expect(ofd.pages[0]!.height).toBeCloseTo(297, 0); + expect(ofd.pages[1]!.width).toBeCloseTo(148, 0); + expect(ofd.pages[1]!.height).toBeCloseTo(210, 0); + }); + + it('往返:文本提取(中英文、跨页)', async () => { + const { ofd } = await sampleOfd(); + const texts = extractText(ofd); + expect(texts[0]).toContain('你好,OFD'); + expect(texts[0]).toContain('Hello Reader'); + expect(texts[1]).toContain('第二页'); + }); + + it('往返:文本对象解析(位置/字号/颜色/字符数)', async () => { + const { ofd } = await sampleOfd(); + const texts = ofd.pages[0]!.objects.filter((o) => o.kind === 'text'); + const zh = texts.find((o) => o.kind === 'text' && o.codes.some((c) => c.text.includes('你好'))); + expect(zh).toBeDefined(); + if (zh?.kind !== 'text') return; + expect(zh.size).toBeGreaterThan(0); + expect(zh.color).toEqual({ r: 0, g: 0, b: 0, alpha: 1 }); + expect(zh.boundary.x).toBeGreaterThan(0); + expect(zh.codes.length).toBeGreaterThanOrEqual(1); + }); + + it('往返:路径对象(线段的 AbbreviatedData)', async () => { + const { ofd } = await sampleOfd(); + const paths = ofd.pages[0]!.objects.filter((o) => o.kind === 'path'); + expect(paths.length).toBeGreaterThanOrEqual(1); + const line = paths[0]!; + if (line.kind !== 'path') return; + expect(line.stroke).toBe(true); + expect(line.ops[0]?.op).toBe('M'); + expect(line.ops[1]).toMatchObject({ op: 'L' }); + }); + + it('往返:图片对象字节一致(位于第二页)', async () => { + const { ofd } = await sampleOfd(); + const img = ofd.pages[1]!.objects.find((o) => o.kind === 'image'); + expect(img).toBeDefined(); + if (img?.kind !== 'image') return; + expect(img.data?.[0]).toBe(0x89); + expect(img.data?.length).toBeGreaterThan(50); + expect(img.format ?? 'PNG').toBe('PNG'); + }); + + it('往返:大纲(书签→页索引)', async () => { + const { ofd } = await sampleOfd(); + expect(ofd.outlines.length).toBe(2); + expect(ofd.outlines[0]).toMatchObject({ title: '第一页', pageIndex: 0 }); + expect(ofd.outlines[1]).toMatchObject({ title: '第二页', pageIndex: 1 }); + }); + + it('往返:附件字节一致', async () => { + const { ofd } = await sampleOfd(); + expect(ofd.attachments.length).toBe(1); + expect(ofd.attachments[0]!.name).toBe('清单.txt'); + expect(new TextDecoder().decode(ofd.attachments[0]!.data)).toBe('附件内容'); + }); + + it('容错:手写畸形包(压缩存储 + 未知对象 + 缺失资源)不抛错', async () => { + // 手工构造一个最小但含"噪音"的包:DEFLATE 压缩之外的场景用 STORE, + // 内容里放一个未知对象(EllipseObject)和指向缺失资源的图片。 + const content = + `\n` + + `` + + `` + + `容错` + + `` + + `` + + ``; + const documentXml = + `\n` + + `` + + `0 0 210 297` + + `PublicRes.xml` + + `` + + ``; + const publicRes = + `\n` + + `` + + `` + + ``; + const ofdXml = + `\n` + + `` + + `h` + + `Doc_0/Document.xml`; + const files = [ + { name: 'OFD.xml', data: utf8Encode(ofdXml) }, + { name: 'Doc_0/Document.xml', data: utf8Encode(documentXml) }, + { name: 'Doc_0/PublicRes.xml', data: utf8Encode(publicRes) }, + { name: 'Doc_0/Pages/Page_0/Content.xml', data: utf8Encode(content) }, + ]; + const bytes = zipStore(files); + const ofd = await parseOfd(bytes); + expect(ofd.docType).toBe('OFD-H'); + expect(ofd.pages[0]!.objects.length).toBe(2); // EllipseObject 被跳过并记录 + expect(ofd.limitations.join(' ')).toContain('EllipseObject'); + expect(ofd.limitations.join(' ')).toContain('img-missing'); + const texts = extractText(ofd); + expect(texts[0]).toContain('容错'); + // 未知字体 f1 在 PublicRes 中声明但没有 FontFile → 名字可读、无字节 + expect(ofd.fonts['f1']).toMatchObject({ name: 'FakeFont' }); + expect(ofd.fonts['f1']!.data.length).toBe(0); + }); + + it('非 OFD 输入直接报错', async () => { + await expect(parseOfd(utf8Encode('not a zip'))).rejects.toThrow(); + }); +}); + +describe('openOfd', () => { + it('打开后可编辑并再次序列化(二次往返)', async () => { + const { bytes } = await sampleOfd(); + const doc = await openOfd(bytes); + expect(doc.getNumberOfPages()).toBe(2); + expect(doc.pages[0]!.width).toBeCloseTo(595.28, 1); // 210mm → pt + // 文本对象映射到 pt 坐标(30mm ≈ 85.04pt) + const texts = doc.pages[0]!.objects.filter((o): o is TextRun => o.t === 'text'); + const zh = texts.find((t) => t.text.includes('你好')); + expect(zh).toBeDefined(); + expect(zh!.x).toBeCloseTo(30 / MM, 1); + expect(zh!.size).toBeCloseTo(12, 1); // 字号恒为 pt(mm 文档里 setFontSize(12) 也是 12pt) + // 字体注册:嵌入字体以 ofd_ 前缀注册,中文回退 simsun + expect(zh!.fontKey === 'simsun' || zh!.fontKey.startsWith('ofd_')).toBe(true); + + // 二次序列化 → 重新解析 → 内容仍在 + const again = await parseOfd(doc.output('uint8array') as Uint8Array); + expect(extractText(again)[0]).toContain('你好,OFD'); + expect(extractText(again)[1]).toContain('第二页'); + expect(again.outlines.map((o) => o.title)).toEqual(['第一页', '第二页']); + expect(again.attachments[0]?.name).toBe('清单.txt'); + }); + + it('大纲映射为 doc.outline,附件映射为 attachments', async () => { + const { bytes } = await sampleOfd(); + const doc = await openOfd(bytes); + expect(doc.outlineRoot.children.map((n) => n.title)).toEqual(['第一页', '第二页']); + expect(doc.attachments.length).toBe(1); + }); +}); + +describe('pageToPng', () => { + it('纯 Node 环境给出明确错误', async () => { + const { ofd } = await sampleOfd(); + // 测试运行环境无 DOM/canvas —— 要么拒绝(Node),要么成功(浏览器模式) + try { + const result = await pageToPng(ofd.pages[0]!, { dpi: 72 }); + expect(result.width).toBeGreaterThan(0); + expect(result.data[0]).toBe(0x89); + } catch (e) { + expect((e as Error).message).toMatch(/canvas|decode|Image/); + } + }); +}); + +// 25.4 / 72 +const MM = 25.4 / 72; + +describe('pageToSvg', () => { + it('渲染文本/路径/图片为 SVG 元素', async () => { + const { ofd } = await sampleOfd(); + const svg = pageToSvg(ofd.pages[0]!); + expect(svg.startsWith(' { + const { ofd } = await sampleOfd(); + const zoomed = pageToSvg(ofd.pages[0]!, { zoom: 2 }); + expect(zoomed).toContain('width="420.00"'); + const noText = pageToSvg(ofd.pages[0]!, { includeText: false }); + expect(noText).not.toContain(' { + chain = createTestChain(); +}); + +function sealInfo(): SesSealInfoInput { + return { + header: { id: 'ES', version: 4, vid: 'JSOFD' }, + esId: 'SEAL-001', + property: { + type: 1, + name: '测试印章', + certList: [chain.leaf.der], + createDate: new Date('2026-01-01T00:00:00Z'), + validStart: new Date('2026-01-01T00:00:00Z'), + validEnd: new Date('2027-01-01T00:00:00Z'), + }, + picture: { type: 'PNG', data: PNG, width: 40, height: 40 }, + }; +} + +async function makeSeal(info = sealInfo()): Promise { + return createSesSeal({ + info, + makerCertificate: chain.leaf.der, + signer: createLocalTestSm2Signer(chain.leaf.privateKey), + }); +} + +function trustOptions() { + return { + trustAnchors: [chain.root.der], + intermediates: [chain.intermediate.der], + at: AT, + }; +} + +describe('SES electronic seal', () => { + it('creates a seal, verifies its maker signature and trust, and extracts the picture', async () => { + const der = await makeSeal(); + const seal = parseSesSeal(der); + expect(seal.makerCertificate).toEqual(chain.leaf.der); + const result = await verifySesSeal(der, trustOptions()); + expect(result.valid).toBe(true); + expect(result.signatureValid).toBe(true); + expect(result.trust.status).toBe('trusted'); + expect(result.validity).toBe(true); + expect(result.picture?.data).toEqual(PNG); + }); + + it('detects eSealInfo tampering', async () => { + const der = await makeSeal(); + const seal = parseSesSeal(der); + seal.info.property.name = '被篡改的印章'; + const tampered = await verifySesSeal(seal.info.rawDer.length ? der.slice() : der, trustOptions()); + expect(tampered.valid).toBe(true); + + const { encodeSesSeal } = await import('../src/crypto/ses-v4'); + const changed = encodeSesSeal(seal); + const result = await verifySesSeal(changed, trustOptions()); + expect(result.signatureValid).toBe(false); + expect(result.valid).toBe(false); + }); + + it('separates mathematical validity from certificate trust', async () => { + const der = await makeSeal(); + const result = await verifySesSeal(der, { + trustAnchors: [createTestRoot('Wrong Root').der], + intermediates: [chain.intermediate.der], + at: AT, + }); + expect(result.signatureValid).toBe(true); + expect(result.trust.status).toBe('untrusted'); + expect(result.valid).toBe(false); + }); + + it('rejects expired seals and unknown critical extensions', async () => { + const expiredInfo = sealInfo(); + expiredInfo.property.validEnd = new Date('2026-02-01T00:00:00Z'); + const expired = await verifySesSeal(await makeSeal(expiredInfo), trustOptions()); + expect(expired.validity).toBe(false); + expect(expired.valid).toBe(false); + + const extendedInfo = { ...sealInfo(), extensions: CRITICAL_EXTENSIONS }; + const extended = await verifySesSeal(await makeSeal(extendedInfo), trustOptions()); + expect(extended.valid).toBe(false); + expect(extended.reasons.join(' ')).toMatch(/critical/i); + }); +}); diff --git a/packages/core/tests/package.test.ts b/packages/core/tests/package.test.ts index 7e3f00b..e6285d5 100644 --- a/packages/core/tests/package.test.ts +++ b/packages/core/tests/package.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { describe, expect, it } from 'vitest'; import { jsOFD } from '../src/jsofd'; +import * as api from '../src/index'; import { readZip, text } from './helpers/zipreader'; function buildSample(): Uint8Array { @@ -148,3 +149,12 @@ describe('OFD 包结构(GB/T 33190-2016)', () => { } }); }); + +describe('公共 SES API', () => { + it('从包入口导出签章、验签、制章和测试签名器', () => { + expect(typeof api.signOfd).toBe('function'); + expect(typeof api.verifyOfd).toBe('function'); + expect(typeof api.createSesSeal).toBe('function'); + expect(typeof api.createLocalTestSm2Signer).toBe('function'); + }); +}); diff --git a/packages/core/tests/ses-v4.test.ts b/packages/core/tests/ses-v4.test.ts new file mode 100644 index 0000000..9a03284 --- /dev/null +++ b/packages/core/tests/ses-v4.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest'; +import { + encodeSesSeal, + encodeSesSignature, + parseSesSeal, + parseSesSignature, +} from '../src/crypto/ses-v4'; +import { SES_V4_SEAL_DER, SES_V4_SIGNATURE_DER } from './fixtures/ses-v4'; + +describe('SES V4 DER', () => { + it('parses and re-encodes an electronic seal byte-for-byte', () => { + const seal = parseSesSeal(SES_V4_SEAL_DER); + expect(seal.info.header.id).toBe('ES'); + expect(seal.info.header.version).toBe(4); + expect(seal.info.header.vid).toBe('JSOFD'); + expect(seal.info.esId).toBe('SEAL-001'); + expect(seal.info.property.name).toBe('Test Seal'); + expect(seal.info.property.certList).toHaveLength(1); + expect(seal.info.picture.type).toBe('PNG'); + expect(seal.info.picture.width).toBe(40); + expect(seal.info.picture.height).toBe(40); + expect(seal.signAlgId).toBe('1.2.156.10197.1.501'); + expect(encodeSesSeal(seal)).toEqual(SES_V4_SEAL_DER); + }); + + it('parses and re-encodes an electronic signature with a timestamp', () => { + const signature = parseSesSignature(SES_V4_SIGNATURE_DER); + expect(signature.toSign.version).toBe(4); + expect(signature.toSign.timeInfo.toISOString()).toBe('2026-10-17T00:00:00.000Z'); + expect(signature.toSign.dataHash).toHaveLength(32); + expect(signature.toSign.propertyInfo).toBe('OFD'); + expect(signature.toSign.eseal.info.esId).toBe('SEAL-001'); + expect(signature.timeStamp).toEqual(new Uint8Array([1, 2])); + expect(encodeSesSignature(signature)).toEqual(SES_V4_SIGNATURE_DER); + }); + + it('retains the original TBS DER for signature verification', () => { + const signature = parseSesSignature(SES_V4_SIGNATURE_DER); + expect(signature.toSign.rawDer[0]).toBe(0x30); + expect(signature.toSign.eseal.info.rawDer[0]).toBe(0x30); + }); + + it('rejects truncated, empty, and trailing data', () => { + expect(() => parseSesSignature(new Uint8Array([0x30, 0x00]))).toThrow(/SES|field|SEQUENCE/i); + expect(() => parseSesSeal(SES_V4_SEAL_DER.slice(0, 20))).toThrow(/DER|SES/i); + expect(() => + parseSesSeal(new Uint8Array([...SES_V4_SEAL_DER, 0])), + ).toThrow(/trailing/i); + }); +}); diff --git a/packages/core/tests/sign-multi.test.ts b/packages/core/tests/sign-multi.test.ts new file mode 100644 index 0000000..3d0e182 --- /dev/null +++ b/packages/core/tests/sign-multi.test.ts @@ -0,0 +1,52 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import { jsOFD } from '../src/jsofd'; +import { signOfd } from '../src/crypto/ofd-sign'; +import { unzip } from '../src/zip-read'; +import { kids, parseXml } from '../src/xml-parse'; +import { createSesTestMaterial, type SesTestMaterial } from './helpers/ses-material'; + +let material: SesTestMaterial; + +beforeAll(async () => { + material = await createSesTestMaterial(); +}); + +function sampleDoc(): Uint8Array { + const doc = new jsOFD({ unit: 'mm', format: 'a4' }); + doc.text('多次签章', 20, 20); + return doc.output('uint8array') as Uint8Array; +} + +function options() { + return { + eSeal: material.eSeal, + signingCertificate: material.signingCertificate, + signer: material.signer, + now: () => new Date('2026-10-17T12:00:00Z'), + }; +} + +describe('multiple SES signatures', () => { + it('preserves old signature files and does not modify OFD.xml again', async () => { + const first = await signOfd(sampleDoc(), options()); + const firstEntries = await unzip(first); + const second = await signOfd(first, options()); + const secondEntries = await unzip(second); + for (const name of [ + 'Signs/Sign_0/Signature.xml', + 'Signs/Sign_0/Seal.esl', + 'Signs/Sign_0/SignedValue.dat', + ]) { + expect(secondEntries.get(name)).toEqual(firstEntries.get(name)); + } + expect(secondEntries.get('OFD.xml')).toEqual(firstEntries.get('OFD.xml')); + expect(secondEntries.has('Signs/Sign_1/Signature.xml')).toBe(true); + const list = parseXml(new TextDecoder().decode(secondEntries.get('Signs/Signatures.xml'))); + expect(kids(list, 'Signature')).toHaveLength(2); + }); + + it('rejects another signature after complete-protection mode', async () => { + const first = await signOfd(sampleDoc(), { ...options(), allowAdditionalSignatures: false }); + await expect(signOfd(first, options())).rejects.toThrow(/additional|protected|signature/i); + }); +}); diff --git a/packages/core/tests/sign.test.ts b/packages/core/tests/sign.test.ts new file mode 100644 index 0000000..6f4ba05 --- /dev/null +++ b/packages/core/tests/sign.test.ts @@ -0,0 +1,78 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import { jsOFD } from '../src/jsofd'; +import { signOfd } from '../src/crypto/ofd-sign'; +import { parseSesSignature } from '../src/crypto/ses-v4'; +import { Sm3 } from '../src/crypto/sm3'; +import { unzip } from '../src/zip-read'; +import { parseXml, textOf } from '../src/xml-parse'; +import { createSesTestMaterial, type SesTestMaterial } from './helpers/ses-material'; + +let material: SesTestMaterial; + +beforeAll(async () => { + material = await createSesTestMaterial(); +}); + +function sampleDoc(): Uint8Array { + const doc = new jsOFD({ unit: 'mm', format: 'a4' }); + doc.text('签章验证文档', 20, 20); + return doc.output('uint8array') as Uint8Array; +} + +function options(): Parameters[1] { + return { + eSeal: material.eSeal, + signingCertificate: material.signingCertificate, + signer: material.signer, + stamp: { page: 1, x: 120, y: 240, w: 40, h: 40 }, + provider: { name: 'A&B ', company: '测试', version: '1' }, + now: () => new Date('2026-10-17T12:00:00Z'), + }; +} + +describe('SES OFD signing', () => { + it('writes the standard signature list, seal, and SES signed value', async () => { + const signed = await signOfd(sampleDoc(), options()); + const entries = await unzip(signed); + for (const name of [ + 'Signs/Signatures.xml', + 'Signs/Sign_0/Signature.xml', + 'Signs/Sign_0/Seal.esl', + 'Signs/Sign_0/SignedValue.dat', + ]) { + expect(entries.has(name)).toBe(true); + } + expect(entries.get('Signs/Sign_0/Seal.esl')).toEqual(material.eSeal); + + const signatureXml = entries.get('Signs/Sign_0/Signature.xml')!; + const signature = parseSesSignature(entries.get('Signs/Sign_0/SignedValue.dat')!); + expect(signature.toSign.dataHash).toEqual(new Sm3().update(signatureXml).digest()); + expect(signature.toSign.eseal.info.esId).toBe('SEAL-001'); + expect(signature.signingCertificate).toEqual(material.signingCertificate); + + const root = parseXml(new TextDecoder().decode(signatureXml)); + const signedInfo = root.children[0]!; + expect(signedInfo.children.find((child) => child.name === 'Provider')?.attrs.ProviderName).toBe( + 'A&B ', + ); + expect(textOf(signedInfo.children.find((child) => child.name === 'SignatureDateTime')!)).toBe( + '20261017120000', + ); + const stamp = signedInfo.children.find((child) => child.name === 'StampAnnot'); + const document = parseXml(new TextDecoder().decode(entries.get('Doc_0/Document.xml'))); + const pageId = document.children.find((child) => child.name === 'Pages')?.children[0]?.attrs.ID; + expect(stamp?.attrs).toMatchObject({ PageRef: pageId, ID: 's001', Boundary: '120 240 40 40' }); + const references = signedInfo.children.find((child) => child.name === 'References')!; + expect(references.children.length).toBeGreaterThan(3); + expect(references.children.some((reference) => reference.attrs.FileRef === '/OFD.xml')).toBe(true); + expect(references.children.some((reference) => reference.attrs.FileRef === '/Signs/Signatures.xml')).toBe(false); + }); + + it('includes the signature list in complete-protection mode', async () => { + const signed = await signOfd(sampleDoc(), { ...options(), allowAdditionalSignatures: false }); + const entries = await unzip(signed); + const root = parseXml(new TextDecoder().decode(entries.get('Signs/Sign_0/Signature.xml'))); + const references = root.children[0]!.children.find((child) => child.name === 'References')!; + expect(references.children.some((reference) => reference.attrs.FileRef === '/Signs/Signatures.xml')).toBe(true); + }); +}); diff --git a/packages/core/tests/signer.test.ts b/packages/core/tests/signer.test.ts new file mode 100644 index 0000000..92384a4 --- /dev/null +++ b/packages/core/tests/signer.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest'; +import { + createLocalTestSm2Signer, + requestSm2Signature, + type Sm2Signer, +} from '../src/crypto/signer'; +import { sm2Generate, sm2Verify } from '../src/crypto/sm2'; + +const certificate = new Uint8Array([1, 2, 3]); +const tbs = new TextEncoder().encode('exact TBS DER bytes'); + +describe('external SM2 signer', () => { + it('signs the exact TBS bytes with the local test adapter', async () => { + const { privateKey, publicKey } = sm2Generate(); + const signer = createLocalTestSm2Signer(privateKey); + const signature = await requestSm2Signature(signer, { tbs, certificate }); + expect(sm2Verify(publicKey, tbs, signature)).toBe(true); + expect(sm2Verify(publicKey, new TextEncoder().encode('other'), signature)).toBe(false); + }); + + it('rejects a malformed signer result', async () => { + const signer: Sm2Signer = { sign: async () => new Uint8Array([0x30, 0x00]) }; + await expect(requestSm2Signature(signer, { tbs, certificate })).rejects.toThrow(/DER|signature/i); + }); + + it('propagates signer failures', async () => { + const signer: Sm2Signer = { sign: async () => { throw new Error('boom'); } }; + await expect(requestSm2Signature(signer, { tbs, certificate })).rejects.toThrow('boom'); + }); + + it('times out even when a signer ignores cancellation', async () => { + const signer: Sm2Signer = { sign: () => new Promise(() => undefined) }; + await expect(requestSm2Signature(signer, { tbs, certificate }, 5)).rejects.toThrow(/timed out/i); + }); +}); diff --git a/packages/core/tests/sm4.test.ts b/packages/core/tests/sm4.test.ts new file mode 100644 index 0000000..9c223db --- /dev/null +++ b/packages/core/tests/sm4.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; +import { sm4CbcDecrypt, sm4CbcEncrypt } from '../src/crypto/sm4'; +import { hexToBytes } from '../src/crypto/sm3'; + +describe('SM4 CBC', () => { + it('matches the GB/T 32907 CBC example and round-trips PKCS#7', () => { + const key = hexToBytes('0123456789abcdeffedcba9876543210'); + const iv = hexToBytes('000102030405060708090a0b0c0d0e0f'); + const plaintext = hexToBytes('0123456789abcdeffedcba9876543210'); + const encrypted = sm4CbcEncrypt(plaintext, key, iv); + expect(encrypted).toHaveLength(32); + expect(Buffer.from(encrypted.subarray(0, 16)).toString('hex')).toBe( + 'a9a268883a336315bac0c9c9ff350ab1', + ); + expect(sm4CbcDecrypt(encrypted, key, iv)).toEqual(plaintext); + }); + + it('rejects invalid key, IV, ciphertext, and padding', () => { + const key = new Uint8Array(16); + const iv = new Uint8Array(16); + expect(() => sm4CbcEncrypt(new Uint8Array(), new Uint8Array(15), iv)).toThrow(/key/i); + expect(() => sm4CbcEncrypt(new Uint8Array(), key, new Uint8Array(15))).toThrow(/IV/i); + expect(() => sm4CbcDecrypt(new Uint8Array(15), key, iv)).toThrow(/ciphertext|block/i); + const encrypted = sm4CbcEncrypt(new Uint8Array([1, 2, 3]), key, iv); + encrypted[encrypted.length - 1] ^= 0xff; + expect(() => sm4CbcDecrypt(encrypted, key, iv)).toThrow(/padding/i); + }); +}); diff --git a/packages/core/tests/verify-attacks.test.ts b/packages/core/tests/verify-attacks.test.ts new file mode 100644 index 0000000..47af29b --- /dev/null +++ b/packages/core/tests/verify-attacks.test.ts @@ -0,0 +1,67 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import { jsOFD } from '../src/jsofd'; +import { signOfd } from '../src/crypto/ofd-sign'; +import { verifyOfd } from '../src/crypto/ofd-verify'; +import { unzip } from '../src/zip-read'; +import { zipStore } from '../src/zip'; +import { createSesTestMaterial, type SesTestMaterial } from './helpers/ses-material'; + +let victim: SesTestMaterial; +let attacker: SesTestMaterial; + +beforeAll(async () => { + victim = await createSesTestMaterial(); + attacker = await createSesTestMaterial(); +}); + +function sampleDoc(text: string): Uint8Array { + const doc = new jsOFD({ unit: 'mm', format: 'a4' }); + doc.text(text, 20, 20); + return doc.output('uint8array') as Uint8Array; +} + +function signOptions(material: SesTestMaterial) { + return { + eSeal: material.eSeal, + signingCertificate: material.signingCertificate, + signer: material.signer, + now: () => new Date('2026-10-17T12:00:00Z'), + }; +} + +describe('OFD SES verification attacks', () => { + it('does not trust a mathematically valid attacker re-signature', async () => { + const forged = await signOfd(sampleDoc('攻击者伪造并重签'), signOptions(attacker)); + const result = await verifyOfd(forged, victim.trustOptions()); + expect(result.signatures[0]!.signatureValid).toBe(true); + expect(result.signatures[0]!.sealSignatureValid).toBe(true); + expect(result.signatures[0]!.signerTrust.status).toBe('untrusted'); + expect(result.signatures[0]!.makerTrust.status).toBe('untrusted'); + expect(result.signatures[0]!.status).toBe('untrusted'); + }); + + it('detects Signature.xml changes and reports malformed packages without throwing', async () => { + const signed = await signOfd(sampleDoc('签名描述篡改'), signOptions(victim)); + const entries = await unzip(signed); + const signatureXml = entries.get('Signs/Sign_0/Signature.xml')!; + signatureXml[signatureXml.length - 2] ^= 1; + entries.set('Signs/Sign_0/Signature.xml', signatureXml); + const changed = zipStore([...entries].map(([name, data]) => ({ name, data }))); + const changedResult = await verifyOfd(changed, victim.trustOptions()); + expect(changedResult.signatures[0]!.status).toBe('invalid'); + + const malformed = await verifyOfd(new Uint8Array([1, 2, 3]), victim.trustOptions()); + expect(malformed.signatures).toEqual([]); + expect(malformed.errors.length).toBeGreaterThan(0); + }); + + it('treats revoked certificates as invalid', async () => { + const signed = await signOfd(sampleDoc('吊销证书'), signOptions(victim)); + const result = await verifyOfd(signed, { + ...victim.trustOptions(), + revocationProvider: { check: async () => 'revoked' }, + }); + expect(result.signatures[0]!.signerTrust.status).toBe('revoked'); + expect(result.signatures[0]!.status).toBe('invalid'); + }); +}); diff --git a/packages/core/tests/verify.test.ts b/packages/core/tests/verify.test.ts new file mode 100644 index 0000000..c9b2f97 --- /dev/null +++ b/packages/core/tests/verify.test.ts @@ -0,0 +1,72 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import { jsOFD } from '../src/jsofd'; +import { signOfd } from '../src/crypto/ofd-sign'; +import { verifyOfd } from '../src/crypto/ofd-verify'; +import { unzip } from '../src/zip-read'; +import { zipStore } from '../src/zip'; +import { createSesTestMaterial, type SesTestMaterial } from './helpers/ses-material'; + +let material: SesTestMaterial; + +beforeAll(async () => { + material = await createSesTestMaterial(); +}); + +function sampleDoc(text = '可信验签'): Uint8Array { + const doc = new jsOFD({ unit: 'mm', format: 'a4' }); + doc.text(text, 20, 20); + return doc.output('uint8array') as Uint8Array; +} + +function signOptions() { + return { + eSeal: material.eSeal, + signingCertificate: material.signingCertificate, + signer: material.signer, + now: () => new Date('2026-10-17T12:00:00Z'), + }; +} + +async function replaceEntry(bytes: Uint8Array, name: string, data: Uint8Array): Promise { + const entries = await unzip(bytes); + entries.set(name, data); + return zipStore([...entries].map(([entryName, entryData]) => ({ name: entryName, data: entryData }))); +} + +describe('OFD SES verification', () => { + it('accepts a trusted seal and detects protected content tampering', async () => { + const signed = await signOfd(sampleDoc(), signOptions()); + const valid = await verifyOfd(signed, material.trustOptions()); + expect(valid.errors).toEqual([]); + expect(valid.signatures).toHaveLength(1); + expect(valid.signatures[0]).toMatchObject({ + status: 'valid', + referencesValid: true, + signatureValid: true, + sealSignatureValid: true, + }); + + const entries = await unzip(signed); + const content = entries.get('Doc_0/Pages/Page_0/Content.xml')!.slice(); + content[content.length - 2] ^= 1; + const tampered = await replaceEntry(signed, 'Doc_0/Pages/Page_0/Content.xml', content); + const result = await verifyOfd(tampered, material.trustOptions()); + expect(result.signatures[0]!.referencesValid).toBe(false); + expect(result.signatures[0]!.signatureValid).toBe(true); + expect(result.signatures[0]!.status).toBe('invalid'); + }); + + it('verifies every signature in a multi-seal package', async () => { + const first = await signOfd(sampleDoc('第一次'), signOptions()); + const second = await signOfd(first, signOptions()); + const result = await verifyOfd(second, material.trustOptions()); + expect(result.signatures).toHaveLength(2); + expect(result.signatures.map((signature) => signature.status)).toEqual(['valid', 'valid']); + }); + + it('returns an empty report for an unsigned document', async () => { + const result = await verifyOfd(sampleDoc(), material.trustOptions()); + expect(result.signatures).toEqual([]); + expect(result.errors.length).toBeGreaterThan(0); + }); +}); diff --git a/packages/core/tests/watermark-redact.test.ts b/packages/core/tests/watermark-redact.test.ts new file mode 100644 index 0000000..552c922 --- /dev/null +++ b/packages/core/tests/watermark-redact.test.ts @@ -0,0 +1,125 @@ +/** + * 水印与关键字脱敏测试。 + */ + +import { describe, expect, it } from 'vitest'; +import { jsOFD } from '../src/jsofd'; +import type { ImageRun, PathRun, TextRun } from '../src/model'; +import { extractText } from '../src/ofd-reader'; + +describe('addWatermark', () => { + it('文字水印:居中、旋转、透明度,绘制在内容之上', () => { + const doc = new jsOFD({ unit: 'mm', format: 'a4' }); + doc.text('机密文件', 20, 20); + const before = doc.pages[0]!.objects.length; + const stamped = doc.addWatermark({ text: '绝密', opacity: 0.2 }); + expect(stamped).toBe(1); + const objs = doc.pages[0]!.objects; + expect(objs.length).toBe(before + 1); + const wm = objs[objs.length - 1] as TextRun; + expect(wm.t).toBe('text'); + expect(wm.text).toBe('绝密'); + expect(wm.angle).toBe(45); + expect(wm.opacity).toBeCloseTo(0.2, 3); + // 居中:基线原点在页面中心左上方(旋转半宽补偿) + expect(wm.x).toBeLessThan(doc.pages[0]!.width / 2); + expect(wm.y).toBeLessThan(doc.pages[0]!.height / 2); + }); + + it('平铺模式产生多个水印,under 模式绘制在内容之下', () => { + const doc = new jsOFD({ unit: 'mm', format: 'a4' }); + doc.text('正文', 20, 20); + doc.addWatermark({ text: '草稿', mode: 'tile', fontSize: 24, layer: 'under' }); + const objs = doc.pages[0]!.objects; + expect(objs.length).toBeGreaterThan(2); + // under:水印对象在最前,正文在所有水印之后 + expect((objs[0] as TextRun).text).toBe('草稿'); + expect(objs.some((o) => (o as TextRun).text === '正文')).toBe(true); + }); + + it('指定页码(1-based)与多页', () => { + const doc = new jsOFD({ unit: 'mm', format: 'a4' }); + doc.addPage('a4'); + doc.addPage('a4'); + doc.addWatermark({ text: 'W', pages: [2] }); + expect(doc.pages[0]!.objects.length).toBe(0); + expect(doc.pages[1]!.objects.length).toBe(1); + expect(doc.pages[2]!.objects.length).toBe(0); + }); + + it('图片水印:默认宽度 40% 页宽,纵横比保持', async () => { + const doc = new jsOFD({ unit: 'mm', format: 'a4' }); + // 真实 2×1 PNG(encodePng 生成,sniffImage 可读出尺寸) + const { encodePng } = await import('../src/png-encode'); + const png = await encodePng(new Uint8Array(2 * 1 * 4), 2, 1); + doc.addWatermark({ image: png, imageFormat: 'PNG' }); + const img = doc.pages[0]!.objects[0] as ImageRun; + expect(img.t).toBe('image'); + expect(img.w).toBeCloseTo(doc.pages[0]!.width * 0.4, 1); + expect(img.h).toBeCloseTo(img.w / 2, 1); // 2:1 纵横比 + }); +}); + +describe('redact', () => { + it('block 模式:加黑色矩形 + 文本替换为空格,其余字形位置不变', () => { + const doc = new jsOFD({ unit: 'mm', format: 'a4' }); + doc.setFont('simsun'); + doc.setFontSize(12); + doc.text('姓名:王小明 电话:13800138000', 20, 30); + const run = doc.pages[0]!.objects[0] as TextRun; + const glyphWsBefore = run.glyphWs; + + const hits = doc.redact('王小明'); + expect(hits).toEqual([{ keyword: '王小明', page: 1, count: 1 }]); + + // 文本被空格替换(提取不再泄漏),长度不变 + const masked = doc.pages[0]!.objects[0] as TextRun; + expect(masked.text).toBe('姓名: 电话:13800138000'); + expect(masked.text.length).toBe(run.text.length); + expect(extractText(doc as never)[0]).not.toContain('王小明'); + + // 版面不错位:字形推进数组保持原样 + expect(masked.glyphWs).toEqual(glyphWsBefore); + + // 增加一个覆盖矩形 + const rects = doc.pages[0]!.objects.filter((o): o is PathRun => o.t === 'path'); + expect(rects.length).toBe(1); + expect(rects[0]!.fill).toBe(true); + expect(rects[0]!.fillColor).toEqual([0, 0, 0]); + expect(rects[0]!.ops.length).toBe(5); + // 矩形起点在"姓名:"之后 + expect(rects[0]!.ops[0]!.op === 'M' && rects[0]!.ops[0]!.x).toBeGreaterThan(masked.x); + }); + + it('asterisk 模式:关键字替换为 *', () => { + const doc = new jsOFD({ unit: 'mm', format: 'a4' }); + doc.text('手机号 13800138000 有效', 20, 30); + const hits = doc.redact('1380013', { mode: 'asterisk' }); + expect(hits[0]!.count).toBe(1); + const run = doc.pages[0]!.objects[0] as TextRun; + expect(run.text).toContain('*******8000'); + }); + + it('多次出现与未命中关键字', () => { + const doc = new jsOFD({ unit: 'mm', format: 'a4' }); + doc.text('绝密A', 20, 20); + doc.text('绝密B', 20, 40); + const hits = doc.redact('绝密', { mode: 'asterisk' }); + expect(hits[0]!.count).toBe(2); + const texts = doc.pages[0]!.objects.map((o) => (o as TextRun).text); + expect(texts).toContain('**A'); + expect(texts).toContain('**B'); + const miss = doc.redact('不存在'); + expect(miss).toEqual([{ keyword: '不存在', page: 0, count: 0 }]); + }); + + it('页码筛选', () => { + const doc = new jsOFD({ unit: 'mm', format: 'a4' }); + doc.text('秘密一', 20, 20); + doc.addPage('a4'); + doc.text('秘密二', 20, 20); + doc.redact('秘密', { pages: [1], mode: 'asterisk' }); + expect((doc.pages[0]!.objects[0] as TextRun).text).toBe('**一'); + expect((doc.pages[1]!.objects[0] as TextRun).text).toBe('秘密二'); + }); +}); diff --git a/packages/core/tests/x509-sm2.test.ts b/packages/core/tests/x509-sm2.test.ts new file mode 100644 index 0000000..95dd894 --- /dev/null +++ b/packages/core/tests/x509-sm2.test.ts @@ -0,0 +1,182 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import { + parseSm2Certificate, + verifyCertificateChain, + verifySm2CertificateSignature, +} from '../src/crypto/x509-sm2'; +import { + createTestChain, + createTestRoot, + issueTestCertificate, + type TestChain, +} from './helpers/test-ca'; +import { sm2Generate } from '../src/crypto/sm2'; + +const AT = new Date('2026-10-17T00:00:00Z'); +let chain: TestChain; + +beforeAll(() => { + chain = createTestChain(); +}); + +describe('SM2 X.509 certificates', () => { + it('parses standard fields and verifies the issuer signature', () => { + const cert = parseSm2Certificate(chain.leaf.der); + expect(cert.subject).toBe('Test Signer'); + expect(cert.issuer).toBe('Test Intermediate'); + expect(cert.isCa).toBe(false); + expect(cert.publicKey).toEqual(chain.leaf.publicKey); + expect(verifySm2CertificateSignature(chain.leaf.der, chain.intermediate.der)).toBe(true); + expect(verifySm2CertificateSignature(chain.leaf.der, chain.root.der)).toBe(false); + }); + + it('trusts a valid Root → Intermediate → Leaf chain', async () => { + const result = await verifyCertificateChain(chain.leaf.der, { + trustAnchors: [chain.root.der], + intermediates: [chain.intermediate.der], + at: AT, + }); + expect(result.status).toBe('trusted'); + expect(result.chain).toEqual(['Test Signer', 'Test Intermediate', 'Test Root']); + }); + + it('rejects a wrong trust anchor and a tampered certificate', async () => { + const otherRoot = createTestRoot('Other Root'); + const wrong = await verifyCertificateChain(chain.leaf.der, { + trustAnchors: [otherRoot.der], + intermediates: [chain.intermediate.der], + at: AT, + }); + expect(wrong.status).toBe('untrusted'); + + const tampered = chain.leaf.der.slice(); + tampered[tampered.length - 1] ^= 1; + const attacked = await verifyCertificateChain(tampered, { + trustAnchors: [chain.root.der], + intermediates: [chain.intermediate.der], + at: AT, + }); + expect(attacked.status).toBe('untrusted'); + }); + + it('rejects expired certificates, non-CA issuers, missing keyCertSign, and pathLen violations', async () => { + const expired = issueTestCertificate({ + subject: 'Expired Signer', + subjectKeys: sm2Generate(), + issuer: chain.intermediate, + notAfter: new Date('2026-01-01T00:00:00Z'), + }); + const expiredResult = await verifyCertificateChain(expired.der, { + trustAnchors: [chain.root.der], + intermediates: [chain.intermediate.der], + at: AT, + }); + expect(expiredResult.status).toBe('expired'); + + const nonCa = issueTestCertificate({ + subject: 'Not CA', + subjectKeys: sm2Generate(), + issuer: chain.root, + ca: false, + }); + const nonCaLeaf = issueTestCertificate({ + subject: 'Leaf Below Not CA', + subjectKeys: sm2Generate(), + issuer: nonCa, + }); + const nonCaResult = await verifyCertificateChain(nonCaLeaf.der, { + trustAnchors: [chain.root.der], + intermediates: [nonCa.der], + at: AT, + }); + expect(nonCaResult.status).toBe('untrusted'); + + const noCertSign = issueTestCertificate({ + subject: 'No Cert Sign', + subjectKeys: sm2Generate(), + issuer: chain.root, + ca: true, + usage: 'no-cert-sign', + }); + const noCertSignLeaf = issueTestCertificate({ + subject: 'Leaf Below No Cert Sign', + subjectKeys: sm2Generate(), + issuer: noCertSign, + }); + const usageResult = await verifyCertificateChain(noCertSignLeaf.der, { + trustAnchors: [chain.root.der], + intermediates: [noCertSign.der], + at: AT, + }); + expect(usageResult.status).toBe('untrusted'); + + const limitedRoot = createTestRoot('Limited Root', 0); + const limitedIntermediate = issueTestCertificate({ + subject: 'Limited Intermediate', + subjectKeys: sm2Generate(), + issuer: limitedRoot, + ca: true, + }); + const limitedLeaf = issueTestCertificate({ + subject: 'Limited Leaf', + subjectKeys: sm2Generate(), + issuer: limitedIntermediate, + }); + const pathResult = await verifyCertificateChain(limitedLeaf.der, { + trustAnchors: [limitedRoot.der], + intermediates: [limitedIntermediate.der], + at: AT, + }); + expect(pathResult.status).toBe('untrusted'); + }); + + it('rejects unknown critical extensions', async () => { + const leaf = issueTestCertificate({ + subject: 'Critical Extension', + subjectKeys: sm2Generate(), + issuer: chain.intermediate, + unknownCriticalExtension: true, + }); + const result = await verifyCertificateChain(leaf.der, { + trustAnchors: [chain.root.der], + intermediates: [chain.intermediate.der], + at: AT, + }); + expect(result.status).toBe('untrusted'); + }); + + it('handles revoked, unknown, and required revocation states', async () => { + const revoked = await verifyCertificateChain(chain.leaf.der, { + trustAnchors: [chain.root.der], + intermediates: [chain.intermediate.der], + at: AT, + revocationProvider: { check: async () => 'revoked' }, + }); + expect(revoked.status).toBe('revoked'); + + const unknown = await verifyCertificateChain(chain.leaf.der, { + trustAnchors: [chain.root.der], + intermediates: [chain.intermediate.der], + at: AT, + revocationProvider: { check: async () => 'unknown' }, + requireRevocation: true, + }); + expect(unknown.status).toBe('indeterminate'); + + const optional = await verifyCertificateChain(chain.leaf.der, { + trustAnchors: [chain.root.der], + intermediates: [chain.intermediate.der], + at: AT, + revocationProvider: { check: async () => 'unknown' }, + }); + expect(optional.status).toBe('trusted'); + }); + + it('returns indeterminate for malformed certificates', async () => { + const result = await verifyCertificateChain(new Uint8Array([0x30, 0x00]), { + trustAnchors: [chain.root.der], + at: AT, + }); + expect(result.status).toBe('indeterminate'); + }); +}); diff --git a/packages/core/tsup.config.ts b/packages/core/tsup.config.ts index 7e97293..86cf2c2 100644 --- a/packages/core/tsup.config.ts +++ b/packages/core/tsup.config.ts @@ -8,7 +8,7 @@ export default defineConfig([ globalName: 'jsOFD', dts: true, sourcemap: false, - target: 'es2018', + target: 'es2020', platform: 'browser', clean: true, outExtension({ format }) { diff --git a/packages/playground/index.html b/packages/playground/index.html index e60a1d2..5281c50 100644 --- a/packages/playground/index.html +++ b/packages/playground/index.html @@ -379,6 +379,32 @@ font-size: 12px; line-height: 1.7; } + .crypto-field { + display: flex; + flex-direction: column; + gap: 6px; + color: var(--text-2); + font-size: 12px; + font-weight: 600; + } + .crypto-field input { + width: 100%; + padding: 9px 10px; + border: 1px solid var(--border-strong); + border-radius: var(--r-sm); + background: var(--surface); + color: var(--text); + outline: none; + } + .crypto-field input:focus { + border-color: var(--accent); + box-shadow: 0 0 0 3px rgba(47, 95, 232, 0.12); + } + .crypto-file { + width: 100%; + color: var(--text-3); + font-size: 12px; + } /* ---------- right preview ---------- */ .preview { @@ -654,6 +680,7 @@
@@ -668,7 +695,7 @@
拖入 PDF 文件,或点击选择
转换后可在右侧预览并下载 .ofd
- +
+ +
diff --git a/packages/playground/src/app.ts b/packages/playground/src/app.ts index f6f487e..1ab8f38 100644 --- a/packages/playground/src/app.ts +++ b/packages/playground/src/app.ts @@ -6,7 +6,8 @@ * projection of the same object model that serializes into the .ofd. */ import { jsOFD, version } from '@hufe921/jsofd'; -import type { PageData } from '@hufe921/jsofd'; +import type { OfdVerifyResult, PageData } from '@hufe921/jsofd'; +import type { SesTestMaterial } from '../../core/tests/helpers/ses-material'; // pdfjs worker served as a local Vite asset (no CDN dependency). import workerUrl from 'pdfjs-dist/build/pdf.worker.min.mjs?url'; @@ -716,11 +717,46 @@ function selectPreset(name: string): void { ($('code') as HTMLTextAreaElement).value = PRESETS[name]!; } -/* ---------------- PDF conversion ---------------- */ +/* ---------------- PDF conversion / OFD opening ---------------- */ let selectedPdf: File | null = null; +/** True when the selected file is an .ofd package (open instead of convert). */ +let selectedIsOfd = false; + +async function openOfdFile(): Promise { + if (!selectedPdf) return; + const btn = $('convert') as HTMLButtonElement; + btn.disabled = true; + btn.textContent = '打开中…'; + pvOverlayShow('正在解析 OFD…'); + log(`⏳ 读取 ${selectedPdf.name} (${(selectedPdf.size / 1024).toFixed(1)} KB)`, 'dim'); + try { + const { openOfd } = await import('@hufe921/jsofd'); + const buf = new Uint8Array(await selectedPdf.arrayBuffer()); + const t0 = performance.now(); + const opened = await openOfd(buf, { + onProgress: (i, n) => pvOverlayShow(`正在解析 OFD… 第 ${i + 1}/${n} 页`), + }); + doc = opened; + pageIdx = 0; + fileName = selectedPdf.name; + render(); + const objects = opened.pages.reduce((s, p) => s + p.objects.length, 0); + log( + `✓ 打开成功: ${opened.pages.length} 页 / ${objects} 个对象 / ${Math.round(performance.now() - t0)}ms(下载按钮可导出重新序列化的 .ofd)`, + 'ok', + ); + } catch (e) { + pvOverlayHide(); + log('✗ 打开失败: ' + (e as Error).message, 'err'); + } finally { + btn.disabled = false; + btn.textContent = '打开 OFD'; + } +} async function convertPdf(): Promise { + if (selectedIsOfd) return openOfdFile(); if (!selectedPdf) return; const btn = $('convert') as HTMLButtonElement; btn.disabled = true; @@ -768,24 +804,177 @@ async function convertPdf(): Promise { function setPdfFile(file: File | null): void { selectedPdf = file; + selectedIsOfd = !!file && /\.ofd$/i.test(file.name); const chip = $('fileChip'); const has = !!file; chip.style.display = has ? 'flex' : 'none'; ($('clearFile') as HTMLButtonElement).style.display = has ? 'inline-flex' : 'none'; ($('convert') as HTMLButtonElement).disabled = !has; + ($('convert') as HTMLButtonElement).textContent = selectedIsOfd ? '打开 OFD' : '▸ 转换为 OFD'; if (file) { $('fileName').textContent = file.name; $('fileSize').textContent = `${(file.size / 1024).toFixed(1)} KB`; } } +/** Open an OFD file from a URL (?ofd= share/deep-link). */ +async function openOfdFromUrl(url: string): Promise { + pvOverlayShow('正在下载 OFD…'); + log(`⏳ 拉取 ${url}`, 'dim'); + try { + const res = await fetch(url); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const bytes = new Uint8Array(await res.arrayBuffer()); + const { openOfd } = await import('@hufe921/jsofd'); + doc = await openOfd(bytes); + pageIdx = 0; + fileName = url.split('/').pop()?.replace(/\?.*$/, '') || 'sample.ofd'; + render(); + log(`✓ 已打开 ${fileName}(${ofdPagesSummary()})`, 'ok'); + } catch (e) { + pvOverlayHide(); + log('✗ 打开 OFD 失败: ' + (e as Error).message, 'err'); + } +} + +function ofdPagesSummary(): string { + if (!doc) return ''; + return `${doc.pages.length} 页 / ${doc.pages.reduce((s, p) => s + p.objects.length, 0)} 个对象`; +} + +/* ---------------- SES electronic signature demo ---------------- */ + +let signatureMaterial: SesTestMaterial | null = null; +let lastSigned: Uint8Array | null = null; +let selectedSigned: Uint8Array | null = null; + +function downloadBytes(bytes: Uint8Array, name: string): void { + const url = URL.createObjectURL(new Blob([bytes as BlobPart], { type: 'application/ofd' })); + const link = document.createElement('a'); + link.href = url; + link.download = name; + link.click(); + setTimeout(() => URL.revokeObjectURL(url), 0); +} + +async function demoSealPng(): Promise { + const canvas = document.createElement('canvas'); + canvas.width = 160; + canvas.height = 160; + const context = canvas.getContext('2d')!; + context.clearRect(0, 0, 160, 160); + context.strokeStyle = '#d22626'; + context.fillStyle = '#d22626'; + context.lineWidth = 6; + context.beginPath(); + context.arc(80, 80, 70, 0, Math.PI * 2); + context.stroke(); + context.font = 'bold 26px sans-serif'; + context.textAlign = 'center'; + context.fillText('测试签章', 80, 90); + const blob = await new Promise((resolve) => canvas.toBlob((value) => resolve(value!), 'image/png')); + return new Uint8Array(await blob.arrayBuffer()); +} + +async function demoMaterial(): Promise { + if (!signatureMaterial) { + const { createSesTestMaterial } = await import('../../core/tests/helpers/ses-material'); + const data = await demoSealPng(); + signatureMaterial = await createSesTestMaterial({ type: 'PNG', data, width: 40, height: 40 }); + } + return signatureMaterial; +} + +function showSignatureReport(report: OfdVerifyResult): void { + const summary = report.signatures.length + ? report.signatures + .map( + (item) => + `${item.id || '签章'}:${item.status}(签章者 ${item.signerTrust.status} / 制章者 ${item.makerTrust.status})`, + ) + .join(';') + : report.errors.join(';') || '未发现签章'; + $('signatureInfo').textContent = summary; + const valid = report.signatures.length > 0 && report.signatures.every((item) => item.status === 'valid'); + log(valid ? '✓ 签章与证书链验证通过,文档未被篡改' : `✗ 验签结果:${summary}`, valid ? 'ok' : 'err'); +} + +async function signCurrentDemo(): Promise { + if (!doc) { + log('✗ 请先运行示例或打开一个 OFD 文档', 'err'); + return; + } + try { + const material = await demoMaterial(); + const { signOfd, verifyOfd } = await import('@hufe921/jsofd'); + const bytes = doc.output('uint8array') as Uint8Array; + lastSigned = await signOfd(bytes, { + eSeal: material.eSeal, + signingCertificate: material.signingCertificate, + signer: material.signer, + stamp: { page: 1, x: 120, y: 240, w: 40, h: 40 }, + }); + selectedSigned = null; + ($('signatureFile') as HTMLInputElement).value = ''; + ($('downloadSigned') as HTMLButtonElement).disabled = false; + const name = fileName.replace(/\.ofd$/i, '') + '-signed.ofd'; + downloadBytes(lastSigned, name); + showSignatureReport(await verifyOfd(lastSigned, material.trustOptions())); + } catch (e) { + log('✗ 签章失败: ' + (e as Error).message, 'err'); + } +} + +async function verifySignedDemo(): Promise { + const signed = selectedSigned ?? lastSigned; + if (!signed) { + log('✗ 请先签章当前文档或选择签章 OFD 文件', 'err'); + return; + } + try { + const material = await demoMaterial(); + const { verifyOfd } = await import('@hufe921/jsofd'); + showSignatureReport(await verifyOfd(signed, material.trustOptions())); + } catch (e) { + log('✗ 验签失败: ' + (e as Error).message, 'err'); + } +} + +async function tamperSignedDemo(): Promise { + const signed = selectedSigned ?? lastSigned; + if (!signed) { + log('✗ 请先签章当前文档或选择签章 OFD 文件', 'err'); + return; + } + try { + const material = await demoMaterial(); + const [{ verifyOfd }, { unzip }, { zipStore }] = await Promise.all([ + import('@hufe921/jsofd'), + import('../../core/src/zip-read'), + import('../../core/src/zip'), + ]); + const entries = await unzip(signed); + const name = 'Doc_0/Pages/Page_0/Content.xml'; + const content = entries.get(name)?.slice(); + if (!content) throw new Error('签章文件中找不到用于演示的页面内容'); + content[content.length - 2] ^= 1; + entries.set(name, content); + const tampered = zipStore([...entries].map(([entryName, data]) => ({ name: entryName, data }))); + showSignatureReport(await verifyOfd(tampered, material.trustOptions())); + } catch (e) { + log('✗ 篡改验签失败: ' + (e as Error).message, 'err'); + } +} + /* ---------------- init ---------------- */ -function switchTab(name: 'examples' | 'pdf'): void { +function switchTab(name: 'examples' | 'pdf' | 'crypto'): void { $('tab-examples').classList.toggle('on', name === 'examples'); $('tab-pdf').classList.toggle('on', name === 'pdf'); + $('tab-crypto').classList.toggle('on', name === 'crypto'); $('panel-examples').classList.toggle('hidden', name !== 'examples'); $('panel-pdf').classList.toggle('hidden', name !== 'pdf'); + $('panel-crypto').classList.toggle('hidden', name !== 'crypto'); } async function bootstrap(): Promise { @@ -832,6 +1021,22 @@ async function bootstrap(): Promise { // tabs $('tab-examples').addEventListener('click', () => switchTab('examples')); $('tab-pdf').addEventListener('click', () => switchTab('pdf')); + $('tab-crypto').addEventListener('click', () => switchTab('crypto')); + + // SES signature demo + $('signCurrent').addEventListener('click', () => void signCurrentDemo()); + $('verifySigned').addEventListener('click', () => void verifySignedDemo()); + $('tamperSigned').addEventListener('click', () => void tamperSignedDemo()); + $('downloadSigned').addEventListener('click', () => { + if (lastSigned) downloadBytes(lastSigned, fileName.replace(/\.ofd$/i, '') + '-signed.ofd'); + }); + $('signatureFile').addEventListener('change', async (event) => { + const file = (event.target as HTMLInputElement).files?.[0]; + if (!file) return; + selectedSigned = new Uint8Array(await file.arrayBuffer()); + lastSigned = null; + $('signatureInfo').textContent = `已选择 ${file.name},点击“验证签章”或“篡改后验证”。`; + }); // run / download $('run').addEventListener('click', run); @@ -904,8 +1109,8 @@ async function bootstrap(): Promise { e.preventDefault(); drop.classList.remove('over'); const f = e.dataTransfer?.files?.[0]; - if (f && /\.pdf$/i.test(f.name)) setPdfFile(f); - else log('✗ 请拖入 .pdf 文件', 'err'); + if (f && /\.(pdf|ofd)$/i.test(f.name)) setPdfFile(f); + else log('✗ 请拖入 .pdf 或 .ofd 文件', 'err'); }); fileInput.addEventListener('change', () => { const f = fileInput.files?.[0]; @@ -918,6 +1123,11 @@ async function bootstrap(): Promise { }); run(); + + // Deep link: ?ofd= opens an OFD package directly (after the example + // bootstrap so the preview pipeline is warm). + const ofdUrl = new URLSearchParams(location.search).get('ofd'); + if (ofdUrl) void openOfdFromUrl(ofdUrl); } void bootstrap(); diff --git a/packages/playground/tests/signature-demo.test.ts b/packages/playground/tests/signature-demo.test.ts new file mode 100644 index 0000000..6491fde --- /dev/null +++ b/packages/playground/tests/signature-demo.test.ts @@ -0,0 +1,26 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +describe('SES signature playground demo', () => { + const html = readFileSync(join(__dirname, '../index.html'), 'utf8'); + const app = readFileSync(join(__dirname, '../src/app.ts'), 'utf8'); + + it('uses signature actions instead of the password-encryption panel', () => { + expect(html).toContain('签章演示'); + expect(html).toContain('id="signCurrent"'); + expect(html).toContain('id="verifySigned"'); + expect(html).toContain('id="tamperSigned"'); + expect(html).not.toContain('id="encryptCurrent"'); + expect(html).not.toContain('id="cryptoPassword"'); + }); + + it('wires SES signing, trusted verification, and tampering', () => { + expect(app).toContain('signOfd'); + expect(app).toContain('verifyOfd'); + expect(app).toContain('createSesTestMaterial'); + expect(app).toContain('tamperSignedDemo'); + expect(app).not.toContain('encryptOfdPassword'); + expect(app).not.toContain('decryptOfdPassword'); + }); +}); diff --git a/packages/playground/tsconfig.json b/packages/playground/tsconfig.json index e872266..a82a501 100644 --- a/packages/playground/tsconfig.json +++ b/packages/playground/tsconfig.json @@ -9,5 +9,5 @@ "@hufe921/jsofd/pdf": ["../core/src/pdf-import.ts"] } }, - "include": ["src"] + "include": ["src", "../core/src/crypto/sm-crypto.d.ts"] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a65a1d9..ab69796 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -189,6 +189,13 @@ importers: version: 1.6.4(@algolia/client-search@5.59.0)(@types/node@22.20.2)(lightningcss@1.33.0)(postcss@8.5.28)(search-insights@2.17.3)(typescript@6.0.3) packages/core: + dependencies: + asn1js: + specifier: 3.1.0 + version: 3.1.0 + sm-crypto: + specifier: 0.5.7 + version: 0.5.7 devDependencies: '@types/node': specifier: ^22.20.2 @@ -1333,6 +1340,10 @@ packages: any-promise@1.3.0: resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} + asn1js@3.1.0: + resolution: {integrity: sha512-HWYi1THkOqguCiO+vwj09ZkQFD2kKEsejYHAYCtX0D80WVdoDnvRc9IukXX/jeb0T9NRWJnRtqwdbHltnFjwXA==} + engines: {node: '>=12.0.0'} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} @@ -1622,6 +1633,9 @@ packages: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} + jsbn@1.1.0: + resolution: {integrity: sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==} + json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} @@ -1889,6 +1903,13 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + pvtsutils@1.3.6: + resolution: {integrity: sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==} + + pvutils@1.2.0: + resolution: {integrity: sha512-BbubeCEyTuQjVMakvJQ/Sxbc93F2pwmbsxONT/ZRrwU7Ua38d8unYTwXpTVLAKJ4BDuH9IGztCjQcd/N/39Dvg==} + engines: {node: '>=16.0.0'} + qified@0.10.1: resolution: {integrity: sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==} engines: {node: '>=20'} @@ -1945,6 +1966,9 @@ packages: siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + sm-crypto@0.5.7: + resolution: {integrity: sha512-Bxz1jWGtI7t/bn6Vbf4iGKcntslKVYAi6oBTtboTL+0WhqBTwsDFinIxIezjkBidxt8aNnEF1V3ZYU6TI0V1gQ==} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -2041,6 +2065,9 @@ packages: ts-interface-checker@0.1.13: resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tsup@8.5.1: resolution: {integrity: sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==} engines: {node: '>=18'} @@ -3219,6 +3246,12 @@ snapshots: any-promise@1.3.0: {} + asn1js@3.1.0: + dependencies: + pvtsutils: 1.3.6 + pvutils: 1.2.0 + tslib: 2.8.1 + assertion-error@2.0.1: {} balanced-match@4.0.4: {} @@ -3536,6 +3569,8 @@ snapshots: joycon@3.1.1: {} + jsbn@1.1.0: {} + json-schema-traverse@0.4.1: {} json-stable-stringify-without-jsonify@1.0.1: {} @@ -3754,6 +3789,12 @@ snapshots: punycode@2.3.1: {} + pvtsutils@1.3.6: + dependencies: + tslib: 2.8.1 + + pvutils@1.2.0: {} + qified@0.10.1: dependencies: hookified: 2.2.0 @@ -3850,6 +3891,10 @@ snapshots: siginfo@2.0.0: {} + sm-crypto@0.5.7: + dependencies: + jsbn: 1.1.0 + source-map-js@1.2.1: {} source-map@0.7.6: {} @@ -3927,6 +3972,8 @@ snapshots: ts-interface-checker@0.1.13: {} + tslib@2.8.1: {} + tsup@8.5.1(postcss@8.5.28)(supports-color@7.2.0)(typescript@6.0.3)(yaml@2.9.1): dependencies: bundle-require: 5.1.0(esbuild@0.27.7)