diff --git a/AGENTS.md b/AGENTS.md index 54802e0..972d6aa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,6 +30,8 @@ This file is mandatory for AI and coding agents working in this repository. Read - Routes and sidebar entries are generated from the registry and catalog. Do not manually add an ordinary tool route to `src/router.ts`. - Put parsing, encoding, cryptography, validation, and file-generation logic in testable modules under `src/lib/`. Vue components own presentation and state, not algorithm implementations. - Put expensive file parsing and calculation in `src/workers/`. Workers still run entirely in the browser. +- CRC, Firmware Hash, and RSA Signature Verification file mode use `src/components/FileDropField.vue` for initial selection, loaded-file summaries, drag replacement, explicit replacement, clear actions, and busy feedback. PNG/ICO has a separate image preview/conversion workspace and must not be forced into this component unless its interaction model is deliberately redesigned. +- File tools whose Worker protocol parses a file and then reuses that Worker for calculation should use `src/composables/useTransactionalFileWorker.ts`. Keep each tool's algorithms, Worker message types, and Vue state independent. - Prefer browser standard APIs or existing, reviewed dependencies. Do not hand-roll established cryptographic primitives or file parsers. - Add dependencies only when necessary. Keep the Node server runtime dependency-free unless a requirement cannot reasonably be met with the standard library. - Define and enforce resource limits for file sizes, record counts, blocks, decoded image dimensions, random output, and rendered syntax tokens. If a limit changes, update implementation constants, tests, and the limits table in `README.zh-CN.md` together. @@ -42,6 +44,8 @@ This file is mandatory for AI and coding agents working in this repository. Read - CRC supports BIN and multi-block Intel HEX, CRC-8/16/32/64 presets, and custom `Width`, `Poly`, `Init`, `XorOut`, `RefIn`, and `RefOut`. The Chinese UI labels `RefIn`/`RefOut` as input/output bit reversal; these flags do not reverse byte order or the complete input. Combined data is ordered by address without filling gaps, and per-block results remain available. - Firmware Hash accepts Intel HEX and computes SHA-256 and SHA-512. Effective-data mode concatenates sorted blocks without gap filling. Block-header mode prepends exactly two binary uint32 values, `startAddress` and data-only `dataLength`, to every block. Both fields are four bytes; Big Endian is the default; total input is `Header1 || Data1 || Header2 || Data2 ...`. Never hash a textual Hex representation of the header. - RSA allows 2048, 3072, and 4096-bit keys with public exponent 65537. Prefer Web Crypto; dynamically load the node-forge fallback only when needed, and seed it from `crypto.getRandomValues`. Export public SPKI PEM, private PKCS#8 PEM, and separately copyable N/E continuous Hex or byte arrays. +- RSA Signature Verification is verification-only. Default to RSA-PSS; support switching between PKCS#1 v1.5 and RSA-PSS with SHA-256/384/512, and do not add SHA-1. Accept one SPKI, PKCS#1 public-key, or X.509 certificate PEM block, or N/E Hex parameters. Treat E as an unsigned Hex integer: accept odd-length input by padding one zero nibble on the left, so `10001` and `010001` both mean decimal 65537; other Hex byte inputs remain even-length. X.509 support only extracts the RSA public key and displays metadata; never represent it as certificate-chain, issuer, usage, validity, or trust validation. Verification accepts 1024-8192-bit moduli and warns below 2048 bits; key generation remains restricted to 2048/3072/4096 bits. +- Signature data modes are exact: UTF-8 uses the original text bytes, Hex uses parsed bytes, raw-file mode uses every file byte, and Intel HEX effective-data mode concatenates sorted blocks without gap filling or block headers. Intel HEX block-header mode uses the same binary contract as Firmware Hash: prepend uint32 `startAddress` and uint32 data-only `dataLength` to every block, default to Big Endian, allow explicit Little Endian, and verify `Header1 || Data1 || Header2 || Data2 ...`. Never infer or silently switch file semantics from the filename. PSS salt length is explicit; its default is the smaller of the selected hash length and the current modulus encoding bound, and it must not exceed that bound. Distinguish an invalid signature from malformed input or execution failure. - Secure random output comes only from `crypto.getRandomValues`, defaults to 16 bytes, is limited to 1-65,536 bytes, and supports continuous Hex, spaced Hex, and byte arrays. Changing display format must not regenerate bytes. Missing or failed cryptographic sources must fail closed without a pseudorandom fallback. - JSON/XML formatting, minification, validation, and syntax highlighting stay local. XML parser failures are identified by the browser parser-error namespace; a valid user element named `parsererror` must not be rejected. When highlighting limits are exceeded, render the complete result as plain text rather than truncating it. - PNG/ICO processing stays local and must enforce both compressed-file and decoded-pixel limits before costly rendering or allocation. @@ -55,6 +59,7 @@ This file is mandatory for AI and coding agents working in this repository. Read - Navigation GET normalization and PUT writes are serialized within each Node process. Do not remove that lock without replacing it with an equally strong concurrency design. - CRC and Firmware Hash may share the reviewed Intel HEX parser but must keep separate calculation logic, Worker protocols, and Vue state. - File replacement, clear, mode change, option change, repeated generation, and component unmount can invalidate asynchronous work. Terminate obsolete Workers where possible and use monotonically increasing request/version IDs. A stale Worker, file read, image decode, or key-generation result must never replace current state. +- Treat file replacement as a transaction: parse a candidate in a separate pending Worker and commit it only after successful parsing. Invalid candidates and pending-Worker failures must preserve the active Worker, current file summary, and existing results; only a successful commit clears results and terminates the previous active Worker. - Clear the native file input value after handling a selection so choosing the same file again still emits a change event. - Worker messages must be correlated to both the active Worker instance and active request ID. Error and completion handlers must ignore stale instances. diff --git a/CHANGELOG.md b/CHANGELOG.md index dc1a750..3a1eef7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,31 @@ 本文档记录面向使用者和维护者的重要变更。版本号以 `package.json` 为准。 +## 0.3.0 - 2026-08-13 + +### Added + +- 增加 RSA 签名验证工具,支持 PKCS#1 v1.5、RSA-PSS、SHA-256/384/512、SPKI/PKCS#1/X.509/N-E 公钥,以及 UTF-8、Hex、原始文件和 Intel HEX 有效数据。 +- RSA 签名验证的 Intel HEX 输入增加 Block 校验头模式;每个 block 前加入 8 字节二进制起始地址/数据长度,支持 Big Endian(默认)和 Little Endian。 +- RSA N/E 输入中的指数 E 按无符号 Hex 整数解析;`10001` 等奇数位输入会在左侧补零并正确识别为十进制 65537。 +- 验证结果展示有效/无效状态、算法、数据来源与长度、数据 Hash、签名长度、RSA 公钥规格、SPKI SHA-256 指纹和实际密码学实现;证书仅提取公钥及展示元数据,不验证证书链。 + +### Changed + +- RSA 签名验证工具默认选择 RSA-PSS;Hash 仍默认使用 SHA-256,Salt Length 默认取 Hash 长度与当前模数编码上限中的较小值。 +- CRC 与固件 Hash 的已选文件摘要现在支持直接拖入新文件,也保留“更换文件”和“清空”操作;RSA 签名验证的文件模式使用同一交互。 +- 三个工具共用文件选择/摘要控件和事务式 Worker 生命周期管理,同时保持各自独立的算法、Worker 协议与页面状态。 + +### Fixed + +- 更换文件时先在独立候选 Worker 中完成解析;无效文件、解析错误或候选 Worker 异常不会再清除当前文件和已有计算结果。 + +### Verification + +- 20 个测试文件、152 条 Vitest 测试通过;新增 RSA PKCS#1 v1.5/PSS 三种 Hash、PEM/证书/N-E 公钥、奇数位 E 左侧补零、签名编码、PSS 盐长、1024 位 SHA-512 边界及自动盐长竞态、空消息、篡改签名、原始文件/Intel HEX 有效数据、大小端 Block 校验头验签语义、受限 Hex/UTF-8 输入及文件 Worker 生命周期覆盖。 +- Vue/TypeScript 类型检查、Vite 生产构建和 Git 差异规范检查通过。 +- CRC、固件 Hash 与 RSA 签名验证在桌面、中间宽度和 390×844 移动端视口下完成真实浏览器回归;默认 RSA-PSS/SHA-256/Salt 32、N/E 输入、PSS 1024 位 SHA-512/Salt 62 验签、文件选择与替换均正常,无横向溢出或应用控制台错误。 + ## 0.2.1 - 2026-08-10 ### Fixed diff --git a/README.md b/README.md index 76a2a7e..ef45b93 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ The authoritative application version is the `version` field in `package.json`. - CRC-8/16/32/64 presets and custom parameters for BIN and multi-block Intel HEX files - Firmware SHA-256/SHA-512 over Intel HEX effective data, optionally with an address/length header for each block - RSA 2048/3072/4096-bit key-pair generation with PEM, N/E Hex, and byte-array output +- RSA PKCS#1 v1.5 and RSA-PSS signature verification (RSA-PSS by default) with SHA-256/384/512, PEM/X.509/N-E public keys, and text, Hex, raw-file, Intel HEX effective-data, or Intel HEX block-header input - Cryptographically secure random bytes in continuous Hex, spaced Hex, or byte-array form, defaulting to 16 bytes - JSON formatting, minification, validation, and syntax highlighting - XML formatting, minification, validation, and syntax highlighting @@ -28,6 +29,9 @@ The authoritative application version is the `version` field in `package.json`. - Existing navigation files that still contain the legacy `ascii-hex` tool ID are migrated in place to `utf8-hex`, preserving their category and position. New configurations and application routes use only `utf8-hex`. - The first-run default category order is Encoding, Checksum, Cryptography, Formatting, and Image. A saved `navigation.json` intentionally keeps its existing custom order; it is not silently reset by an application update. - Theme selection is stored in same-origin browser `localStorage`. The only server-side application data is `navigation.json`. +- CRC and Firmware Hash share the same file picker and loaded-file summary. After a file is loaded, a replacement can be dropped directly onto that summary. The candidate is parsed in a separate Worker and replaces the active file only after successful validation; an invalid replacement preserves the current file and results. +- RSA signature verification is verification-only and never requests a private key. X.509 input only extracts the RSA public key and displays certificate metadata; it does not validate trust, issuer, usage, validity, or a certificate chain. Raw-file, Intel HEX effective-data, and Intel HEX block-header modes are explicit and never inferred from the filename. Block-header mode uses the same binary per-block `startAddress`/`dataLength` contract and byte-order selection as Firmware Hash. +- In N/E input mode, E is an unsigned Hex integer. Odd-length input is left-padded by one zero nibble, so `10001` and `010001` both represent decimal 65537; Hex byte inputs elsewhere remain even-length. - Browser navigation writes carrying Fetch Metadata are accepted only when `Sec-Fetch-Site` is `same-origin`. Clients that omit the header are still accepted, so this is defense in depth rather than authentication. - The application is suitable for a private network, VPN, or a deployment protected by an external access-control layer. Do not treat the hidden settings URL as an Internet-facing security boundary. @@ -35,7 +39,7 @@ See [README.zh-CN.md](README.zh-CN.md) for the exact algorithm contracts, input ## Resource limits -The browser rejects oversized or structurally dense inputs before costly allocation. The current limits are 64 MiB for firmware files and merged Intel HEX effective data, 500,000 non-empty Intel HEX records, 1,024 Intel HEX blocks, 32 MiB for compressed PNG files, 16,384 pixels per PNG side and 32,000,000 decoded pixels, 1-65,536 bytes for secure random output (default 16), and 100,000 characters/10,000 tokens for syntax highlighting. Highlighting falls back to complete plain text; it never truncates a result. +The browser rejects oversized or structurally dense inputs before costly allocation. The current limits are 64 MiB for firmware/signature files and merged Intel HEX effective data, 1 MiB for parsed inline signature-verification data with a 6 MiB input-character guard, 1,024-8,192 bits for verification RSA moduli, 500,000 non-empty Intel HEX records, 1,024 Intel HEX blocks, 32 MiB for compressed PNG files, 16,384 pixels per PNG side and 32,000,000 decoded pixels, 1-65,536 bytes for secure random output (default 16), and 100,000 characters/10,000 tokens for syntax highlighting. Highlighting falls back to complete plain text; it never truncates a result. ## Docker deployment @@ -113,6 +117,7 @@ npm audit --audit-level=low ```text src/components/ shared UI components +src/composables/ reusable Vue state and asynchronous lifecycle management src/lib/ testable computation and parsing logic src/tools/ one lazy-loaded Vue component per tool src/workers/ browser workers for expensive file operations @@ -123,9 +128,9 @@ tests/ unit and server integration tests public/ browser icons and Web App Manifest ``` -New tools must keep their computation independent, register the same stable ID in the catalog, default navigation, and lazy-load registry, reuse the shared UI, define resource limits where appropriate, and include normal, invalid, boundary, and known-vector tests. Routes are generated from the tool registry; ordinary tools do not add routes manually. +New tools must keep their computation independent, register the same stable ID in the catalog, default navigation, and lazy-load registry, reuse the shared UI, define resource limits where appropriate, and include normal, invalid, boundary, and known-vector tests. File tools matching the existing parse-then-calculate Worker protocol should reuse `FileDropField` and `useTransactionalFileWorker`, while keeping tool-specific algorithms, Worker messages, and Vue state separate. Candidate replacement must not destroy active state before parsing succeeds. Routes are generated from the tool registry; ordinary tools do not add routes manually. -RSA uses Web Crypto first and dynamically loads the bundled `node-forge` fallback chunk from the same origin only when the native provider is unavailable or fails. Tool components remain lazy-loaded; the project does not add a Service Worker or duplicate the Brotli/Gzip compression normally supplied by a production reverse proxy such as Cloudflare. +RSA generation and verification use Web Crypto first and dynamically load the bundled `node-forge` fallback chunk from the same origin only when the native provider is unavailable or fails. Tool components remain lazy-loaded; the project does not add a Service Worker or duplicate the Brotli/Gzip compression normally supplied by a production reverse proxy such as Cloudflare. Only fingerprinted files under `dist/assets/` receive long-lived immutable caching. HTML, icons, the Manifest, and other stable filenames use `no-cache` so deployments can update them without waiting for a one-year browser cache to expire. XML validation identifies browser parser failures by their dedicated namespace, so a valid user element named `parsererror` is not rejected. diff --git a/README.zh-CN.md b/README.zh-CN.md index aadaaef..0220b52 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -4,7 +4,7 @@ 一个匿名访问、自托管的浏览器本地工具箱。支持多人同时使用;除全局导航顺序外,文本、密钥、文件、图片和计算结果均只存在于各自浏览器中,不会发送到服务端。 -当前应用版本:`0.2.1`。 +当前应用版本:`0.3.0`。 ## 内置工具 @@ -14,6 +14,7 @@ - CRC-8/16/32/64 预设与自定义参数,支持 BIN 和多 block Intel HEX - 固件 SHA-256 / SHA-512,支持 Intel HEX 有效数据和带地址/长度的 Block 校验头 - RSA 2048/3072/4096 位密钥对,导出 PEM,以及可分别复制的 N/E Hex 或代码字节数组 +- RSA 签名验证,支持 PKCS#1 v1.5、RSA-PSS(默认)、SHA-256/384/512、PEM/X.509/N-E 公钥,以及文本、Hex、原始文件、Intel HEX 有效数据和 Block 校验头 - 密码学安全随机数,默认 16 字节,支持连续 Hex、空格 Hex 和代码字节数组 - JSON 格式化、压缩、校验和语法着色 - XML 格式化、压缩、校验和语法着色 @@ -28,6 +29,7 @@ ```text src/ Vue 3 前端源码 components/ 所有工具共用的 UI 组件 + composables/ 可复用的 Vue 状态与异步生命周期管理 lib/ 可测试的纯计算与浏览器辅助逻辑 tools/ 每个工具一个独立 Vue 组件 workers/ 大文件计算使用的浏览器后台线程 @@ -49,9 +51,9 @@ CHANGELOG.md 版本变更、修复和验证记录 ## 架构约束 - Vue 3、TypeScript、Vite 前端;Node.js 标准库服务端;不引入数据库。 -- 工具组件彼此独立并懒加载,公共交互使用 `ToolPage`、`ModeTabs`、`TextEditor` 和 `src/styles.css` 中的现有样式。 +- 工具组件彼此独立并懒加载,公共交互使用 `ToolPage`、`ModeTabs`、`TextEditor`、`FileDropField` 和 `src/styles.css` 中的现有样式。CRC、固件 Hash 与 RSA 签名验证的文件模式共用 `FileDropField` 的文件选择、摘要、拖入更换和清空交互;PNG/ICO 的图片工作区有独立的预览与转换流程,不属于该共用控件范围。 - 工具的计算、解析和文件生成逻辑放在 `src/lib/`,Vue 组件只负责状态与交互。 -- 大文件或长时间计算必须放入 `src/workers/` 的 Web Worker,避免阻塞页面交互;Worker 仍只在浏览器本地处理数据。 +- 大文件或长时间计算必须放入 `src/workers/` 的 Web Worker,避免阻塞页面交互;Worker 仍只在浏览器本地处理数据。协议符合“解析后复用同一 Worker 计算”的文件工具应使用 `useTransactionalFileWorker` 管理候选 Worker、活动 Worker 和请求 ID,同时保留各工具独立的 Worker 协议、计算逻辑及 Vue 状态。 - 路由和左侧导航由工具目录自动生成,增加普通工具时不得手工新增路由。 - 工具输入只在浏览器内存中处理,不得发送给服务端、遥测服务或第三方接口。 - 服务端只持久化导航顺序,文件为 `/data/navigation.json`,写入采用临时文件和原子替换。 @@ -71,16 +73,18 @@ CHANGELOG.md 版本变更、修复和验证记录 - Base64 支持标准和 URL-safe 形式;解码结果可显示为严格 UTF-8、连续 Hex 或 `0x48, 0x9F, ...` 字节数组。无效字符、错误填充和非规范编码必须报错。 - AES-CMAC 密钥长度可选 128、192 或 256 位,默认 128 位,并分别严格要求 16、24 或 32 字节;不自动补零或截断。密钥和 Hex 消息接受连续 Hex,或空格、换行、英文/中文逗号分隔,并允许片段使用可选的 `0x` 前缀。CMAC 标签始终为 128 位;AES-128 向量遵循 RFC 4493,AES-192/256 向量遵循 NIST SP 800-38B。 - CRC 支持 BIN 和多 block Intel HEX,提供 CRC-8/16/32/64 预设,并允许自定义 `Width`、`Poly`、`Init`、`XorOut`、`RefIn` 和 `RefOut`。`RefIn`/`RefOut` 在页面中标为输入/输出位反转,不表示字节顺序或整个数据倒序。总 CRC 对按地址升序拼接的有效数据计算,不填充地址空洞,同时输出每个 block 的结果。 -- 固件 Hash 只接受 Intel HEX,输出 SHA-256 和 SHA-512。“有效数据”与“Block 校验头”模式的确切字节流见“固件 Hash 固定语义”;默认校验头字节序为 Big Endian。 -- RSA 只允许 2048、3072、4096 位密钥,公钥指数固定为 65537。优先使用 Web Crypto,只有本地提供者不可用或失败时才动态加载 `node-forge` 回退,其随机种子仍只来自 `crypto.getRandomValues`;公钥为 SPKI PEM,私钥为 PKCS#8 PEM,N/E 可分别显示为连续 Hex 或代码字节数组。 +- 固件 Hash 只接受 Intel HEX,输出 SHA-256 和 SHA-512。“有效数据”与“Block 校验头”模式的确切字节流见“Intel HEX 与 Block 校验头固定语义”;默认校验头字节序为 Big Endian。 +- RSA 密钥对工具只生成 2048、3072、4096 位密钥,公钥指数固定为 65537。优先使用 Web Crypto,只有本地提供者不可用或失败时才动态加载 `node-forge` 回退,其随机种子仍只来自 `crypto.getRandomValues`;公钥为 SPKI PEM,私钥为 PKCS#8 PEM,N/E 可分别显示为连续 Hex 或代码字节数组。 +- RSA 签名验证工具只执行验证,不接收私钥或生成签名。它默认选择 RSA-PSS,并支持切换到 RSA PKCS#1 v1.5;Hash 支持 SHA-256/384/512,不支持 SHA-1。PSS 的 Salt Length 必须显式给出,默认取 Hash 长度与当前模数编码上限中的较小值,并受当前模数与 Hash 的编码上限约束。公钥可使用单个 SPKI `PUBLIC KEY`、PKCS#1 `RSA PUBLIC KEY`、X.509 `CERTIFICATE` PEM 块或 N/E Hex 参数;E 按无符号 Hex 整数解析,奇数位输入会在最左侧补一个 `0`,因此 `10001`、`010001` 均表示十进制 65537,其他 Hex 字节输入仍要求偶数位。X.509 只用于提取 RSA 公钥和展示 Subject、序列号、有效期,不验证证书链、颁发者、用途或可信状态。验证接受 1024 至 8192 位 RSA 公钥,低于 2048 位时显示兼容性警告。 +- 签名可输入 Hex、Base64 或 Base64 URL。待验证数据可为 UTF-8 原文、Hex 字节、原始文件字节、Intel HEX 按 32 位绝对地址升序拼接的有效数据,或带 Block 校验头的 Intel HEX 数据。“Intel HEX 有效数据”不填充地址空洞,也不加入校验头;“Block 校验头”对每个 block 加入与固件 Hash 相同的 8 字节二进制地址/长度头,默认 Big Endian,可显式切换 Little Endian。文件语义必须由用户明确选择,工具不得按扩展名自动推断或切换。 - 安全随机数必须来自浏览器 `crypto.getRandomValues`,默认 16 字节,允许长度为 1 至 65,536 字节,支持连续 Hex、空格 Hex 和代码字节数组;切换显示格式不得重新生成随机数据。安全随机源不可用或抛错时直接失败,不使用普通伪随机回退。 - JSON/XML 的格式化、压缩和校验均在浏览器本地完成。合法 XML 可以使用 `parsererror` 作为元素名;只把浏览器专用解析错误命名空间中的节点视为解析失败。语法着色超过资源限制时只能降级为完整纯文本,不能截断结果。 -- 文件选择、文件更换、清空、模式切换和组件卸载都可能使异步任务失效。旧 Worker、旧图像解码和旧密钥生成结果不得覆盖当前输入或已清空状态。 +- 文件选择、文件更换、清空、模式切换和组件卸载都可能使异步任务失效。旧 Worker、旧图像解码和旧密钥生成结果不得覆盖当前输入或已清空状态。CRC、固件 Hash 与 RSA 签名验证的文件模式在已有文件时支持把新文件直接拖入摘要区域;候选文件解析成功后才替换当前文件并清除旧结果,解析失败时保留当前文件和结果。 ## 多人使用与状态边界 - 每个浏览器标签页拥有独立的 Vue 组件状态、文件对象、图像对象和 Web Worker。不同用户、浏览器或标签页不会共享工具输入与结果。 -- CRC、固件 Hash、RSA、AES-CMAC、随机数、PNG/ICO、编码转换和格式化都在浏览器中执行;服务端没有工具计算接口。 +- CRC、固件 Hash、RSA 密钥生成与签名验证、AES-CMAC、随机数、PNG/ICO、编码转换和格式化都在浏览器中执行;服务端没有工具计算接口。 - `/tools/setting` 保存的是所有访问者共享的导航配置。同一服务进程会按进入导航操作队列的顺序串行处理读写,最后排队的保存生效;不提供版本冲突合并。 - 旧导航配置中的历史工具 ID `ascii-hex` 会在读取时原位迁移为 `utf8-hex`,保留原分类和排序;新配置和应用路由只使用 `utf8-hex`。 - 原子替换保证读取者看到完整的旧配置或新配置,不会看到写到一半的 JSON;它不保证保留多个用户各自的排序修改。 @@ -93,11 +97,14 @@ CHANGELOG.md 版本变更、修复和验证记录 | 功能 | 当前限制 | 超限行为 | | --- | --- | --- | -| CRC 固件文件 | 最大 64 MiB | 选择时拒绝,不保留旧文件结果 | +| CRC 固件文件 | 最大 64 MiB | 首次选择时拒绝;替换时保留当前文件和结果 | | Intel HEX 非空记录 | 最多 500,000 条 | Worker 停止解析并显示错误 | | Intel HEX 数据块 | 最多 1,024 个 block | Worker 停止解析并显示错误 | | Intel HEX 合并后的有效数据 | 最大 64 MiB | 合并分配前拒绝,Worker 停止解析并显示错误 | -| 固件 Hash 文件及有效数据 | 文件最大 64 MiB,有效数据最大 64 MiB | 选择或计算时拒绝 | +| 固件 Hash 文件及有效数据 | 文件最大 64 MiB,有效数据最大 64 MiB | 首次选择或计算时拒绝;替换解析失败时保留当前文件和结果 | +| RSA 签名验证内联数据 | UTF-8 或 Hex 解析后最大 1 MiB;原始输入最多 6 MiB 字符 | 在分配超限结果前拒绝,并提示改用文件模式 | +| RSA 签名验证文件 | 原始文件或 Intel HEX 文件最大 64 MiB;Intel HEX 有效数据或加入 Block 校验头后的验签数据最大 64 MiB | 首次选择时拒绝;替换失败时保留当前文件和结果 | +| RSA 签名验证输入 | PEM 最大 64 KiB;N/E 与签名输入各最大 16 KiB;RSA 模数 1024 至 8192 位 | 解析或验证前拒绝 | | PNG 压缩文件 | 最大 32 MiB | 创建预览前拒绝 | | PNG 尺寸 | 单边最大 16,384 像素,总计最大 32,000,000 像素 | 浏览器解码前拒绝 | | 安全随机数 | 默认 16 字节,每次 1 至 65,536 字节 | 拒绝无效长度或不安全回退 | @@ -255,10 +262,10 @@ docker compose restart web-tools ## 离线镜像 -若交付目录包含 `personal-web-tools-0.2.1-amd64.tar`,可直接导入: +若交付目录包含 `personal-web-tools-0.3.0-amd64.tar`,可直接导入: ```bash -docker load -i personal-web-tools-0.2.1-amd64.tar +docker load -i personal-web-tools-0.3.0-amd64.tar docker compose up -d --no-build ``` @@ -312,13 +319,13 @@ npm audit --audit-level=low `npm run check` 会依次运行全部 Vitest 测试、TypeScript/Vue 类型检查和生产构建。服务端测试使用随机本地端口验证 SPA 直达路由、静态资源 404 与缓存策略、导航 API、请求大小限制及跨源浏览器写入拦截。 -自动化测试还覆盖标准算法向量、Base64 规范性、Intel HEX 记录与 block 边界、固件 Hash 与大小端 Block 头向量、PNG 头部与像素限制、导航排序纯函数、目录/图标/懒加载组件一致性,以及原子写入失败后的临时文件清理。 +自动化测试还覆盖标准算法向量、Base64 规范性、Intel HEX 记录与 block 边界、固件 Hash 与大小端 Block 头向量、RSA PKCS#1 v1.5/PSS 三种 Hash、PEM/X.509/N-E 公钥解析、签名编码与 PSS 盐长边界、原始文件、Intel HEX 有效数据与大小端 Block 校验头的验签字节语义、文件替换成功提交、失败保留旧状态、过期 Worker 响应隔离和 Worker 生命周期异常、PNG 头部与像素限制、导航排序纯函数、目录/图标/懒加载组件一致性,以及原子写入失败后的临时文件清理。 ## 增加工具 -### 固件 Hash 固定语义 +### Intel HEX 与 Block 校验头固定语义 -固件 Hash 与 CRC 共用经过校验的 Intel HEX 解析器,但使用独立纯逻辑、Worker 和 Vue 页面。解析器处理扩展段/线性地址,将数据按 32 位绝对地址排序,合并连续记录,去重内容一致的重叠记录,并拒绝冲突或越过 `0xFFFFFFFF` 的数据。记录文本、记录校验和、EOF 和启动地址不参与 Hash。 +固件 Hash、RSA 签名验证与 CRC 共用经过校验的 Intel HEX 解析器,但使用独立纯逻辑、Worker 和 Vue 页面。解析器处理扩展段/线性地址,将数据按 32 位绝对地址排序,合并连续记录,去重内容一致的重叠记录,并拒绝冲突或越过 `0xFFFFFFFF` 的数据。记录文本、记录校验和、EOF 和启动地址不参与 Hash 或签名验证。 摘要计算优先使用浏览器原生 Web Crypto;通过普通 HTTP 局域网地址访问而无法使用 `SubtleCrypto` 时,自动回退到项目内已有的 `node-forge` SHA-256/SHA-512 实现。两条路径都在 Web Worker 中本地处理,不会把固件数据发送到服务端。 @@ -329,7 +336,7 @@ uint32 startAddress uint32 dataLength ``` -默认使用 Big Endian,可以显式切换 Little Endian。两个字段始终为 4 字节;`dataLength` 只表示该 block 的有效数据字节数,不包含 8 字节校验头。总输入严格为 `Header1 || Data1 || Header2 || Data2 ...`,同时输出总 SHA-256/SHA-512 和每个 block 的独立摘要。不得把固定宽度 Hex 文本误作为 ASCII 字符加入 Hash,也不得隐式使用运行设备的原生端序。 +默认使用 Big Endian,可以显式切换 Little Endian。两个字段始终为 4 字节;`dataLength` 只表示该 block 的有效数据字节数,不包含 8 字节校验头。总输入严格为 `Header1 || Data1 || Header2 || Data2 ...`。固件 Hash 对该输入输出总 SHA-256/SHA-512 和每个 block 的独立摘要;RSA 签名验证则把同一输入交给所选 RSA/Hash 算法验证。不得把固定宽度 Hex 文本误作为 ASCII 字符加入计算,也不得隐式使用运行设备的原生端序。 ### 1. 先定义稳定标识 @@ -348,7 +355,7 @@ uint32 dataLength 在 `tests/` 添加正常输入、边界输入、无效输入和已知测试向量。涉及字节的工具必须明确 UTF-8、Hex、Base64、换行及分隔符语义,避免隐式改变数据。 -文件或高开销工具还必须定义文件大小、记录数量、图像尺寸或输出 DOM 数量等资源上限。异步工具在清空、更换输入或卸载后必须忽略旧任务结果;计算量较大的文件解析与 CRC 计算放入 Web Worker。 +文件或高开销工具还必须定义文件大小、记录数量、图像尺寸或输出 DOM 数量等资源上限。异步工具在清空、更换输入或卸载后必须忽略旧任务结果;计算量较大的文件解析与 CRC 计算放入 Web Worker。若新工具采用“解析文件后继续在同一 Worker 中计算”的协议,应优先复用 `FileDropField` 和 `useTransactionalFileWorker`;候选文件解析成功前不得销毁当前 Worker、文件摘要或结果。 ### 3. 编写统一的工具页面 @@ -358,7 +365,7 @@ uint32 dataLength - 文本输入输出优先使用 `TextEditor`。 - 模式切换使用 `ModeTabs`。 - 操作按钮使用 Lucide 图标和现有 `action-button` 类。 -- 必须提供清空、错误提示和必要的复制/粘贴操作。 +- 必须提供清空、错误提示和必要的复制/粘贴操作。文件工具的交互与现有协议匹配时复用 `FileDropField`,不要复制一套相近的上传框和替换状态。 - 不增加登录、收藏、赞助、遥测、外部网络请求或独立视觉风格。 - 在窄屏下确认控件不溢出、不重叠,文字不会遮挡。 @@ -414,9 +421,9 @@ npm audit --audit-level=low 1. `shared/tool-catalog.json` 和 `shared/default-navigation.json` 2. `src/tools/registry.ts` 3. 与目标功能最接近的 `src/tools/*.vue` -4. `src/components/ToolPage.vue`、`TextEditor.vue`、`ModeTabs.vue` -5. 相关 `src/lib/` 和 `tests/` -6. `server/navigation.mjs` 中的导航兼容逻辑 +4. `src/components/ToolPage.vue`、`TextEditor.vue`、`ModeTabs.vue`;文件工具还要检查 `FileDropField.vue` +5. 相关 `src/lib/`、`src/workers/` 和 `tests/`;采用解析/计算 Worker 协议时还要检查 `src/composables/useTransactionalFileWorker.ts` +6. 涉及工具登记或导航行为时,检查 `server/navigation.mjs` 中的导航兼容逻辑 AI 不应仅修改 `dist/`;必须修改源码并重新构建。不得覆盖或删除用户的 `data/navigation.json`。发现工作区已有未说明的改动时应保留并兼容,不得擅自回退。 diff --git a/docker-compose.yml b/docker-compose.yml index deaae8d..201f14a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,6 @@ services: web-tools: - image: personal-web-tools:0.2.1 + image: personal-web-tools:0.3.0 build: context: . dockerfile: Dockerfile diff --git a/package-lock.json b/package-lock.json index 17a4230..c593b4b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "personal-web-tools", - "version": "0.2.1", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "personal-web-tools", - "version": "0.2.1", + "version": "0.3.0", "dependencies": { "@lucide/vue": "^1.29.0", "@stablelib/aes": "^2.0.1", diff --git a/package.json b/package.json index 2810813..7df1ec1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "personal-web-tools", - "version": "0.2.1", + "version": "0.3.0", "private": true, "type": "module", "engines": { diff --git a/shared/default-navigation.json b/shared/default-navigation.json index 310d7b3..3a6b1ac 100644 --- a/shared/default-navigation.json +++ b/shared/default-navigation.json @@ -3,7 +3,7 @@ "categories": [ { "id": "encoding", "tools": ["utf8-hex", "base64"] }, { "id": "checksum", "tools": ["crc", "firmware-hash"] }, - { "id": "crypto", "tools": ["aes-cmac", "rsa-key-pair", "random"] }, + { "id": "crypto", "tools": ["aes-cmac", "rsa-key-pair", "signature-verify", "random"] }, { "id": "formatting", "tools": ["json", "xml"] }, { "id": "image", "tools": ["png-ico"] } ] diff --git a/shared/tool-catalog.json b/shared/tool-catalog.json index 0930c52..6ac17d8 100644 --- a/shared/tool-catalog.json +++ b/shared/tool-catalog.json @@ -36,6 +36,13 @@ "description": "生成 RSA 密钥对并导出 PEM 与 N/E 参数。", "icon": "KeyRound" }, + { + "id": "signature-verify", + "title": "RSA 签名验证", + "category": "crypto", + "description": "使用 RSA 公钥验证文本或文件的数字签名。", + "icon": "Signature" + }, { "id": "random", "title": "安全随机数", diff --git a/src/components/FileDropField.vue b/src/components/FileDropField.vue new file mode 100644 index 0000000..4f7714b --- /dev/null +++ b/src/components/FileDropField.vue @@ -0,0 +1,110 @@ + + + diff --git a/src/components/TextEditor.vue b/src/components/TextEditor.vue index 179644f..63d8227 100644 --- a/src/components/TextEditor.vue +++ b/src/components/TextEditor.vue @@ -8,12 +8,14 @@ const props = withDefaults(defineProps<{ meta?: string placeholder?: string readonly?: boolean + disabled?: boolean rows?: number }>(), { format: '', meta: '', placeholder: '', readonly: false, + disabled: false, rows: 9, }) @@ -37,6 +39,7 @@ function update(event: Event) { :value="props.modelValue" :placeholder="placeholder" :readonly="readonly" + :disabled="disabled" :rows="rows" spellcheck="false" @input="update" diff --git a/src/composables/useTransactionalFileWorker.ts b/src/composables/useTransactionalFileWorker.ts new file mode 100644 index 0000000..2b6e33c --- /dev/null +++ b/src/composables/useTransactionalFileWorker.ts @@ -0,0 +1,171 @@ +import { readonly, ref } from 'vue' + +interface ParsedWorkerResponse { + type: 'parsed' + id: number +} + +interface CalculatedWorkerResponse { + type: 'calculated' + id: number +} + +interface ErrorWorkerResponse { + type: 'error' + id: number + operation: 'parse' | 'calculate' + error: string +} + +type WorkerResponse = ParsedWorkerResponse | CalculatedWorkerResponse | ErrorWorkerResponse +export type WorkerStartStatus = 'started' | 'unavailable' | 'failed' + +interface TransactionalFileWorkerOptions { + createWorker: () => Worker + createParseRequest?: (id: number, file: File) => object + onParsed: (response: Extract, file: File) => void + onCalculated: (response: Extract) => void + onParseError: (message: string) => void + onCalculationError: (message: string) => void + onPendingWorkerError: (hasCurrentFile: boolean) => void + onActiveWorkerError: () => void +} + +function errorMessage(reason: unknown): string { + return reason instanceof Error ? reason.message : '后台线程通信失败' +} + +export function useTransactionalFileWorker( + options: TransactionalFileWorkerOptions, +) { + const parsing = ref(false) + const calculating = ref(false) + let activeWorker: Worker | undefined + let pendingWorker: Worker | undefined + let requestSequence = 0 + let activeParseRequest: number | undefined + let activeCalculationRequest: number | undefined + + function destroyActiveWorker() { + activeWorker?.terminate() + activeWorker = undefined + activeCalculationRequest = undefined + calculating.value = false + } + + function destroyPendingWorker() { + pendingWorker?.terminate() + pendingWorker = undefined + activeParseRequest = undefined + parsing.value = false + } + + function configureWorker(nextWorker: Worker, selected: File) { + nextWorker.onmessage = (event: MessageEvent) => { + const response = event.data + if (response.type === 'parsed') { + if (nextWorker !== pendingWorker || response.id !== activeParseRequest) return + pendingWorker = undefined + activeParseRequest = undefined + parsing.value = false + destroyActiveWorker() + activeWorker = nextWorker + options.onParsed(response as Extract, selected) + return + } + if (response.type === 'calculated') { + if (nextWorker !== activeWorker || response.id !== activeCalculationRequest) return + activeCalculationRequest = undefined + calculating.value = false + options.onCalculated(response as Extract) + return + } + if (response.operation === 'parse') { + if (nextWorker !== pendingWorker || response.id !== activeParseRequest) return + destroyPendingWorker() + options.onParseError(response.error) + return + } + if (nextWorker !== activeWorker || response.id !== activeCalculationRequest) return + activeCalculationRequest = undefined + calculating.value = false + options.onCalculationError(response.error) + } + + function handleWorkerFailure(event: Event) { + event.preventDefault() + if (nextWorker === pendingWorker) { + const hasCurrentFile = Boolean(activeWorker) + destroyPendingWorker() + options.onPendingWorkerError(hasCurrentFile) + return + } + if (nextWorker === activeWorker) { + destroyActiveWorker() + options.onActiveWorkerError() + } + } + + nextWorker.onerror = handleWorkerFailure + nextWorker.onmessageerror = handleWorkerFailure + } + + function parseFile(selected: File): boolean { + if (parsing.value) return false + let nextWorker: Worker + try { + nextWorker = options.createWorker() + } catch { + options.onPendingWorkerError(Boolean(activeWorker)) + return false + } + configureWorker(nextWorker, selected) + pendingWorker = nextWorker + const id = ++requestSequence + activeParseRequest = id + parsing.value = true + try { + nextWorker.postMessage(options.createParseRequest?.(id, selected) ?? { type: 'parse', id, file: selected }) + return true + } catch (reason) { + destroyPendingWorker() + options.onParseError(errorMessage(reason)) + return false + } + } + + function calculate(payload: TCalculatePayload): WorkerStartStatus { + if (!activeWorker || calculating.value || parsing.value) return 'unavailable' + const id = ++requestSequence + activeCalculationRequest = id + calculating.value = true + try { + activeWorker.postMessage({ ...payload, type: 'calculate', id }) + return 'started' + } catch (reason) { + activeCalculationRequest = undefined + calculating.value = false + options.onCalculationError(errorMessage(reason)) + return 'failed' + } + } + + function invalidateCalculation() { + activeCalculationRequest = undefined + calculating.value = false + } + + function destroyWorkers() { + destroyPendingWorker() + destroyActiveWorker() + } + + return { + parsing: readonly(parsing), + calculating: readonly(calculating), + parseFile, + calculate, + invalidateCalculation, + destroyWorkers, + } +} diff --git a/src/lib/bytes.ts b/src/lib/bytes.ts index 366885e..2db4959 100644 --- a/src/lib/bytes.ts +++ b/src/lib/bytes.ts @@ -1,4 +1,29 @@ -export function parseHex(input: string, exactBytes?: number): Uint8Array { +export interface ParseHexOptions { + exactBytes?: number + maxBytes?: number + maxInputChars?: number + limitLabel?: string +} + +function parseHexOptions(options?: number | ParseHexOptions): ParseHexOptions { + return typeof options === 'number' ? { exactBytes: options } : options ?? {} +} + +function isHexSeparator(character: string): boolean { + return character === ',' || character === ',' || /\s/.test(character) +} + +function formatHexLimit(bytes: number): string { + if (bytes % (1024 * 1024) === 0) return `${bytes / 1024 / 1024} MiB` + if (bytes % 1024 === 0) return `${bytes / 1024} KiB` + return `${bytes} 字节` +} + +export function parseHex(input: string, options?: number | ParseHexOptions): Uint8Array { + const { exactBytes, maxBytes, maxInputChars, limitLabel = '十六进制数据' } = parseHexOptions(options) + if (maxInputChars !== undefined && input.length > maxInputChars) { + throw new Error(`${limitLabel}输入不能超过 ${maxInputChars.toLocaleString()} 个字符`) + } const trimmed = input.trim() if (!trimmed) { @@ -6,23 +31,72 @@ export function parseHex(input: string, exactBytes?: number): Uint8Array { throw new Error(`需要 ${exactBytes} 字节,当前为 0 字节`) } - const tokens = trimmed.split(/[\s,,]+/) - const normalizedTokens = tokens.map((token) => token.replace(/^0x/i, '')) - const emptyToken = normalizedTokens.findIndex((token) => !token) - if (emptyToken !== -1) throw new Error(`第 ${emptyToken + 1} 个十六进制片段缺少数值`) - const normalized = normalizedTokens.join('') - const invalid = normalized.match(/[^0-9a-f]/i) - if (invalid) throw new Error(`包含无效的十六进制字符“${invalid[0]}”`) - if (normalized.length % 2 !== 0) throw new Error('十六进制字符数量必须为偶数') + let digits = 0 + let tokenActive = false + let tokenHasDigits = false + let tokenPrefixSeen = false + let sawToken = false + for (let index = 0; index < trimmed.length; index += 1) { + const character = trimmed[index] + if (isHexSeparator(character)) { + if ((character === ',' || character === ',') && !sawToken) throw new Error('十六进制片段缺少数值') + if (tokenActive && !tokenHasDigits) throw new Error('十六进制片段缺少数值') + tokenActive = false + tokenHasDigits = false + tokenPrefixSeen = false + continue + } + if (!tokenActive) tokenActive = true + if (!tokenHasDigits && !tokenPrefixSeen && character === '0' && /[xX]/.test(trimmed[index + 1] ?? '')) { + index += 1 + tokenPrefixSeen = true + continue + } + if (!/[0-9a-f]/i.test(character)) throw new Error(`包含无效的十六进制字符“${character}”`) + digits += 1 + tokenHasDigits = true + sawToken = true + } + if (tokenActive && !tokenHasDigits) throw new Error('十六进制片段缺少数值') + if (/[,,]$/.test(trimmed)) throw new Error('十六进制片段缺少数值') + if (digits % 2 !== 0) throw new Error('十六进制字符数量必须为偶数') - const byteLength = normalized.length / 2 + const byteLength = digits / 2 if (exactBytes !== undefined && byteLength !== exactBytes) { throw new Error(`需要 ${exactBytes} 字节,当前为 ${byteLength} 字节`) } + if (maxBytes !== undefined && byteLength > maxBytes) { + throw new Error(`${limitLabel}不能超过 ${formatHexLimit(maxBytes)}`) + } const result = new Uint8Array(byteLength) - for (let index = 0; index < byteLength; index += 1) { - result[index] = Number.parseInt(normalized.slice(index * 2, index * 2 + 2), 16) + let highNibble = -1 + let outputIndex = 0 + tokenActive = false + tokenHasDigits = false + tokenPrefixSeen = false + for (let index = 0; index < trimmed.length; index += 1) { + const character = trimmed[index] + if (isHexSeparator(character)) { + tokenActive = false + tokenHasDigits = false + tokenPrefixSeen = false + continue + } + if (!tokenActive) tokenActive = true + if (!tokenHasDigits && !tokenPrefixSeen && character === '0' && /[xX]/.test(trimmed[index + 1] ?? '')) { + index += 1 + tokenPrefixSeen = true + continue + } + const nibble = Number.parseInt(character, 16) + if (highNibble < 0) highNibble = nibble + else { + result[outputIndex] = highNibble * 16 + nibble + outputIndex += 1 + highNibble = -1 + } + tokenHasDigits = true } return result } @@ -56,6 +130,27 @@ export function textToBytes(value: string): Uint8Array { return new TextEncoder().encode(value) } +export function textToBytesLimited( + value: string, + maxBytes: number, + maxInputChars?: number, + limitLabel = '文本数据', +): Uint8Array { + if (maxInputChars !== undefined && value.length > maxInputChars) { + throw new Error(`${limitLabel}输入不能超过 ${maxInputChars.toLocaleString()} 个字符`) + } + let byteLength = 0 + for (const character of value) { + const codePoint = character.codePointAt(0) as number + byteLength += codePoint <= 0x7F ? 1 : codePoint <= 0x7FF ? 2 : codePoint <= 0xFFFF ? 3 : 4 + if (byteLength > maxBytes) throw new Error(`${limitLabel}不能超过 ${maxBytes.toLocaleString()} 字节`) + } + const result = new Uint8Array(byteLength) + const encoded = new TextEncoder().encodeInto(value, result) + if (encoded.read !== value.length || encoded.written !== byteLength) throw new Error('UTF-8 数据编码失败') + return result +} + export function bytesToText(value: Uint8Array): string { try { return new TextDecoder('utf-8', { fatal: true }).decode(value) diff --git a/src/lib/signatureVerification.ts b/src/lib/signatureVerification.ts new file mode 100644 index 0000000..4fd0d5e --- /dev/null +++ b/src/lib/signatureVerification.ts @@ -0,0 +1,313 @@ +import { decodeBase64, formatBytes, parseHex } from '@/lib/bytes' + +export type RsaSignatureScheme = 'pkcs1-v1_5' | 'pss' +export type SignatureHashAlgorithm = 'SHA-256' | 'SHA-384' | 'SHA-512' +export type SignatureEncoding = 'hex' | 'base64' | 'base64url' +export type RsaPublicKeySource = 'SPKI' | 'PKCS#1' | 'X.509 certificate' | 'N/E parameters' + +export const MAX_RSA_PUBLIC_KEY_INPUT_CHARS = 64 * 1024 +export const MAX_RSA_PARAMETER_INPUT_CHARS = 16 * 1024 +export const MAX_SIGNATURE_INPUT_CHARS = 16 * 1024 +export const MAX_INLINE_SIGNATURE_DATA_BYTES = 1024 * 1024 +export const MAX_INLINE_SIGNATURE_INPUT_CHARS = 6 * 1024 * 1024 +export const MIN_RSA_MODULUS_BITS = 1024 +export const MAX_RSA_MODULUS_BITS = 8192 + +export interface RsaCertificateInfo { + subject: string + serialNumber: string + notBefore: string + notAfter: string +} + +export interface ParsedRsaPublicKey { + source: RsaPublicKeySource + modulusHex: string + exponentHex: string + exponentDecimal: string + modulusBits: number + spkiDer: Uint8Array + fingerprintSha256: string + certificate?: RsaCertificateInfo +} + +export interface RsaVerificationOptions { + scheme: RsaSignatureScheme + hash: SignatureHashAlgorithm + saltLength?: number +} + +export interface RsaVerificationResult { + valid: boolean + provider: 'Web Crypto' | 'node-forge' + dataHash: string + dataBytes: number + signatureBytes: number +} + +function binaryToBytes(value: string): Uint8Array { + return Uint8Array.from(value, (character) => character.charCodeAt(0)) +} + +function bytesToBinary(value: Uint8Array): string { + const chunkSize = 0x8000 + let result = '' + for (let offset = 0; offset < value.length; offset += chunkSize) { + result += String.fromCharCode(...value.subarray(offset, offset + chunkSize)) + } + return result +} + +function normalizeUnsignedHex(value: string): string { + const normalized = value.replace(/^0+/, '') || '00' + return (normalized.length % 2 ? `0${normalized}` : normalized).toUpperCase() +} + +function parseUnsignedHexInteger(input: string, label: string): Uint8Array { + const trimmed = input.trim() + if (!trimmed) return new Uint8Array() + const tokens = trimmed.split(/[\s,,]+/).map((token) => token.replace(/^0x/i, '')) + const emptyToken = tokens.findIndex((token) => !token) + if (emptyToken !== -1) throw new Error(`${label} 的第 ${emptyToken + 1} 个十六进制片段缺少数值`) + const normalized = tokens.join('') + const invalid = normalized.match(/[^0-9a-f]/i) + if (invalid) throw new Error(`${label} 包含无效的十六进制字符“${invalid[0]}”`) + const significant = normalized.replace(/^0+/, '') || '0' + return parseHex(significant.length % 2 ? `0${significant}` : significant) +} + +function formatCertificateSubject(attributes: Array<{ shortName?: string, name?: string, value?: unknown }>): string { + const parts = attributes.map((attribute) => { + const label = attribute.shortName || attribute.name || 'OID' + return `${label}=${String(attribute.value ?? '')}` + }) + return parts.join(', ') || '未提供 Subject' +} + +function validateParsedKey(modulusBits: number, exponentHex: string): void { + if (modulusBits < MIN_RSA_MODULUS_BITS || modulusBits > MAX_RSA_MODULUS_BITS) { + throw new Error(`RSA 模数必须为 ${MIN_RSA_MODULUS_BITS} 至 ${MAX_RSA_MODULUS_BITS} 位`) + } + const exponent = BigInt(`0x${exponentHex}`) + if (exponent < 3n || exponent > 0xFFFF_FFFFn || exponent % 2n === 0n) { + throw new Error('RSA 公钥指数 E 必须是 3 至 0xFFFFFFFF 的奇数') + } +} + +async function digestBytes(data: Uint8Array, hash: SignatureHashAlgorithm): Promise { + const input = data.byteOffset === 0 && data.byteLength === data.buffer.byteLength ? data.buffer : data.slice().buffer + if (globalThis.crypto?.subtle) { + try { + const digest = await globalThis.crypto.subtle.digest(hash, input as ArrayBuffer) + return formatBytes(new Uint8Array(digest), { format: 'continuous-hex' }) + } catch { + // Plain HTTP LAN origins may expose crypto without an operational SubtleCrypto provider. + } + } + + const { default: forge } = await import('node-forge') + const digest = createForgeDigest(forge, hash) + digest.update(bytesToBinary(data)) + return digest.digest().toHex().toUpperCase() +} + +function createForgeDigest(forge: typeof import('node-forge'), hash: SignatureHashAlgorithm) { + if (hash === 'SHA-256') return forge.md.sha256.create() + if (hash === 'SHA-384') return forge.md.sha384.create() + return forge.md.sha512.create() +} + +async function buildParsedKey( + key: { n: { bitLength(): number, toString(radix?: number): string }, e: { toString(radix?: number): string } }, + source: RsaPublicKeySource, + certificate?: RsaCertificateInfo, +): Promise { + const { default: forge } = await import('node-forge') + const modulusHex = normalizeUnsignedHex(key.n.toString(16)) + const exponentHex = normalizeUnsignedHex(key.e.toString(16)) + const modulusBits = key.n.bitLength() + validateParsedKey(modulusBits, exponentHex) + if (Number.parseInt(modulusHex.at(-1) as string, 16) % 2 === 0) throw new Error('RSA 模数 N 必须是奇数') + const spkiDer = binaryToBytes(forge.asn1.toDer(forge.pki.publicKeyToAsn1(key as never)).getBytes()) + return { + source, + modulusHex, + exponentHex, + exponentDecimal: BigInt(`0x${exponentHex}`).toString(10), + modulusBits, + spkiDer, + fingerprintSha256: await digestBytes(spkiDer, 'SHA-256'), + certificate, + } +} + +export async function parseRsaPublicKeyPem(input: string): Promise { + const trimmed = input.trim() + if (!trimmed) throw new Error('请输入 RSA 公钥或 X.509 证书 PEM') + if (trimmed.length > MAX_RSA_PUBLIC_KEY_INPUT_CHARS) throw new Error('公钥或证书 PEM 不能超过 64 KiB') + + const blocks = [...trimmed.matchAll(/-----BEGIN ([A-Z0-9 ]+)-----[\s\S]*?-----END \1-----/g)] + if (blocks.length !== 1 || blocks[0][0].length !== trimmed.length) { + throw new Error('请输入且仅输入一个完整的 PEM 块') + } + + const label = blocks[0][1] + if (!['PUBLIC KEY', 'RSA PUBLIC KEY', 'CERTIFICATE'].includes(label)) { + throw new Error('仅支持 PUBLIC KEY、RSA PUBLIC KEY 或 CERTIFICATE PEM') + } + + try { + const { default: forge } = await import('node-forge') + if (label === 'CERTIFICATE') { + const certificate = forge.pki.certificateFromPem(trimmed, false, true) + const certificateKey = certificate.publicKey as unknown as { + n?: { bitLength(): number, toString(radix?: number): string } + e?: { toString(radix?: number): string } + } + if (!certificateKey.n || !certificateKey.e) throw new Error('证书公钥不是 RSA') + return buildParsedKey(certificateKey as Required, 'X.509 certificate', { + subject: formatCertificateSubject(certificate.subject.attributes), + serialNumber: certificate.serialNumber.toUpperCase(), + notBefore: certificate.validity.notBefore.toISOString(), + notAfter: certificate.validity.notAfter.toISOString(), + }) + } + const key = forge.pki.publicKeyFromPem(trimmed) + if (!key?.n || !key?.e) throw new Error('PEM 公钥不是 RSA') + return buildParsedKey(key, label === 'PUBLIC KEY' ? 'SPKI' : 'PKCS#1') + } catch (reason) { + if (reason instanceof Error && /RSA 模数|RSA 公钥指数|不是 RSA/.test(reason.message)) throw reason + throw new Error('RSA 公钥或证书 PEM 格式无效') + } +} + +export async function parseRsaPublicKeyParameters(modulusInput: string, exponentInput: string): Promise { + if (modulusInput.length > MAX_RSA_PARAMETER_INPUT_CHARS || exponentInput.length > MAX_RSA_PARAMETER_INPUT_CHARS) { + throw new Error('RSA N/E 参数输入过长') + } + const modulusBytes = parseHex(modulusInput) + const exponentBytes = parseUnsignedHexInteger(exponentInput, 'RSA 公钥指数 E') + if (modulusBytes.length === 0) throw new Error('请输入 RSA 模数 N') + if (exponentBytes.length === 0) throw new Error('请输入 RSA 公钥指数 E') + if (modulusBytes.length > MAX_RSA_MODULUS_BITS / 8 + 1) throw new Error(`RSA 模数不能超过 ${MAX_RSA_MODULUS_BITS} 位`) + if (exponentBytes.length > 4) throw new Error('RSA 公钥指数 E 不能超过 32 位') + + const { default: forge } = await import('node-forge') + const modulusHex = normalizeUnsignedHex(formatBytes(modulusBytes, { format: 'continuous-hex' })) + const exponentHex = normalizeUnsignedHex(formatBytes(exponentBytes, { format: 'continuous-hex' })) + const key = forge.pki.rsa.setPublicKey( + new forge.jsbn.BigInteger(modulusHex, 16), + new forge.jsbn.BigInteger(exponentHex, 16), + ) + return buildParsedKey(key, 'N/E parameters') +} + +export function decodeSignature(input: string, encoding: SignatureEncoding): Uint8Array { + const trimmed = input.trim() + if (!trimmed) throw new Error('请输入签名') + if (trimmed.length > MAX_SIGNATURE_INPUT_CHARS) throw new Error('签名输入过长') + let signature: Uint8Array + if (encoding === 'hex') { + signature = parseHex(trimmed) + } else if (encoding === 'base64') { + if (/[-_]/.test(trimmed)) throw new Error('Base64 签名不能包含 Base64 URL 字符') + signature = decodeBase64(trimmed) + } else { + if (/[+/]/.test(trimmed)) throw new Error('Base64 URL 签名不能包含标准 Base64 字符') + signature = decodeBase64(trimmed, true) + } + if (signature.length === 0) throw new Error('签名不能为空') + if (signature.length > MAX_RSA_MODULUS_BITS / 8) throw new Error('签名不能超过 1,024 字节') + return signature +} + +export function hashLength(hash: SignatureHashAlgorithm): number { + if (hash === 'SHA-256') return 32 + if (hash === 'SHA-384') return 48 + return 64 +} + +export function defaultPssSaltLength(modulusBits: number, hash: SignatureHashAlgorithm): number { + return Math.max(0, Math.min(hashLength(hash), maximumPssSaltLength(modulusBits, hash))) +} + +export function maximumPssSaltLength(modulusBits: number, hash: SignatureHashAlgorithm): number { + return Math.ceil((modulusBits - 1) / 8) - hashLength(hash) - 2 +} + +export function validateVerificationOptions(key: ParsedRsaPublicKey, options: RsaVerificationOptions): number | undefined { + if (!['pkcs1-v1_5', 'pss'].includes(options.scheme)) throw new Error('RSA 签名算法无效') + if (!['SHA-256', 'SHA-384', 'SHA-512'].includes(options.hash)) throw new Error('Hash 算法无效') + if (options.scheme !== 'pss') return undefined + const saltLength = options.saltLength + const maximum = maximumPssSaltLength(key.modulusBits, options.hash) + if (!Number.isSafeInteger(saltLength) || saltLength === undefined || saltLength < 0 || saltLength > maximum) { + throw new Error(`RSA-PSS Salt Length 必须是 0 至 ${maximum} 字节的整数`) + } + return saltLength +} + +async function verifyWithForge( + key: ParsedRsaPublicKey, + data: Uint8Array, + signature: Uint8Array, + options: RsaVerificationOptions, + saltLength: number | undefined, +): Promise { + const { default: forge } = await import('node-forge') + const publicKey = forge.pki.rsa.setPublicKey( + new forge.jsbn.BigInteger(key.modulusHex, 16), + new forge.jsbn.BigInteger(key.exponentHex, 16), + ) + const digest = createForgeDigest(forge, options.hash) + digest.update(bytesToBinary(data)) + try { + if (options.scheme === 'pkcs1-v1_5') { + return publicKey.verify(digest.digest().getBytes(), bytesToBinary(signature), 'RSASSA-PKCS1-V1_5') + } + const pss = forge.pss.create({ + md: createForgeDigest(forge, options.hash), + mgf: forge.mgf.mgf1.create(createForgeDigest(forge, options.hash)), + saltLength, + }) + return publicKey.verify(digest.digest().getBytes(), bytesToBinary(signature), pss) + } catch { + return false + } +} + +export async function verifyRsaSignature( + key: ParsedRsaPublicKey, + data: Uint8Array, + signature: Uint8Array, + options: RsaVerificationOptions, +): Promise { + if (data.length > 64 * 1024 * 1024) throw new Error('待验证数据不能超过 64 MiB') + const expectedSignatureBytes = Math.ceil(key.modulusBits / 8) + if (signature.length !== expectedSignatureBytes) { + throw new Error(`签名长度必须为 ${expectedSignatureBytes} 字节,当前为 ${signature.length} 字节`) + } + const saltLength = validateVerificationOptions(key, options) + const dataHash = await digestBytes(data, options.hash) + + if (globalThis.crypto?.subtle) { + try { + const algorithmName = options.scheme === 'pss' ? 'RSA-PSS' : 'RSASSA-PKCS1-v1_5' + const cryptoKey = await globalThis.crypto.subtle.importKey( + 'spki', + key.spkiDer.slice().buffer, + { name: algorithmName, hash: options.hash }, + false, + ['verify'], + ) + const algorithm = options.scheme === 'pss' ? { name: algorithmName, saltLength: saltLength as number } : algorithmName + const valid = await globalThis.crypto.subtle.verify(algorithm, cryptoKey, signature, data) + return { valid, provider: 'Web Crypto', dataHash, dataBytes: data.length, signatureBytes: signature.length } + } catch { + // Fall back only when the native provider cannot perform the requested operation. + } + } + + const valid = await verifyWithForge(key, data, signature, options, saltLength) + return { valid, provider: 'node-forge', dataHash, dataBytes: data.length, signatureBytes: signature.length } +} diff --git a/src/lib/signatureVerificationFile.ts b/src/lib/signatureVerificationFile.ts new file mode 100644 index 0000000..3c53104 --- /dev/null +++ b/src/lib/signatureVerificationFile.ts @@ -0,0 +1,46 @@ +import { MAX_FIRMWARE_FILE_SIZE } from '@/lib/firmware' +import { + buildFirmwareHashInput, + type FirmwareHashByteOrder, +} from '@/lib/firmwareHash' +import { concatenateBlocks, parseIntelHex } from '@/lib/intelHex' + +export type SignatureFileMode = 'raw-file' | 'intel-hex' | 'intel-hex-block-header' + +export interface ParsedSignatureFile { + data: Uint8Array + dataBytes: number + effectiveDataBytes: number + headerBytes: number + blockCount?: number + warnings: string[] +} + +export async function parseSignatureFile( + file: File, + mode: SignatureFileMode, + byteOrder: FirmwareHashByteOrder = 'big-endian', +): Promise { + if (file.size > MAX_FIRMWARE_FILE_SIZE) throw new Error('文件不能超过 64 MiB') + if (mode === 'raw-file') { + const data = new Uint8Array(await file.arrayBuffer()) + return { data, dataBytes: data.length, effectiveDataBytes: data.length, headerBytes: 0, warnings: [] } + } + if (mode !== 'intel-hex' && mode !== 'intel-hex-block-header') throw new Error('文件数据模式无效') + const parsed = parseIntelHex(await file.text()) + const headerBytes = mode === 'intel-hex-block-header' ? parsed.blocks.length * 8 : 0 + if (parsed.totalBytes + headerBytes > MAX_FIRMWARE_FILE_SIZE) { + throw new Error('加入 Block 校验头后的待验签数据不能超过 64 MiB') + } + const data = mode === 'intel-hex-block-header' + ? buildFirmwareHashInput(parsed.blocks, 'block-header', byteOrder) + : concatenateBlocks(parsed.blocks) + return { + data, + dataBytes: data.length, + effectiveDataBytes: parsed.totalBytes, + headerBytes, + blockCount: parsed.blocks.length, + warnings: parsed.warnings, + } +} diff --git a/src/lib/signatureVerificationWorker.ts b/src/lib/signatureVerificationWorker.ts new file mode 100644 index 0000000..2fd5955 --- /dev/null +++ b/src/lib/signatureVerificationWorker.ts @@ -0,0 +1,50 @@ +import type { + ParsedRsaPublicKey, + RsaSignatureScheme, + RsaVerificationResult, + SignatureHashAlgorithm, +} from '@/lib/signatureVerification' +import type { SignatureFileMode } from '@/lib/signatureVerificationFile' +import type { FirmwareHashByteOrder } from '@/lib/firmwareHash' + +export interface SignatureVerificationWorkerParseRequest { + type: 'parse' + id: number + file: File + mode: SignatureFileMode + byteOrder: FirmwareHashByteOrder +} + +export interface SignatureVerificationWorkerCalculateRequest { + type: 'calculate' + id: number + key: ParsedRsaPublicKey + signature: Uint8Array + scheme: RsaSignatureScheme + hash: SignatureHashAlgorithm + saltLength?: number +} + +export type SignatureVerificationWorkerRequest = + | SignatureVerificationWorkerParseRequest + | SignatureVerificationWorkerCalculateRequest + +export type SignatureVerificationWorkerResponse = { + type: 'parsed' + id: number + mode: SignatureFileMode + byteOrder: FirmwareHashByteOrder + dataBytes: number + effectiveDataBytes: number + headerBytes: number + blockCount?: number + warnings: string[] +} | ({ + type: 'calculated' + id: number +} & RsaVerificationResult) | { + type: 'error' + id: number + operation: SignatureVerificationWorkerRequest['type'] + error: string +} diff --git a/src/styles.css b/src/styles.css index 1c8a471..580bdec 100644 --- a/src/styles.css +++ b/src/styles.css @@ -151,6 +151,8 @@ button[aria-disabled='true'], input[aria-disabled='true'], select[aria-disabled= .editor-header strong { color: var(--text); font-weight: 600; } .editor-meta { margin-left: auto; color: var(--faint); } .editor-panel textarea { width: 100%; min-height: 184px; padding: 13px; display: block; resize: vertical; color: var(--text); background: var(--surface); border: 0; outline: 0; font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; font-size: 12px; line-height: 1.6; } +.editor-panel:has(textarea:disabled) { opacity: .72; } +.editor-panel textarea:disabled { cursor: not-allowed; } .editor-panel textarea::placeholder, .key-input::placeholder { color: var(--faint); } .syntax-viewer { min-width: 0; overflow: hidden; background: var(--surface); border: 1px solid var(--border-strong); border-radius: 7px; } .syntax-viewer pre { min-height: 290px; max-height: 520px; margin: 0; padding: 13px; overflow: auto; color: var(--text); background: var(--surface); font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; font-size: 12px; line-height: 1.6; white-space: pre; tab-size: 2; } @@ -212,11 +214,18 @@ button[aria-disabled='true'], input[aria-disabled='true'], select[aria-disabled= .size-option input { margin: 0; accent-color: var(--accent); } .file-meta { margin-top: 12px; color: var(--muted); font-size: 11px; } .compact-upload { min-height: 138px; margin-bottom: 16px; } -.firmware-summary { min-height: 58px; margin-bottom: 16px; padding: 10px 12px; display: flex; align-items: center; gap: 8px; background: var(--panel); border: 1px solid var(--border); border-radius: 7px; } -.firmware-summary > div { min-width: 0; margin-right: auto; } -.firmware-summary strong, .firmware-summary span { display: block; overflow-wrap: anywhere; } -.firmware-summary strong { font-size: 12px; font-weight: 600; } -.firmware-summary span { margin-top: 3px; color: var(--muted); font-size: 11px; } +.file-summary { min-height: 58px; margin-bottom: 16px; padding: 10px 12px; display: flex; align-items: center; gap: 8px; background: var(--panel); border: 1px solid var(--border); border-radius: 7px; } +.file-summary > div { min-width: 0; margin-right: auto; } +.file-summary strong, .file-summary span { display: block; overflow-wrap: anywhere; } +.file-summary strong { font-size: 12px; font-weight: 600; } +.file-summary span { margin-top: 3px; color: var(--muted); font-size: 11px; } +.file-drop-summary { position: relative; overflow: hidden; transition: border-color .15s ease, background-color .15s ease, box-shadow .15s ease; } +.file-drop-summary.is-dragging { background: var(--accent-soft); border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 12%, transparent); } +.file-drop-feedback { position: absolute; inset: 0; z-index: 1; display: flex; align-items: center; justify-content: center; gap: 8px; color: var(--accent); background: color-mix(in srgb, var(--surface) 88%, transparent); pointer-events: none; } +.file-drop-feedback strong { font-size: 12px; } +.file-drop-summary.is-replacing .file-drop-feedback { color: var(--muted); } +.file-drop-spinner { animation: file-drop-spin .8s linear infinite; } +@keyframes file-drop-spin { to { transform: rotate(360deg); } } .crc-config { display: grid; grid-template-columns: minmax(190px, 1.35fr) minmax(96px, .65fr) repeat(3, minmax(145px, 1fr)); gap: 10px; align-items: end; } .parameter-field { min-width: 0; display: grid; gap: 6px; color: var(--muted); font-size: 11px; font-weight: 600; } .parameter-field .select-input, .parameter-field .text-input { width: 100%; font-size: 12px; font-weight: 400; } @@ -261,6 +270,37 @@ button[aria-disabled='true'], input[aria-disabled='true'], select[aria-disabled= .rsa-parameter-format { margin: 0 0 10px; } .rsa-parameter-grid { display: grid; gap: 14px; } .rsa-copy-row { min-height: 30px; margin-top: 4px; justify-content: flex-end; } +.signature-section { min-width: 0; padding: 16px 0; border-top: 1px solid var(--border); } +.signature-section:first-of-type { padding-top: 0; border-top: 0; } +.signature-section-heading { min-height: 28px; margin-bottom: 11px; display: flex; align-items: baseline; gap: 10px; } +.signature-section-heading strong { font-size: 13px; font-weight: 600; } +.signature-section-heading span { color: var(--faint); font-size: 11px; overflow-wrap: anywhere; } +.signature-config-grid { display: grid; grid-template-columns: minmax(220px, auto) minmax(150px, 190px) minmax(170px, 210px); gap: 12px; align-items: start; } +.signature-config-grid .parameter-field small { color: var(--faint); font-size: 10px; font-weight: 400; } +.signature-toolbar { margin-bottom: 10px; display: flex; align-items: center; gap: 10px; } +.signature-key-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(180px, .36fr); gap: 14px; align-items: start; } +.signature-key-summary { margin-top: 10px; padding: 11px 12px; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px 18px; background: var(--panel); border: 1px solid var(--border); border-radius: 7px; } +.signature-key-summary > div { min-width: 0; } +.signature-key-summary span, .signature-key-summary strong { display: block; } +.signature-key-summary span { margin-bottom: 3px; color: var(--faint); font-size: 10px; } +.signature-key-summary strong { font-size: 11px; overflow-wrap: anywhere; } +.signature-fingerprint, .signature-certificate-info { grid-column: 1 / -1; } +.signature-key-summary code { display: block; color: var(--text); font-family: "SFMono-Regular", Consolas, monospace; font-size: 11px; overflow-wrap: anywhere; } +.signature-certificate-info small { display: block; margin-top: 4px; color: var(--faint); font-size: 10px; overflow-wrap: anywhere; } +.signature-actions { margin-top: 4px; } +.signature-result { margin-top: 16px; padding: 13px; border: 1px solid; border-radius: 7px; } +.signature-result.is-valid { color: var(--success); background: var(--success-soft); border-color: color-mix(in srgb, var(--success) 30%, var(--border)); } +.signature-result.is-invalid { color: var(--danger); background: var(--danger-soft); border-color: color-mix(in srgb, var(--danger) 30%, var(--border)); } +.signature-result-title { display: flex; align-items: center; gap: 10px; } +.signature-result-title strong, .signature-result-title span { display: block; } +.signature-result-title strong { color: currentColor; font-size: 14px; } +.signature-result-title span { margin-top: 2px; color: var(--muted); font-size: 11px; } +.signature-result dl { margin: 12px 0 0; padding-top: 10px; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px 16px; border-top: 1px solid color-mix(in srgb, currentColor 20%, transparent); } +.signature-result dl > div { min-width: 0; } +.signature-result dt { color: var(--faint); font-size: 10px; } +.signature-result dd { margin: 3px 0 0; color: var(--text); font-size: 11px; font-weight: 600; overflow-wrap: anywhere; } +.signature-result-fingerprint, .signature-result-hash { grid-column: 1 / -1; } +.signature-result code { font-family: "SFMono-Regular", Consolas, monospace; font-weight: 400; overflow-wrap: anywhere; } .random-controls { margin-bottom: 14px; display: grid; grid-template-columns: minmax(120px, 180px) max-content auto; gap: 12px; align-items: end; } .random-check { min-height: 34px; white-space: nowrap; } @@ -289,6 +329,7 @@ button[aria-disabled='true'], input[aria-disabled='true'], select[aria-disabled= @media (max-width: 1040px) { .crc-config { grid-template-columns: repeat(2, minmax(0, 1fr)); } .crc-config .preset-field, .crc-config .crc-reflection-options { grid-column: 1 / -1; } + .signature-config-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } } @media (max-width: 800px) { @@ -306,6 +347,7 @@ button[aria-disabled='true'], input[aria-disabled='true'], select[aria-disabled= .preset-field { grid-column: 1 / -1; } .random-controls { grid-template-columns: repeat(2, minmax(0, 1fr)); } .random-format-field { grid-column: 1 / -1; } + .signature-key-grid { grid-template-columns: 1fr; } } @media (max-width: 520px) { @@ -328,8 +370,9 @@ button[aria-disabled='true'], input[aria-disabled='true'], select[aria-disabled= .setting-tool-row { padding: 9px; align-items: flex-start; flex-wrap: wrap; } .setting-tool-row .select-input { width: calc(100% - 76px); margin-left: 38px; order: 5; } .setting-tool-row .row-actions { margin-left: auto; } - .firmware-summary { align-items: flex-start; flex-wrap: wrap; } - .firmware-summary > div { width: 100%; } + .file-summary { align-items: flex-start; flex-wrap: wrap; } + .file-summary > div { width: 100%; } + .file-drop-feedback { align-items: center; } .crc-total { align-items: flex-start; flex-direction: column; } .crc-total strong { margin-left: 0; font-size: 18px; overflow-wrap: anywhere; } .hash-config { display: grid; grid-template-columns: 1fr; align-items: stretch; } @@ -338,6 +381,10 @@ button[aria-disabled='true'], input[aria-disabled='true'], select[aria-disabled= .hash-summary-mode { margin-left: 0; } .hash-block-result > header { align-items: flex-start; flex-wrap: wrap; padding: 10px 0; } .hash-block-result > header small { width: 100%; margin-left: 0; } + .signature-section-heading { align-items: flex-start; flex-direction: column; gap: 3px; } + .signature-config-grid { grid-template-columns: 1fr; } + .signature-key-summary, .signature-result dl { grid-template-columns: 1fr; } + .signature-fingerprint, .signature-certificate-info, .signature-result-fingerprint, .signature-result-hash { grid-column: auto; } } @media (prefers-reduced-motion: reduce) { diff --git a/src/tools/CrcTool.vue b/src/tools/CrcTool.vue index 4563170..ad4d353 100644 --- a/src/tools/CrcTool.vue +++ b/src/tools/CrcTool.vue @@ -1,10 +1,12 @@