Compare commits
5
Commits
aca004c513
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1c20a4924e | ||
|
|
1aea179f19 | ||
|
|
8bc6032f38 | ||
|
|
0ae6db36cb | ||
|
|
32b7cc827c |
@@ -15,8 +15,10 @@ This file is mandatory for AI and coding agents working in this repository. Read
|
||||
- Web Tools is an anonymous-access, self-hosted Vue 3 + TypeScript browser-local tools application with a zero-dependency Node.js static/configuration server.
|
||||
- The default production port is `9000`. Port `8081` is only the local Vite development proxy target.
|
||||
- The application is anonymous. Do not add login, accounts, favorites, sponsorship, analytics, telemetry, advertising, CDN assets, or a database.
|
||||
- A deployment may place Cloudflare in front of the origin. The server's per-response CSP nonce exists only to support Cloudflare JavaScript Detections without `unsafe-inline`; it is not permission to add application analytics, telemetry, or remote tool processing.
|
||||
- Tool inputs, keys, files, images, and results must remain inside the current browser tab. Do not send them to the Node server, a third-party API, or any remote service.
|
||||
- `/setting` is a fixed, unlisted route for navigation management. It has no authentication and is not a security boundary. Anyone who can access the application can open it and change the globally shared navigation order.
|
||||
- `/tools/setting` is the fixed, unlisted route for navigation management. It has no authentication and is not a security boundary. Anyone who can access the application can open it and change the globally shared navigation order.
|
||||
- The first-run default category order is `encoding`, `checksum`, `crypto`, `formatting`, `image`; an existing saved navigation file keeps its custom order and is never reset by a release.
|
||||
- The server persists only navigation configuration. Preserve `data/navigation.json` and the configured external data directory; writes must remain temporary-file plus atomic-replace operations.
|
||||
- Multiple users and tabs may use every tool concurrently. Their calculation state must remain independent; only navigation order is globally shared.
|
||||
|
||||
@@ -28,20 +30,24 @@ 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.
|
||||
|
||||
## Stable tool behavior
|
||||
|
||||
- ASCII/Hex uses UTF-8. Hex input accepts continuous text or chunks separated by whitespace, English commas, or Chinese commas, with an optional `0x` prefix on each chunk. Invalid UTF-8 must fail rather than silently replace bytes.
|
||||
- UTF-8 / Hex has the permanent tool ID `utf8-hex` and route `/tools/utf8-hex`. It uses strict UTF-8 conversion. Hex input accepts continuous text or chunks separated by whitespace, English commas, or Chinese commas, with an optional `0x` prefix on each chunk. Invalid UTF-8 must fail rather than silently replace bytes.
|
||||
- Base64 supports standard and URL-safe input. Decoded output supports strict UTF-8, continuous Hex, and `0xNN, ...` byte arrays. Reject invalid characters, padding placement, lengths, and non-canonical trailing bits.
|
||||
- AES-128-CMAC follows RFC 4493. The key is exactly 16 bytes of Hex and accepts continuous input or whitespace/comma/Chinese-comma-separated chunks with optional `0x` prefixes. Hex messages use the same parser.
|
||||
- CRC supports BIN and multi-block Intel HEX, CRC-8/16/32/64 presets, and custom `Width`, `Poly`, `Init`, `XorOut`, `RefIn`, and `RefOut`. Combined data is ordered by address without filling gaps, and per-block results remain available.
|
||||
- AES-CMAC accepts exactly 16, 24, or 32 key bytes for AES-128, AES-192, or AES-256, defaulting to 16 bytes. Never pad or truncate the key. Key and Hex-message input accepts continuous Hex or whitespace/comma/Chinese-comma-separated chunks with optional `0x` prefixes. The CMAC tag remains exactly 16 bytes for every key size. Preserve RFC 4493 AES-128 and NIST SP 800-38B AES-192/256 vectors.
|
||||
- CRC supports inline Hex bytes, BIN, and multi-block Intel HEX, with 16 reviewed CRC-8/16/32/64 presets and custom `Width`, `Poly`, `Init`, `XorOut`, `RefIn`, and `RefOut`. Keep canonical preset names and visible aliases synchronized with `src/lib/crc.ts`: CRC-8/SMBUS, MAXIM-DOW, SAE-J1850; CRC-16/ARC, DNP, IBM-3740, IBM-SDLC, KERMIT, MODBUS, USB, XMODEM; CRC-32/ISO-HDLC, ISCSI, MPEG-2; CRC-64/ECMA-182, XZ. Show the ambiguous `CCITT` alias only alongside KERMIT and `CCITT-FALSE` only alongside IBM-3740; never collapse them into one ambiguous preset. Inline Hex uses the shared strict byte parser: accept continuous Hex or chunks separated by whitespace, English commas, or Chinese commas, with an optional `0x` prefix on each chunk; reject empty input. Limit it to 1 MiB after parsing and 6 MiB of source characters, and parse/calculate it in a dedicated Worker. The Chinese UI labels `RefIn`/`RefOut` as input/output bit reversal; these flags do not reverse byte order or the complete input. Combined file 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; the node-forge fallback must be seeded from `crypto.getRandomValues`. Export public SPKI PEM, private PKCS#8 PEM, and separately copyable N/E continuous Hex or byte arrays.
|
||||
- Secure random output comes only from `crypto.getRandomValues`, is limited to 1-65,536 bytes, and supports continuous Hex, spaced Hex, and byte arrays. Changing display format must not regenerate bytes.
|
||||
- JSON/XML formatting, minification, validation, and syntax highlighting stay local. When highlighting limits are exceeded, render the complete result as plain text rather than truncating it.
|
||||
- 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.
|
||||
|
||||
## Intel HEX and asynchronous safety
|
||||
@@ -49,8 +55,11 @@ This file is mandatory for AI and coding agents working in this repository. Read
|
||||
- Treat every address, length, end-address, offset, and allocation calculation as checked uint32 arithmetic. JavaScript bitwise operators are signed 32-bit and must not be used as unchecked address arithmetic.
|
||||
- Validate record syntax, byte counts, checksums, control-record lengths/addresses, EOF placement, extended addresses, and the `0xFFFFFFFF` boundary.
|
||||
- Sort records by absolute address, merge contiguous records, deduplicate identical overlaps, and reject conflicting overlaps. Enforce record, block, and effective-byte limits before allocating combined buffers.
|
||||
- The default Intel HEX effective-data allocation limit is 64 MiB (`DEFAULT_MAX_INTEL_HEX_BYTES`); keep it aligned with `MAX_FIRMWARE_FILE_SIZE` and the limits table in `README.zh-CN.md`.
|
||||
- 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.
|
||||
|
||||
@@ -61,15 +70,16 @@ This file is mandatory for AI and coding agents working in this repository. Read
|
||||
- Keep desktop controls compact. Use stable grid/flex constraints and allow controls to become full-width where appropriate on narrow screens.
|
||||
- Prevent horizontal page overflow, clipped controls, nested scrolling accidents, text overlap, layout shifts, and unbounded result columns. Long Hex, hashes, filenames, and table cells require explicit wrapping or contained scrolling.
|
||||
- Version information remains at the bottom of the sidebar and is read from `package.json`. Every page footer displays exactly `Copyright © mid All Rights Reserved.`
|
||||
- Do not add a visible link to `/setting`, explanatory marketing copy, login UI, favorites, or sponsorship UI.
|
||||
- Do not add a visible link to `/tools/setting`, explanatory marketing copy, login UI, favorites, or sponsorship UI.
|
||||
- Every native `input`, `select`, and `textarea` must have a unique `id` and stable `name`. `TextEditor` requires an explicit `id` and uses it as the default `name`; preserve the existing implicit or explicit label association.
|
||||
- Preserve the application icon contract under `public/`: `favicon.svg`, a 16/32/48-frame `favicon.ico`, `apple-touch-icon.png` at 180x180, `icon-192.png`, `icon-512.png`, `icon-maskable-512.png`, and `site.webmanifest`. Keep the icon visually aligned with the sidebar brand and keep the maskable artwork inside its safe zone.
|
||||
- The manifest is metadata/install-ready only. Do not add a Service Worker, offline cache, install prompt, splash-screen bundle, Safari mask icon, legacy `browserconfig.xml`, redundant Apple sizes, or separate 16/32 PNG favicons unless requirements explicitly change.
|
||||
- Preserve the share metadata contract: the single user-facing description is exactly `一个匿名访问、自托管的浏览器本地工具箱`, represented by the standard HTML description and the identical `og:description` compatibility field. Do not add a custom share image, other Open Graph or platform-specific metadata, platform crawlers, route-specific previews, tool lists, or user data. Sharing clients may fall back to the document title and existing favicon assets, but their final preview and cache behavior remain platform-controlled.
|
||||
- For UI work, verify a wide desktop, an intermediate width around 801-1040 px, and mobile portrait around 390 x 844 in a real browser. Check direct routes, refresh, navigation, search, `/setting`, both themes, console errors, and network requests.
|
||||
- For UI work, verify a wide desktop, an intermediate width around 801-1040 px, and mobile portrait around 390 x 844 in a real browser. Check direct routes, refresh, navigation, search, `/tools/setting`, both themes, console errors, and network requests.
|
||||
|
||||
## Adding a tool
|
||||
|
||||
1. Choose a permanent, unique kebab-case ID. Published IDs must not change because routes and saved navigation depend on them.
|
||||
1. Choose a permanent, unique kebab-case ID. IDs must remain stable after publication because routes and saved navigation depend on them. The recorded pre-0.2.0 correction from `ascii-hex` to `utf8-hex` is the sole existing ID migration. `setting` is reserved by `/tools/setting` and cannot be used as a tool ID.
|
||||
2. Add pure logic and normal, invalid, boundary, resource-limit, and known-vector tests.
|
||||
3. Add one Vue component under `src/tools/` using shared components and responsive styles.
|
||||
4. Add matching metadata to `shared/tool-catalog.json` and its default position to `shared/default-navigation.json`. Only change navigation `version: 1` for a schema migration, not for a new tool.
|
||||
@@ -77,6 +87,7 @@ This file is mandatory for AI and coding agents working in this repository. Read
|
||||
6. Verify that an old saved `navigation.json` retains its order and receives the missing tool after the Node process restarts and navigation is next loaded.
|
||||
|
||||
IDs must remain synchronized across the catalog, default navigation, icon map, and component registry. Existing catalog tests enforce this contract.
|
||||
The `ascii-hex` ID is a legacy input accepted only while normalizing existing `navigation.json` files; it migrates in place to `utf8-hex`. Do not restore it as a catalog ID, component key, form-field prefix, or route.
|
||||
|
||||
## Deployment and release
|
||||
|
||||
@@ -85,6 +96,9 @@ IDs must remain synchronized across the catalog, default navigation, icon map, a
|
||||
- The Windows update flow requires a clean `main` checkout and uses `git pull --ff-only`. Preserve external `C:\ProgramData\WebTools\data` state.
|
||||
- Running `web-tools.ps1` without an action is an interactive user entry point and must keep the start/update/update-without-start/exit menu. Scheduled tasks and automation must pass an explicit `start` or `update` action so they never wait for input.
|
||||
- New catalog data is loaded by the Node process at startup. Rebuilding `dist` alone is insufficient after catalog changes; restart the server and refresh already-open browser pages.
|
||||
- Preserve the per-response 128-bit CSP nonce generated with `node:crypto`. Cloudflare parses the nonce from the response header for JavaScript Detections. Never replace it with a fixed nonce or `unsafe-inline`; keep HTML uncached/no-cache so a document and its nonce cannot become mismatched.
|
||||
- Keep one-year immutable caching limited to fingerprinted files under `dist/assets/`. Stable filenames such as HTML, favicon assets, and `site.webmanifest` must use `no-cache` so a deployment can replace them safely.
|
||||
- Browser navigation PUT requests carrying Fetch Metadata must be same-origin. Requests without `Sec-Fetch-Site` remain supported for non-browser administration, so document this as defense in depth and never as authentication.
|
||||
- If a single EXE is later authorized, use Node.js SEA with embedded read-only web/catalog assets and an external writable data directory. Add per-architecture builds, asset-read abstraction, Windows smoke tests, replacement/update instructions, and optional Authenticode signing. Derive EXE version metadata from `package.json`; do not create another manually maintained version source. Do not commit generated executables.
|
||||
|
||||
For a release, synchronize these five tracked files:
|
||||
@@ -95,7 +109,7 @@ For a release, synchronize these five tracked files:
|
||||
4. `README.zh-CN.md`
|
||||
5. `CHANGELOG.md`
|
||||
|
||||
Use `npm version patch --no-git-tag-version` or the appropriate minor/major form so the project version in `package.json` and the two project metadata entries in `package-lock.json` remain synchronized. Other lockfile `version` fields belong to dependencies. `README.md` intentionally does not hardcode the current version.
|
||||
Use an npm/SemVer-compatible calendar version in `YYYY.M.D` form, without leading zeroes in the numeric fields; for example, `2026.8.22`. Allow at most one final release per calendar day and use `YYYY.M.D-rc.N` only for prerelease validation. Run `npm version YYYY.M.D --no-git-tag-version` so the project version in `package.json` and the two project metadata entries in `package-lock.json` remain synchronized. Other lockfile `version` fields belong to dependencies. `README.md` intentionally does not hardcode the current version.
|
||||
|
||||
Rebuild `dist`, the requested delivery ZIP, optional Docker image tar, and SHA-256 checksums. Generated assets are release outputs and remain ignored by Git. Never include `node_modules`, `.env`, runtime data, credentials, or local logs.
|
||||
|
||||
|
||||
@@ -2,6 +2,99 @@
|
||||
|
||||
本文档记录面向使用者和维护者的重要变更。版本号以 `package.json` 为准。
|
||||
|
||||
## 未发布
|
||||
|
||||
## 2026.8.22 - 2026-08-22
|
||||
|
||||
### Added
|
||||
|
||||
- CRC 工具增加“Hex 输入 / 文件”模式切换;内联 Hex 支持连续字节、空白、中英文逗号和逐段可选 `0x` 前缀,并在独立 Web Worker 中完成受限解析与计算。
|
||||
- CRC 标准预设由 6 种扩充为 16 种,增加 MAXIM-DOW、SAE-J1850、DNP、IBM-SDLC、KERMIT、USB、ISCSI、MPEG-2、ECMA-182 和 XZ,并补齐可直接选择的 CRC-64 预设。
|
||||
|
||||
### Changed
|
||||
|
||||
- CRC 预设按位宽分组,并采用规范名称及常用别名;原 CCITT-FALSE 显示为 IBM-3740(CCITT-FALSE),原 CRC-8 显示为 SMBUS(CRC-8),同时明确区分 KERMIT(CCITT)。
|
||||
|
||||
### Fixed
|
||||
|
||||
- 共享 Hex 字节解析器现在拒绝连续中英文逗号形成的空片段,避免输入格式错误被静默忽略。
|
||||
|
||||
### Security
|
||||
|
||||
- 将开发工具链中的传递依赖 `nanoid` 更新到 `3.3.18`,消除自定义生成器在长度为零时可能无限循环的已知漏洞。
|
||||
|
||||
## 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
|
||||
|
||||
- Base64 解码现在拒绝 `TQ=`、`TWE==` 等长度不完整的显式填充,同时继续接受规范的无填充 Base64 URL 输入。
|
||||
- XML 校验改为识别浏览器专用解析错误命名空间,合法的 `<parsererror>` 用户元素不再被误判为解析失败。
|
||||
- favicon、Web App Manifest 和其他稳定文件名资源不再使用一年不可变缓存;只有带构建指纹的 `dist/assets/` 文件使用长期 `immutable` 缓存。
|
||||
|
||||
### Security
|
||||
|
||||
- 带 Fetch Metadata 的导航配置写入现在只接受 `Sec-Fetch-Site: same-origin`,同时拦截 `same-site` 与 `cross-site` 浏览器跨源请求。无该请求头的非浏览器客户端仍受支持,因此隐藏设置路径仍不构成身份认证。
|
||||
|
||||
### Verification
|
||||
|
||||
- 120 条 Vitest 测试通过,新增 Base64 显式填充、XML `parsererror` 合法元素、静态资源缓存分类和 same-site 跨源写入回归覆盖。
|
||||
- Vue/TypeScript 类型检查、Vite 生产构建、Git 差异规范检查和 Windows PowerShell 启动脚本语法检查通过。
|
||||
- 11 个页面在 1440×900、900×900 和 390×844 三种视口完成真实浏览器回归;深浅主题无横向溢出或控制台警告/错误。
|
||||
|
||||
## 0.2.0 - 2026-08-09
|
||||
|
||||
### Added
|
||||
|
||||
- AES-CMAC 增加 AES-192 和 AES-256 密钥长度,保留 AES-128 默认值,并增加 NIST SP 800-38B 已知向量。
|
||||
- 服务端为每个响应生成独立的 128 位 CSP nonce,使 Cloudflare JavaScript Detections 无需 `'unsafe-inline'` 便可与严格 CSP 兼容。
|
||||
|
||||
### Changed
|
||||
|
||||
- 安全随机数默认长度从 8 字节调整为 16 字节,并集中管理默认值和资源上下限。
|
||||
- CRC 自定义参数和结果表头改为中文在前、英文在后的双语标签;`RefIn`/`RefOut` 明确为输入/输出位反转,宽屏参数区不再压缩长标签换行。
|
||||
- CRC 与固件 Hash 的文件上传框统一悬浮、拖拽及键盘聚焦状态,并增加与后续控件的固定间距。
|
||||
- RSA 仅在 Web Crypto 不可用或失败时动态加载 `node-forge` 回退,降低现代 HTTPS 浏览器首次打开 RSA 工具时的下载量。
|
||||
- UTF-8 / Hex 的工具 ID、组件、字段前缀和固定路径统一为 `utf8-hex` 与 `/tools/utf8-hex`;旧导航配置中的 `ascii-hex` 会原位迁移,保留分类和排序。
|
||||
- 侧边栏移除无功能编号,品牌副标题改为“本地工具箱”,共享控件统一键盘焦点环和禁用态。
|
||||
- 工具管理路径调整为 `/tools/setting`;所有表单字段补充唯一 `id` 和稳定 `name`。
|
||||
|
||||
### Security
|
||||
|
||||
- 随机数增加最小/最大长度、安全随机源缺失及随机源异常的失败关闭测试,不使用普通伪随机回退。
|
||||
- CSP 仍禁止任意内联脚本和第三方脚本;Cloudflare Web Analytics 保持为部署层可选功能,不加入应用代码。
|
||||
- RSA 密钥生成增加异步请求版本保护,组件卸载或状态失效后,旧结果不会覆盖当前页面状态。
|
||||
|
||||
### Verification
|
||||
|
||||
- 116 条 Vitest 测试通过,覆盖 AES-128/192/256 标准向量、安全随机源失败关闭、RSA 动态回退、CSP nonce 强度及唯一性、固定工具路径、导航 ID 迁移、默认分类顺序、Intel HEX 有效数据分配上限、并发导航写入、CRC 参数防御和表单字段标识约束。
|
||||
- Vue/TypeScript 类型检查、Vite 生产构建和 Windows PowerShell 启动脚本语法检查通过。
|
||||
- 11 个页面在 1440×900、900×900 和 390×844 三种视口完成真实浏览器检查;深浅主题无横向溢出或控制台警告/错误。
|
||||
|
||||
## 0.1.0 - 2026-08-09
|
||||
|
||||
### Added
|
||||
|
||||
@@ -8,13 +8,14 @@ The authoritative application version is the `version` field in `package.json`.
|
||||
|
||||
## Included tools
|
||||
|
||||
- ASCII/UTF-8 and Hex conversion
|
||||
- UTF-8 and Hex conversion at the canonical route `/tools/utf8-hex`
|
||||
- Base64 and Base64 URL encoding/decoding, with UTF-8, continuous Hex, and source-code byte-array output
|
||||
- AES-128-CMAC (RFC 4493)
|
||||
- CRC-8/16/32/64 presets and custom parameters for BIN and multi-block Intel HEX files
|
||||
- AES-CMAC with selectable AES-128, AES-192, and AES-256 keys
|
||||
- 16 reviewed CRC-8/16/32/64 presets and custom parameters for inline Hex bytes, 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
|
||||
- Cryptographically secure random bytes in continuous Hex, spaced Hex, or byte-array form
|
||||
- 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
|
||||
- PNG to multi-size ICO conversion
|
||||
@@ -22,13 +23,25 @@ The authoritative application version is the `version` field in `package.json`.
|
||||
## Security and state model
|
||||
|
||||
- Tool data is processed locally in browser memory and is not sent to the server or a third party.
|
||||
- There is no login, account, favorites, sponsorship, telemetry, CDN, or database.
|
||||
- `/setting` is a fixed, deliberately unlisted management route. It is not authentication: anyone who can reach the application can open that URL and change the globally shared navigation order.
|
||||
- There is no login, account, favorites, sponsorship, telemetry, application-loaded CDN asset, or database.
|
||||
- The Node server emits a fresh 128-bit CSP nonce on every response. Cloudflare can attach it to JavaScript Detections injected by its proxy without enabling `unsafe-inline`; this compatibility does not add application analytics or send tool inputs away from the browser.
|
||||
- `/tools/setting` is the fixed, deliberately unlisted management route. It is not authentication: anyone who can reach the application can open that URL and change the globally shared navigation order.
|
||||
- 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.
|
||||
- CRC presets are grouped by width and use canonical names. They cover CRC-8/SMBUS, MAXIM-DOW, and SAE-J1850; CRC-16/ARC, DNP, IBM-3740, IBM-SDLC, KERMIT, MODBUS, USB, and XMODEM; CRC-32/ISO-HDLC, ISCSI, and MPEG-2; and CRC-64/ECMA-182 and XZ. Familiar aliases such as CRC-8, CCITT-FALSE, CCITT, X-25, and CRC-32C remain visible to disambiguate otherwise overloaded names.
|
||||
- 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.
|
||||
|
||||
See [README.zh-CN.md](README.zh-CN.md) for the exact algorithm contracts, input limits, concurrency behavior, and server security controls.
|
||||
|
||||
## Resource limits
|
||||
|
||||
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 each for parsed inline CRC Hex and 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
|
||||
|
||||
Requirements: Docker Engine 24+ and Docker Compose v2 on Linux AMD64 or ARM64.
|
||||
@@ -105,6 +118,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
|
||||
@@ -115,7 +129,11 @@ 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 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.
|
||||
|
||||
The browser icon set follows the sidebar brand and includes SVG, multi-frame ICO, Apple touch, 192/512, and maskable assets. The manifest supplies metadata only: the project deliberately has no Service Worker, offline cache, install prompt, or splash-screen bundle, so a deployment update cannot be hidden by application-managed stale caches.
|
||||
|
||||
|
||||
+62
-42
@@ -4,21 +4,24 @@
|
||||
|
||||
一个匿名访问、自托管的浏览器本地工具箱。支持多人同时使用;除全局导航顺序外,文本、密钥、文件、图片和计算结果均只存在于各自浏览器中,不会发送到服务端。
|
||||
|
||||
当前应用版本:`0.1.0`。
|
||||
当前应用版本:`2026.8.22`。
|
||||
|
||||
## 内置工具
|
||||
|
||||
- ASCII/UTF-8 与 Hex 互转
|
||||
- UTF-8 与 Hex 互转,固定路径为 `/tools/utf8-hex`
|
||||
- Base64 与 Base64 URL 编码、解码,解码结果支持 UTF-8、连续 Hex 和代码字节数组
|
||||
- AES-128-CMAC(RFC 4493)
|
||||
- CRC-8/16/32/64 预设与自定义参数,支持 BIN 和多 block Intel HEX
|
||||
- AES-CMAC,支持 AES-128、AES-192 和 AES-256 密钥
|
||||
- 16 种 CRC-8/16/32/64 标准预设与自定义参数,支持内联 Hex 字节、BIN 和多 block Intel HEX
|
||||
- 固件 SHA-256 / SHA-512,支持 Intel HEX 有效数据和带地址/长度的 Block 校验头
|
||||
- RSA 2048/3072/4096 位密钥对,导出 PEM,以及可分别复制的 N/E Hex 或代码字节数组
|
||||
- 密码学安全随机数,支持连续 Hex、空格 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 格式化、压缩、校验和语法着色
|
||||
- PNG 转多尺寸 ICO
|
||||
|
||||
首次部署且尚未保存过自定义配置时,默认分类顺序为“编码转换 → 校验计算 → 加密计算 → 格式化 → 图像处理”。已有 `data/navigation.json` 会保留用户排序,不会因升级被默认顺序覆盖。
|
||||
|
||||
## 交付物与源码
|
||||
|
||||
容器交付 ZIP 同时包含源码、测试、生产构建结果和部署文件,不是只有压缩后的网页。主要目录如下:
|
||||
@@ -26,10 +29,11 @@
|
||||
```text
|
||||
src/ Vue 3 前端源码
|
||||
components/ 所有工具共用的 UI 组件
|
||||
composables/ 可复用的 Vue 状态与异步生命周期管理
|
||||
lib/ 可测试的纯计算与浏览器辅助逻辑
|
||||
tools/ 每个工具一个独立 Vue 组件
|
||||
workers/ 大文件计算使用的浏览器后台线程
|
||||
views/SettingView.vue /setting 工具管理页
|
||||
views/SettingView.vue /tools/setting 工具管理页
|
||||
server/ 零依赖 Node.js 静态服务和导航配置 API
|
||||
shared/tool-catalog.json 工具与分类的权威元数据目录
|
||||
shared/default-navigation.json 新部署的默认分类和工具顺序
|
||||
@@ -47,13 +51,13 @@ 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`,写入采用临时文件和原子替换。
|
||||
- `/setting` 是固定但不公开显示的管理入口;项目没有登录、收藏、赞助或用户系统。
|
||||
- `/tools/setting` 是固定但不公开显示的管理入口;项目没有登录、收藏、赞助或用户系统。
|
||||
- 页面必须保持统一 UI,并兼容桌面端和移动端竖屏。
|
||||
- 浏览器图标与左侧品牌标记保持一致。`favicon.svg` 是现代浏览器主图标,`favicon.ico` 内含 16/32/48 像素回退图,另提供 180 像素 Apple 图标、192/512 像素 Web App 图标和 512 像素 maskable 图标。
|
||||
|
||||
@@ -65,21 +69,24 @@ CHANGELOG.md 版本变更、修复和验证记录
|
||||
|
||||
以下行为属于已经发布的兼容性约束。修改时必须同步实现、页面提示、自动化测试和本文档,不能只调整界面文案。
|
||||
|
||||
- ASCII/Hex 工具按 UTF-8 字节转换文本;Hex 输入接受连续形式,或以空白、英文逗号、中文逗号分隔的片段,并允许每个片段使用可选的 `0x` 前缀。无效 UTF-8 字节必须报错,不能静默替换字符。
|
||||
- UTF-8 / Hex 工具的固定 ID 为 `utf8-hex`,路径为 `/tools/utf8-hex`。它按 UTF-8 字节转换文本;Hex 输入接受连续形式,或以空白、英文逗号、中文逗号分隔的片段,并允许每个片段使用可选的 `0x` 前缀。无效 UTF-8 字节必须报错,不能静默替换字符。
|
||||
- Base64 支持标准和 URL-safe 形式;解码结果可显示为严格 UTF-8、连续 Hex 或 `0x48, 0x9F, ...` 字节数组。无效字符、错误填充和非规范编码必须报错。
|
||||
- AES-128-CMAC 密钥必须恰好为 16 字节。密钥和 Hex 消息接受连续 Hex,或空格、换行、英文/中文逗号分隔,并允许片段使用可选的 `0x` 前缀;计算语义遵循 RFC 4493。
|
||||
- CRC 支持 BIN 和多 block Intel HEX,提供 CRC-8/16/32/64 预设,并允许自定义 `Width`、`Poly`、`Init`、`XorOut`、`RefIn` 和 `RefOut`。总 CRC 对按地址升序拼接的有效数据计算,不填充地址空洞,同时输出每个 block 的结果。
|
||||
- 固件 Hash 只接受 Intel HEX,输出 SHA-256 和 SHA-512。“有效数据”与“Block 校验头”模式的确切字节流见“固件 Hash 固定语义”;默认校验头字节序为 Big Endian。
|
||||
- RSA 只允许 2048、3072、4096 位密钥,公钥指数固定为 65537。优先使用 Web Crypto,必要时使用由 `crypto.getRandomValues` 提供随机种子的 `node-forge` 回退;公钥为 SPKI PEM,私钥为 PKCS#8 PEM,N/E 可分别显示为连续 Hex 或代码字节数组。
|
||||
- 安全随机数必须来自浏览器 `crypto.getRandomValues`,长度为 1 至 65,536 字节,支持连续 Hex、空格 Hex 和代码字节数组;切换显示格式不得重新生成随机数据。
|
||||
- JSON/XML 的格式化、压缩和校验均在浏览器本地完成。语法着色超过资源限制时只能降级为完整纯文本,不能截断结果。
|
||||
- 文件选择、文件更换、清空、模式切换和组件卸载都可能使异步任务失效。旧 Worker、旧图像解码和旧密钥生成结果不得覆盖当前输入或已清空状态。
|
||||
- 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 支持内联 Hex 字节、BIN 和多 block Intel HEX,提供 16 种按位宽分组的标准预设,并允许自定义 `Width`、`Poly`、`Init`、`XorOut`、`RefIn` 和 `RefOut`。预设包括 CRC-8/SMBUS、MAXIM-DOW、SAE-J1850;CRC-16/ARC、DNP、IBM-3740、IBM-SDLC、KERMIT、MODBUS、USB、XMODEM;CRC-32/ISO-HDLC、ISCSI、MPEG-2;CRC-64/ECMA-182、XZ。界面保留常用别名:CRC-8、CCITT-FALSE、CCITT、X-25 和 CRC-32C;有歧义的 CCITT 只作为 KERMIT 后的辅助别名展示,并始终通过完整名称与 IBM-3740(CCITT-FALSE)区分。内联 Hex 接受连续形式,或以空白、英文逗号、中文逗号分隔的片段,并允许每个片段使用可选的 `0x` 前缀;空输入和无效字节格式会被拒绝,解析与计算均在独立 Web Worker 中完成。`RefIn`/`RefOut` 在页面中标为输入/输出位反转,不表示字节顺序或整个数据倒序。文件模式的总 CRC 对按地址升序拼接的有效数据计算,不填充地址空洞,同时输出每个 block 的结果。
|
||||
- 固件 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、旧图像解码和旧密钥生成结果不得覆盖当前输入或已清空状态。CRC、固件 Hash 与 RSA 签名验证的文件模式在已有文件时支持把新文件直接拖入摘要区域;候选文件解析成功后才替换当前文件并清除旧结果,解析失败时保留当前文件和结果。
|
||||
|
||||
## 多人使用与状态边界
|
||||
|
||||
- 每个浏览器标签页拥有独立的 Vue 组件状态、文件对象、图像对象和 Web Worker。不同用户、浏览器或标签页不会共享工具输入与结果。
|
||||
- CRC、固件 Hash、RSA、AES-CMAC、随机数、PNG/ICO、编码转换和格式化都在浏览器中执行;服务端没有工具计算接口。
|
||||
- `/setting` 保存的是所有访问者共享的导航配置。并发保存没有版本冲突合并,最后完成原子替换的请求生效。
|
||||
- CRC、固件 Hash、RSA 密钥生成与签名验证、AES-CMAC、随机数、PNG/ICO、编码转换和格式化都在浏览器中执行;服务端没有工具计算接口。
|
||||
- `/tools/setting` 保存的是所有访问者共享的导航配置。同一服务进程会按进入导航操作队列的顺序串行处理读写,最后排队的保存生效;不提供版本冲突合并。
|
||||
- 旧导航配置中的历史工具 ID `ascii-hex` 会在读取时原位迁移为 `utf8-hex`,保留原分类和排序;新配置和应用路由只使用 `utf8-hex`。
|
||||
- 原子替换保证读取者看到完整的旧配置或新配置,不会看到写到一半的 JSON;它不保证保留多个用户各自的排序修改。
|
||||
- 已打开的页面不会实时接收其他用户保存的新顺序,刷新或重新打开后才会读取。
|
||||
- 深浅主题保存在当前浏览器同源 `localStorage` 中;系统剪贴板由操作系统管理。这两项不会改变工具计算结果。
|
||||
@@ -90,13 +97,18 @@ CHANGELOG.md 版本变更、修复和验证记录
|
||||
|
||||
| 功能 | 当前限制 | 超限行为 |
|
||||
| --- | --- | --- |
|
||||
| CRC 固件文件 | 最大 64 MiB | 选择时拒绝,不保留旧文件结果 |
|
||||
| CRC 固件文件 | 最大 64 MiB | 首次选择时拒绝;替换时保留当前文件和结果 |
|
||||
| CRC 内联 Hex | 解析后最大 1 MiB;原始输入最多 6 MiB 字符 | 在 Worker 中解析与计算,超限或空输入直接拒绝 |
|
||||
| Intel HEX 非空记录 | 最多 500,000 条 | Worker 停止解析并显示错误 |
|
||||
| Intel HEX 数据块 | 最多 1,024 个 block | Worker 停止解析并显示错误 |
|
||||
| 固件 Hash 文件及有效数据 | 文件最大 64 MiB,有效数据最大 64 MiB | 选择或计算时拒绝 |
|
||||
| Intel HEX 合并后的有效数据 | 最大 64 MiB | 合并分配前拒绝,Worker 停止解析并显示错误 |
|
||||
| 固件 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 像素 | 浏览器解码前拒绝 |
|
||||
| 安全随机数 | 每次 1 至 65,536 字节 | 拒绝无效长度 |
|
||||
| 安全随机数 | 默认 16 字节,每次 1 至 65,536 字节 | 拒绝无效长度或不安全回退 |
|
||||
| JSON/XML 语法着色 | 最大 100,000 字符且不超过 10,000 个 token | 结果仍完整显示,但降级为纯文本以减少 DOM 节点 |
|
||||
| 导航配置请求体 | 最大 64 KiB | 服务端返回 400 |
|
||||
|
||||
@@ -139,6 +151,12 @@ docker compose down
|
||||
|
||||
健康检查地址:`http://服务器地址:9000/healthz`
|
||||
|
||||
### Cloudflare 反向代理与 CSP
|
||||
|
||||
服务端在每个响应的 `Content-Security-Policy` 中放入独立的 128 位密码学随机 nonce。Cloudflare JavaScript Detections 会从源站 CSP 响应头读取该 nonce,并把它加到代理注入的脚本上,因此 Bot Fight Mode 可在不启用 `'unsafe-inline'` 的情况下与严格 CSP 共存。不得把 nonce 改为固定值,也不得为消除控制台提示而放开 `'unsafe-inline'`。
|
||||
|
||||
项目自身不添加 Web Analytics、遥测或第三方脚本。Cloudflare Web Analytics 是部署层可选的浏览器 RUM,不是 CDN、WAF、DDoS 或 Bot 防护的必要条件;默认保持关闭时,Cloudflare 边缘流量统计仍然可用。若未来明确启用 Web Analytics,必须单独评审 CSP 外部脚本来源和“无统计/无遥测”文档契约。
|
||||
|
||||
## Windows Server 部署(不使用 Docker)
|
||||
|
||||
要求安装 Git、Node.js 22.12.0 或更高版本,并使用 PowerShell 5.1 或更高版本。以下示例假设项目位于 `C:\Services\WebTools`。
|
||||
@@ -206,7 +224,7 @@ powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\web-tools.ps1 update
|
||||
2. 抽象静态资源读取方式,使 Node 服务既能读取开发时的文件系统,也能读取 EXE 内嵌资源。
|
||||
3. 增加 SEA 配置、注入工具(通常为 `postject`)、`build:exe` 脚本以及 Windows x64/ARM64 分架构构建。
|
||||
4. 继续把 `navigation.json` 放在 EXE 外部的可写数据目录,默认使用 `C:\ProgramData\WebTools\data`,升级 EXE 时不得覆盖它。
|
||||
5. 增加 Windows Server 启动、端口、静态资源、`/setting`、更新替换和杀毒软件兼容性冒烟测试。
|
||||
5. 增加 Windows Server 启动、端口、静态资源、`/tools/setting`、更新替换和杀毒软件兼容性冒烟测试。
|
||||
6. 正式对外分发时补充 EXE 图标/版本资源,并考虑 Authenticode 代码签名;未签名程序可能触发 SmartScreen 警告。
|
||||
|
||||
这项改造对源码体积影响不大,通常只是数十 KB 级的入口、资源适配和构建脚本,并增加少量开发依赖;真正明显的是发布产物。每个架构的 EXE 会包含 Node.js 运行时,常见为约 60-100 MB 未压缩、约 30-60 MB 压缩,具体随 Node 版本和资源大小变化。EXE 和中间产物必须保持在 `.gitignore` 或发布目录中,不提交到 Git,因此仓库不会明显膨胀;增加的主要成本是分架构构建、签名、测试和更新维护。
|
||||
@@ -223,7 +241,7 @@ powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\web-tools.ps1 update
|
||||
管理入口不显示在页面中,直接访问:
|
||||
|
||||
```text
|
||||
http://服务器地址:9000/setting
|
||||
http://服务器地址:9000/tools/setting
|
||||
```
|
||||
|
||||
可以调整分类顺序、分类内工具顺序及工具所属分类。保存时采用同目录临时文件和原子替换。
|
||||
@@ -245,10 +263,10 @@ docker compose restart web-tools
|
||||
|
||||
## 离线镜像
|
||||
|
||||
若交付目录包含 `personal-web-tools-0.1.0-amd64.tar`,可直接导入:
|
||||
若交付目录包含 `personal-web-tools-2026.8.22-amd64.tar`,可直接导入:
|
||||
|
||||
```bash
|
||||
docker load -i personal-web-tools-0.1.0-amd64.tar
|
||||
docker load -i personal-web-tools-2026.8.22-amd64.tar
|
||||
docker compose up -d --no-build
|
||||
```
|
||||
|
||||
@@ -300,15 +318,15 @@ npm run check
|
||||
npm audit --audit-level=low
|
||||
```
|
||||
|
||||
`npm run check` 会依次运行全部 Vitest 测试、TypeScript/Vue 类型检查和生产构建。服务端测试使用随机本地端口验证 SPA 直达路由、静态资源 404、导航 API、请求大小限制及跨站写入拦截。
|
||||
`npm run check` 会依次运行全部 Vitest 测试、TypeScript/Vue 类型检查和生产构建。服务端测试使用随机本地端口验证 SPA 直达路由、静态资源 404 与缓存策略、导航 API、请求大小限制及跨源浏览器写入拦截。
|
||||
|
||||
自动化测试还覆盖标准算法向量、Base64 规范性、Intel HEX 记录与 block 边界、固件 Hash 与大小端 Block 头向量、PNG 头部与像素限制、导航排序纯函数、目录/图标/懒加载组件一致性,以及原子写入失败后的临时文件清理。
|
||||
自动化测试还覆盖标准算法向量、Base64 规范性、16 种 CRC 预设的名称/参数/Check 值、空输入与 64 位结果边界、CRC 内联 Hex 已知向量/多种分隔格式/资源上限、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 中本地处理,不会把固件数据发送到服务端。
|
||||
|
||||
@@ -319,7 +337,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. 先定义稳定标识
|
||||
|
||||
@@ -338,7 +356,7 @@ uint32 dataLength
|
||||
|
||||
在 `tests/` 添加正常输入、边界输入、无效输入和已知测试向量。涉及字节的工具必须明确 UTF-8、Hex、Base64、换行及分隔符语义,避免隐式改变数据。
|
||||
|
||||
文件或高开销工具还必须定义文件大小、记录数量、图像尺寸或输出 DOM 数量等资源上限。异步工具在清空、更换输入或卸载后必须忽略旧任务结果;计算量较大的文件解析与 CRC 计算放入 Web Worker。
|
||||
文件或高开销工具还必须定义文件大小、记录数量、图像尺寸或输出 DOM 数量等资源上限。异步工具在清空、更换输入或卸载后必须忽略旧任务结果;计算量较大的文件解析与 CRC 计算放入 Web Worker。若新工具采用“解析文件后继续在同一 Worker 中计算”的协议,应优先复用 `FileDropField` 和 `useTransactionalFileWorker`;候选文件解析成功前不得销毁当前 Worker、文件摘要或结果。
|
||||
|
||||
### 3. 编写统一的工具页面
|
||||
|
||||
@@ -348,7 +366,7 @@ uint32 dataLength
|
||||
- 文本输入输出优先使用 `TextEditor`。
|
||||
- 模式切换使用 `ModeTabs`。
|
||||
- 操作按钮使用 Lucide 图标和现有 `action-button` 类。
|
||||
- 必须提供清空、错误提示和必要的复制/粘贴操作。
|
||||
- 必须提供清空、错误提示和必要的复制/粘贴操作。文件工具的交互与现有协议匹配时复用 `FileDropField`,不要复制一套相近的上传框和替换状态。
|
||||
- 不增加登录、收藏、赞助、遥测、外部网络请求或独立视觉风格。
|
||||
- 在窄屏下确认控件不溢出、不重叠,文字不会遮挡。
|
||||
|
||||
@@ -392,7 +410,7 @@ npm audit --audit-level=low
|
||||
还应在真实浏览器中检查:
|
||||
|
||||
- 新工具的默认路径 `/tools/<id>` 可以直接打开和刷新。
|
||||
- 左侧导航、搜索和 `/setting` 正常显示新工具。
|
||||
- 左侧导航、搜索和 `/tools/setting` 正常显示新工具。
|
||||
- 已保存的旧 `navigation.json` 在服务重启并首次读取后会自动补入新工具;仅重新构建 `dist` 不会刷新已经运行的 Node 进程。
|
||||
- 桌面端和约 `390 x 844` 的移动端竖屏布局正常。
|
||||
- 浏览器控制台无错误,工具内容没有发往服务端。
|
||||
@@ -404,9 +422,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`。发现工作区已有未说明的改动时应保留并兼容,不得擅自回退。
|
||||
|
||||
@@ -416,9 +434,9 @@ AI 不应仅修改 `dist/`;必须修改源码并重新构建。不得覆盖或
|
||||
|
||||
## 版本与发布
|
||||
|
||||
应用版本的唯一权威来源是 `package.json`。`package-lock.json` 记录完整依赖树、下载地址和完整性摘要,供 `npm ci` 复现安装;其中项目自身的版本会在文件顶部和 `packages[""]` 中各出现一次,其余 `version` 都是第三方依赖版本,不应手工统一修改。
|
||||
应用版本的唯一权威来源是 `package.json`。版本采用 npm/SemVer 兼容的日历版本 `YYYY.M.D`,例如 `2026.8.22`;月份和日期不使用前导零。每个自然日只发布一个正式版本,需要预发布验证时可使用 `YYYY.M.D-rc.N`。`package-lock.json` 记录完整依赖树、下载地址和完整性摘要,供 `npm ci` 复现安装;其中项目自身的版本会在文件顶部和 `packages[""]` 中各出现一次,其余 `version` 都是第三方依赖版本,不应手工统一修改。
|
||||
|
||||
使用 `npm version patch --no-git-tag-version`(或按兼容性选择 minor/major)可以同步更新 `package.json` 与 `package-lock.json`。按当前工程结构,一次完整版本发布需要更新以下 5 个已跟踪文件:
|
||||
使用 `npm version YYYY.M.D --no-git-tag-version` 可以同步更新 `package.json` 与 `package-lock.json`。按当前工程结构,一次完整版本发布需要更新以下 5 个已跟踪文件:
|
||||
|
||||
1. `package.json`
|
||||
2. `package-lock.json`
|
||||
@@ -438,12 +456,14 @@ AI 不应仅修改 `dist/`;必须修改源码并重新构建。不得覆盖或
|
||||
|
||||
## 安全边界
|
||||
|
||||
- 无登录、无统计、无遥测、无 CDN 和第三方网络请求。
|
||||
- `/setting` 是固定但不显示的入口,不构成身份认证;直接公开到互联网时任何访问者都可能修改排序。
|
||||
- 应用代码无登录、无统计、无遥测、无 CDN 资产和第三方网络请求;部署时可在外层使用 Cloudflare 反向代理。
|
||||
- `/tools/setting` 是固定但不显示的入口,不构成身份认证;直接公开到互联网时任何访问者都可能修改排序。
|
||||
- 工具内容仅在浏览器内存中处理,默认不会持久化。
|
||||
- 容器以非 root 用户运行,根文件系统只读,全部 Linux capabilities 已移除。
|
||||
- 服务端只接受不超过 64 KiB 的导航 JSON 配置。
|
||||
- 服务端只提供静态文件、`GET/PUT /api/navigation` 和 `GET /healthz`。
|
||||
- 只有无扩展名且明确请求 HTML 的前端路由会回退到 `index.html`;缺失的 JS、CSS 和其他资源返回 404。
|
||||
- 浏览器发起的导航配置写入只接受 `Sec-Fetch-Site: same-origin`;缺少该请求头的非浏览器客户端仍可写入,因此这项检查不能替代身份认证或外层访问控制。
|
||||
- JSON API 响应使用 `Cache-Control: no-store`,避免浏览器缓存导航和健康状态。
|
||||
- 安全响应头禁止第三方脚本、跨站嵌入、摄像头、麦克风和定位权限。
|
||||
- 只有文件名带构建指纹的 `dist/assets/` 资源使用一年 `immutable` 缓存;HTML、favicon、Manifest 和其他稳定文件名资源使用 `no-cache`,更新后会重新验证。
|
||||
- 安全响应头禁止第三方脚本、跨站嵌入、摄像头、麦克风和定位权限;每个响应的 CSP 使用独立 128 位 nonce 兼容 Cloudflare JavaScript Detections,不允许 `'unsafe-inline'`。
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
services:
|
||||
web-tools:
|
||||
image: personal-web-tools:0.1.0
|
||||
image: personal-web-tools:2026.8.22
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
|
||||
Generated
+5
-5
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "personal-web-tools",
|
||||
"version": "0.1.0",
|
||||
"version": "2026.8.22",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "personal-web-tools",
|
||||
"version": "0.1.0",
|
||||
"version": "2026.8.22",
|
||||
"dependencies": {
|
||||
"@lucide/vue": "^1.29.0",
|
||||
"@stablelib/aes": "^2.0.1",
|
||||
@@ -1167,9 +1167,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.17",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.17.tgz",
|
||||
"integrity": "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==",
|
||||
"version": "3.3.18",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
|
||||
"integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "personal-web-tools",
|
||||
"version": "0.1.0",
|
||||
"version": "2026.8.22",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
|
||||
+28
-7
@@ -1,3 +1,4 @@
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import { createReadStream } from 'node:fs'
|
||||
import { stat } from 'node:fs/promises'
|
||||
import { createServer } from 'node:http'
|
||||
@@ -17,6 +18,7 @@ const serverDir = path.dirname(serverFile)
|
||||
const projectRoot = path.resolve(serverDir, '..')
|
||||
export const MAX_JSON_BODY_SIZE = 64 * 1024
|
||||
export const DEFAULT_PORT = 9000
|
||||
export const CSP_NONCE_BYTES = 16
|
||||
|
||||
const mimeTypes = new Map([
|
||||
['.css', 'text/css; charset=utf-8'],
|
||||
@@ -32,8 +34,13 @@ const mimeTypes = new Map([
|
||||
['.woff2', 'font/woff2'],
|
||||
])
|
||||
|
||||
export function createContentSecurityPolicy(nonce) {
|
||||
return `default-src 'self'; img-src 'self' blob: data:; style-src 'self'; script-src 'self' 'nonce-${nonce}'; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'`
|
||||
}
|
||||
|
||||
function applySecurityHeaders(response) {
|
||||
response.setHeader('Content-Security-Policy', "default-src 'self'; img-src 'self' blob: data:; style-src 'self'; script-src 'self'; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'")
|
||||
const nonce = randomBytes(CSP_NONCE_BYTES).toString('base64')
|
||||
response.setHeader('Content-Security-Policy', createContentSecurityPolicy(nonce))
|
||||
response.setHeader('Cross-Origin-Opener-Policy', 'same-origin')
|
||||
response.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=()')
|
||||
response.setHeader('Referrer-Policy', 'no-referrer')
|
||||
@@ -96,6 +103,12 @@ function acceptsHtmlNavigation(request, pathname) {
|
||||
return String(request.headers.accept || '').split(',').some((value) => value.trim().toLowerCase().startsWith('text/html'))
|
||||
}
|
||||
|
||||
function isFingerprintedAsset(filePath, staticRoot) {
|
||||
const relativePath = path.relative(staticRoot, filePath)
|
||||
return relativePath.split(path.sep)[0] === 'assets'
|
||||
&& /-[A-Za-z0-9_-]{8,}(?:\.[^.]+)+$/.test(path.basename(relativePath))
|
||||
}
|
||||
|
||||
async function serveStatic(request, response, staticRoot) {
|
||||
const url = new URL(request.url, 'http://localhost')
|
||||
let pathname
|
||||
@@ -133,8 +146,8 @@ async function serveStatic(request, response, staticRoot) {
|
||||
response.statusCode = 200
|
||||
response.setHeader('Content-Type', mimeTypes.get(path.extname(filePath).toLowerCase()) || 'application/octet-stream')
|
||||
response.setHeader('Content-Length', details.size)
|
||||
if (path.basename(filePath) === 'index.html') response.setHeader('Cache-Control', 'no-cache')
|
||||
else response.setHeader('Cache-Control', 'public, max-age=31536000, immutable')
|
||||
if (isFingerprintedAsset(filePath, staticRoot)) response.setHeader('Cache-Control', 'public, max-age=31536000, immutable')
|
||||
else response.setHeader('Cache-Control', 'no-cache')
|
||||
if (request.method === 'HEAD') {
|
||||
response.end()
|
||||
return
|
||||
@@ -149,6 +162,13 @@ export async function createWebToolsServer(options = {}) {
|
||||
const catalog = options.catalog || await loadCatalog()
|
||||
const defaults = options.defaults || await loadDefaultNavigation()
|
||||
const logger = options.logger || console
|
||||
let navigationOperation = Promise.resolve()
|
||||
|
||||
function withNavigationLock(operation) {
|
||||
const result = navigationOperation.then(operation, operation)
|
||||
navigationOperation = result.catch(() => undefined)
|
||||
return result
|
||||
}
|
||||
|
||||
return createServer(async (request, response) => {
|
||||
applySecurityHeaders(response)
|
||||
@@ -160,12 +180,13 @@ export async function createWebToolsServer(options = {}) {
|
||||
}
|
||||
if (url.pathname === '/api/navigation') {
|
||||
if (request.method === 'GET') {
|
||||
sendJson(response, 200, await readOrCreateNavigation(navigationPath, catalog, defaults))
|
||||
sendJson(response, 200, await withNavigationLock(() => readOrCreateNavigation(navigationPath, catalog, defaults)))
|
||||
return
|
||||
}
|
||||
if (request.method === 'PUT') {
|
||||
if (request.headers['sec-fetch-site'] === 'cross-site') {
|
||||
sendJson(response, 403, { error: '不允许跨站写入配置' })
|
||||
const fetchSite = request.headers['sec-fetch-site']
|
||||
if (fetchSite && fetchSite !== 'same-origin') {
|
||||
sendJson(response, 403, { error: '不允许跨源写入配置' })
|
||||
return
|
||||
}
|
||||
let navigation
|
||||
@@ -175,7 +196,7 @@ export async function createWebToolsServer(options = {}) {
|
||||
sendJson(response, 400, { error: error instanceof Error ? error.message : '配置格式无效' })
|
||||
return
|
||||
}
|
||||
await writeJsonAtomic(navigationPath, navigation)
|
||||
await withNavigationLock(() => writeJsonAtomic(navigationPath, navigation))
|
||||
sendJson(response, 200, navigation)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -5,6 +5,9 @@ import { fileURLToPath } from 'node:url'
|
||||
|
||||
const serverDir = path.dirname(fileURLToPath(import.meta.url))
|
||||
const projectRoot = path.resolve(serverDir, '..')
|
||||
const legacyToolIdAliases = new Map([
|
||||
['ascii-hex', 'utf8-hex'],
|
||||
])
|
||||
|
||||
async function readJson(filePath) {
|
||||
return JSON.parse(await readFile(filePath, 'utf8'))
|
||||
@@ -30,7 +33,9 @@ export function normalizeNavigation(input, catalog, defaults) {
|
||||
if (!candidate || !categoryIds.has(candidate.id) || seenCategories.has(candidate.id)) continue
|
||||
seenCategories.add(candidate.id)
|
||||
const tools = []
|
||||
for (const toolId of Array.isArray(candidate.tools) ? candidate.tools : []) {
|
||||
for (const savedToolId of Array.isArray(candidate.tools) ? candidate.tools : []) {
|
||||
const migratedToolId = legacyToolIdAliases.get(savedToolId)
|
||||
const toolId = migratedToolId && toolById.has(migratedToolId) ? migratedToolId : savedToolId
|
||||
if (!toolById.has(toolId) || seenTools.has(toolId)) continue
|
||||
seenTools.add(toolId)
|
||||
tools.push(toolId)
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"version": 1,
|
||||
"categories": [
|
||||
{ "id": "encoding", "tools": ["ascii-hex", "base64"] },
|
||||
{ "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"] }
|
||||
]
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
],
|
||||
"tools": [
|
||||
{
|
||||
"id": "ascii-hex",
|
||||
"title": "ASCII / Hex",
|
||||
"id": "utf8-hex",
|
||||
"title": "UTF-8 / Hex",
|
||||
"category": "encoding",
|
||||
"description": "在 UTF-8 文本与十六进制字节之间转换。",
|
||||
"icon": "Binary"
|
||||
@@ -24,9 +24,9 @@
|
||||
},
|
||||
{
|
||||
"id": "aes-cmac",
|
||||
"title": "AES-128-CMAC",
|
||||
"title": "AES-CMAC",
|
||||
"category": "crypto",
|
||||
"description": "使用 128 位密钥计算消息认证码。",
|
||||
"description": "使用 128、192 或 256 位 AES 密钥计算消息认证码。",
|
||||
"icon": "ShieldCheck"
|
||||
},
|
||||
{
|
||||
@@ -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": "安全随机数",
|
||||
@@ -47,7 +54,7 @@
|
||||
"id": "crc",
|
||||
"title": "CRC",
|
||||
"category": "checksum",
|
||||
"description": "解析 BIN 或 Intel HEX 数据并计算 CRC。",
|
||||
"description": "输入 Hex 字节或解析 BIN、Intel HEX 数据并计算 CRC。",
|
||||
"icon": "BadgeCheck"
|
||||
},
|
||||
{
|
||||
|
||||
@@ -25,17 +25,17 @@ const sections = computed(() => props.navigation.categories.map((navigationCateg
|
||||
<aside id="tool-navigation" class="app-sidebar" aria-label="工具导航">
|
||||
<div class="brand-row">
|
||||
<span class="brand-mark"><Wrench :size="17" aria-hidden="true" /></span>
|
||||
<span><strong>Web Tools</strong><small>Personal workspace</small></span>
|
||||
<span><strong>Web Tools</strong><small>本地工具箱</small></span>
|
||||
</div>
|
||||
<label class="sidebar-search">
|
||||
<Search :size="15" aria-hidden="true" />
|
||||
<input v-model="query" type="search" placeholder="搜索工具" aria-label="搜索工具" />
|
||||
<input id="tool-search" name="tool-search" v-model="query" type="search" placeholder="搜索工具" aria-label="搜索工具" />
|
||||
</label>
|
||||
<nav class="tool-navigation">
|
||||
<section v-for="section in sections" :key="section.category!.id">
|
||||
<h2>{{ section.category!.title }}</h2>
|
||||
<RouterLink
|
||||
v-for="(tool, index) in section.tools"
|
||||
v-for="tool in section.tools"
|
||||
:key="tool!.id"
|
||||
:to="`/tools/${tool!.id}`"
|
||||
:class="{ 'is-active': route.meta.toolId === tool!.id }"
|
||||
@@ -43,7 +43,6 @@ const sections = computed(() => props.navigation.categories.map((navigationCateg
|
||||
>
|
||||
<component :is="tool!.iconComponent" :size="16" aria-hidden="true" />
|
||||
<span>{{ tool!.title }}</span>
|
||||
<small>{{ String(index + 1).padStart(2, '0') }}</small>
|
||||
</RouterLink>
|
||||
</section>
|
||||
<p v-if="sections.length === 0" class="empty-search">没有匹配的工具</p>
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
<script setup lang="ts">
|
||||
import { LoaderCircle, Trash2, Upload } from '@lucide/vue'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
inputId: string
|
||||
replacementInputId: string
|
||||
accept: string
|
||||
hasFile: boolean
|
||||
busy: boolean
|
||||
initialLabel: string
|
||||
initialBusyLabel: string
|
||||
hint: string
|
||||
replacementBusyLabel?: string
|
||||
dropLabel?: string
|
||||
replaceLabel?: string
|
||||
clearLabel?: string
|
||||
compact?: boolean
|
||||
}>(), {
|
||||
replacementBusyLabel: '正在验证新文件…',
|
||||
dropLabel: '松开以更换文件',
|
||||
replaceLabel: '更换文件',
|
||||
clearLabel: '清空',
|
||||
compact: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [file: File]
|
||||
clear: []
|
||||
}>()
|
||||
|
||||
const dragging = ref(false)
|
||||
|
||||
function onFileInput(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const selected = input.files?.[0]
|
||||
input.value = ''
|
||||
if (selected && !props.busy) emit('select', selected)
|
||||
}
|
||||
|
||||
function onDragOver(event: DragEvent) {
|
||||
if (!props.busy && event.dataTransfer?.types.includes('Files')) dragging.value = true
|
||||
}
|
||||
|
||||
function onDragLeave(event: DragEvent) {
|
||||
const container = event.currentTarget as HTMLElement
|
||||
const nextTarget = event.relatedTarget
|
||||
if (nextTarget instanceof Node && container.contains(nextTarget)) return
|
||||
dragging.value = false
|
||||
}
|
||||
|
||||
function onDrop(event: DragEvent) {
|
||||
dragging.value = false
|
||||
const selected = event.dataTransfer?.files[0]
|
||||
if (selected && !props.busy) emit('select', selected)
|
||||
}
|
||||
|
||||
watch(() => props.busy, (busy) => {
|
||||
if (busy) dragging.value = false
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<label
|
||||
v-if="!hasFile"
|
||||
class="upload-zone"
|
||||
:class="{ 'compact-upload': compact, 'is-dragging': dragging }"
|
||||
@dragover.prevent="onDragOver"
|
||||
@dragleave.prevent="onDragLeave"
|
||||
@drop.prevent="onDrop"
|
||||
>
|
||||
<input :id="inputId" :name="inputId" type="file" :accept="accept" :disabled="busy" @change="onFileInput" />
|
||||
<span>
|
||||
<slot name="icon"><Upload :size="28" aria-hidden="true" /></slot>
|
||||
<strong>{{ busy ? initialBusyLabel : initialLabel }}</strong>
|
||||
<span>{{ hint }}</span>
|
||||
</span>
|
||||
</label>
|
||||
<div
|
||||
v-else
|
||||
class="file-summary file-drop-summary"
|
||||
:class="{ 'is-dragging': dragging, 'is-replacing': busy }"
|
||||
:aria-busy="busy"
|
||||
@dragover.prevent="onDragOver"
|
||||
@dragleave.prevent="onDragLeave"
|
||||
@drop.prevent="onDrop"
|
||||
>
|
||||
<div><slot /></div>
|
||||
<label class="action-button" :class="{ disabled: busy }" :aria-disabled="busy">
|
||||
<Upload :size="15" aria-hidden="true" />{{ replaceLabel }}
|
||||
<input
|
||||
:id="replacementInputId"
|
||||
:name="replacementInputId"
|
||||
class="sr-only-file"
|
||||
type="file"
|
||||
:accept="accept"
|
||||
:disabled="busy"
|
||||
@change="onFileInput"
|
||||
/>
|
||||
</label>
|
||||
<button class="action-button" type="button" :disabled="busy" @click="emit('clear')">
|
||||
<Trash2 :size="15" aria-hidden="true" />{{ clearLabel }}
|
||||
</button>
|
||||
<div v-if="dragging || busy" class="file-drop-feedback" role="status">
|
||||
<LoaderCircle v-if="busy" class="file-drop-spinner" :size="18" aria-hidden="true" />
|
||||
<Upload v-else :size="18" aria-hidden="true" />
|
||||
<strong>{{ busy ? replacementBusyLabel : dropLabel }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,17 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
const props = withDefaults(defineProps<{
|
||||
id: string
|
||||
name?: string
|
||||
modelValue: string
|
||||
label: string
|
||||
format?: string
|
||||
meta?: string
|
||||
placeholder?: string
|
||||
readonly?: boolean
|
||||
disabled?: boolean
|
||||
rows?: number
|
||||
}>(), {
|
||||
format: '',
|
||||
meta: '',
|
||||
placeholder: '',
|
||||
readonly: false,
|
||||
disabled: false,
|
||||
rows: 9,
|
||||
})
|
||||
|
||||
@@ -30,9 +34,12 @@ function update(event: Event) {
|
||||
<span v-if="meta" class="editor-meta">{{ meta }}</span>
|
||||
</span>
|
||||
<textarea
|
||||
:id="props.id"
|
||||
:name="props.name ?? props.id"
|
||||
:value="props.modelValue"
|
||||
:placeholder="placeholder"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:rows="rows"
|
||||
spellcheck="false"
|
||||
@input="update"
|
||||
|
||||
@@ -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<TCalculatePayload extends object, TResponse extends WorkerResponse> {
|
||||
createWorker: () => Worker
|
||||
createParseRequest?: (id: number, file: File) => object
|
||||
onParsed: (response: Extract<TResponse, ParsedWorkerResponse>, file: File) => void
|
||||
onCalculated: (response: Extract<TResponse, CalculatedWorkerResponse>) => 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<TCalculatePayload extends object, TResponse extends WorkerResponse>(
|
||||
options: TransactionalFileWorkerOptions<TCalculatePayload, TResponse>,
|
||||
) {
|
||||
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<TResponse>) => {
|
||||
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<TResponse, ParsedWorkerResponse>, selected)
|
||||
return
|
||||
}
|
||||
if (response.type === 'calculated') {
|
||||
if (nextWorker !== activeWorker || response.id !== activeCalculationRequest) return
|
||||
activeCalculationRequest = undefined
|
||||
calculating.value = false
|
||||
options.onCalculated(response as Extract<TResponse, CalculatedWorkerResponse>)
|
||||
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,
|
||||
}
|
||||
}
|
||||
+112
-13
@@ -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,75 @@ 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
|
||||
let commaNeedsValue = false
|
||||
for (let index = 0; index < trimmed.length; index += 1) {
|
||||
const character = trimmed[index]
|
||||
if (isHexSeparator(character)) {
|
||||
if ((character === ',' || character === ',') && (!sawToken || commaNeedsValue)) throw new Error('十六进制片段缺少数值')
|
||||
if (tokenActive && !tokenHasDigits) throw new Error('十六进制片段缺少数值')
|
||||
if (character === ',' || character === ',') commaNeedsValue = true
|
||||
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
|
||||
commaNeedsValue = false
|
||||
}
|
||||
if (tokenActive && !tokenHasDigits) throw new Error('十六进制片段缺少数值')
|
||||
if (commaNeedsValue) 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 +133,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)
|
||||
@@ -83,9 +181,10 @@ export function decodeBase64(input: string, urlSafe = false): Uint8Array {
|
||||
if (urlSafe) normalized = normalized.replace(/-/g, '+').replace(/_/g, '/')
|
||||
if (!normalized) return new Uint8Array()
|
||||
if (/[^A-Za-z0-9+/=]/.test(normalized)) throw new Error('包含无效的 Base64 字符')
|
||||
if (!/^[A-Za-z0-9+/]*={0,2}$/.test(normalized)) throw new Error('Base64 填充位置无效')
|
||||
if (normalized.includes('=') && normalized.length % 4 !== 0) throw new Error('Base64 填充长度无效')
|
||||
if (normalized.length % 4 === 1) throw new Error('Base64 长度无效')
|
||||
normalized = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=')
|
||||
if (!/^[A-Za-z0-9+/]*={0,2}$/.test(normalized)) throw new Error('Base64 填充位置无效')
|
||||
try {
|
||||
const binary = atob(normalized)
|
||||
if (btoa(binary) !== normalized) throw new Error('non-canonical')
|
||||
|
||||
+12
-2
@@ -1,8 +1,13 @@
|
||||
import { AES } from '@stablelib/aes'
|
||||
import { CMAC } from '@stablelib/cmac'
|
||||
|
||||
export function aes128Cmac(key: Uint8Array, message: Uint8Array): Uint8Array {
|
||||
if (key.length !== 16) throw new Error(`AES-128 密钥必须是 16 字节,当前为 ${key.length} 字节`)
|
||||
export const AES_KEY_LENGTHS = [16, 24, 32] as const
|
||||
export type AesKeyLength = typeof AES_KEY_LENGTHS[number]
|
||||
|
||||
export function aesCmac(key: Uint8Array, message: Uint8Array): Uint8Array {
|
||||
if (!AES_KEY_LENGTHS.includes(key.length as AesKeyLength)) {
|
||||
throw new Error(`AES-CMAC 密钥必须是 16、24 或 32 字节,当前为 ${key.length} 字节`)
|
||||
}
|
||||
const aes = new AES(key)
|
||||
const cmac = new CMAC(aes)
|
||||
try {
|
||||
@@ -13,3 +18,8 @@ export function aes128Cmac(key: Uint8Array, message: Uint8Array): Uint8Array {
|
||||
aes.clean()
|
||||
}
|
||||
}
|
||||
|
||||
export function aes128Cmac(key: Uint8Array, message: Uint8Array): Uint8Array {
|
||||
if (key.length !== 16) throw new Error(`AES-128 密钥必须是 16 字节,当前为 ${key.length} 字节`)
|
||||
return aesCmac(key, message)
|
||||
}
|
||||
|
||||
+40
-8
@@ -13,14 +13,36 @@ export interface CrcPreset extends CrcParameters {
|
||||
check: bigint
|
||||
}
|
||||
|
||||
export const CRC_PRESETS: CrcPreset[] = [
|
||||
{ id: 'crc-32-iso-hdlc', name: 'CRC-32 / ISO-HDLC', width: 32, polynomial: 0x04c11db7n, initialValue: 0xffffffffn, inputReflected: true, outputReflected: true, xorOut: 0xffffffffn, check: 0xcbf43926n },
|
||||
{ id: 'crc-16-ccitt-false', name: 'CRC-16 / CCITT-FALSE', width: 16, polynomial: 0x1021n, initialValue: 0xffffn, inputReflected: false, outputReflected: false, xorOut: 0n, check: 0x29b1n },
|
||||
{ id: 'crc-16-modbus', name: 'CRC-16 / MODBUS', width: 16, polynomial: 0x8005n, initialValue: 0xffffn, inputReflected: true, outputReflected: true, xorOut: 0n, check: 0x4b37n },
|
||||
{ id: 'crc-16-arc', name: 'CRC-16 / ARC', width: 16, polynomial: 0x8005n, initialValue: 0n, inputReflected: true, outputReflected: true, xorOut: 0n, check: 0xbb3dn },
|
||||
{ id: 'crc-16-xmodem', name: 'CRC-16 / XMODEM', width: 16, polynomial: 0x1021n, initialValue: 0n, inputReflected: false, outputReflected: false, xorOut: 0n, check: 0x31c3n },
|
||||
{ id: 'crc-8', name: 'CRC-8', width: 8, polynomial: 0x07n, initialValue: 0n, inputReflected: false, outputReflected: false, xorOut: 0n, check: 0xf4n },
|
||||
]
|
||||
export const DEFAULT_CRC_PRESET = {
|
||||
id: 'crc-32-iso-hdlc',
|
||||
name: 'CRC-32/ISO-HDLC',
|
||||
width: 32,
|
||||
polynomial: 0x04c11db7n,
|
||||
initialValue: 0xffffffffn,
|
||||
inputReflected: true,
|
||||
outputReflected: true,
|
||||
xorOut: 0xffffffffn,
|
||||
check: 0xcbf43926n,
|
||||
} as const satisfies CrcPreset
|
||||
|
||||
export const CRC_PRESETS = [
|
||||
DEFAULT_CRC_PRESET,
|
||||
{ id: 'crc-32-iscsi', name: 'CRC-32/ISCSI (CRC-32C)', width: 32, polynomial: 0x1edc6f41n, initialValue: 0xffffffffn, inputReflected: true, outputReflected: true, xorOut: 0xffffffffn, check: 0xe3069283n },
|
||||
{ id: 'crc-32-mpeg-2', name: 'CRC-32/MPEG-2', width: 32, polynomial: 0x04c11db7n, initialValue: 0xffffffffn, inputReflected: false, outputReflected: false, xorOut: 0n, check: 0x0376e6e7n },
|
||||
{ id: 'crc-16-arc', name: 'CRC-16/ARC', width: 16, polynomial: 0x8005n, initialValue: 0n, inputReflected: true, outputReflected: true, xorOut: 0n, check: 0xbb3dn },
|
||||
{ id: 'crc-16-dnp', name: 'CRC-16/DNP', width: 16, polynomial: 0x3d65n, initialValue: 0n, inputReflected: true, outputReflected: true, xorOut: 0xffffn, check: 0xea82n },
|
||||
{ id: 'crc-16-ccitt-false', name: 'CRC-16/IBM-3740 (CCITT-FALSE)', width: 16, polynomial: 0x1021n, initialValue: 0xffffn, inputReflected: false, outputReflected: false, xorOut: 0n, check: 0x29b1n },
|
||||
{ id: 'crc-16-ibm-sdlc', name: 'CRC-16/IBM-SDLC (X-25)', width: 16, polynomial: 0x1021n, initialValue: 0xffffn, inputReflected: true, outputReflected: true, xorOut: 0xffffn, check: 0x906en },
|
||||
{ id: 'crc-16-kermit', name: 'CRC-16/KERMIT (CCITT)', width: 16, polynomial: 0x1021n, initialValue: 0n, inputReflected: true, outputReflected: true, xorOut: 0n, check: 0x2189n },
|
||||
{ id: 'crc-16-modbus', name: 'CRC-16/MODBUS', width: 16, polynomial: 0x8005n, initialValue: 0xffffn, inputReflected: true, outputReflected: true, xorOut: 0n, check: 0x4b37n },
|
||||
{ id: 'crc-16-usb', name: 'CRC-16/USB', width: 16, polynomial: 0x8005n, initialValue: 0xffffn, inputReflected: true, outputReflected: true, xorOut: 0xffffn, check: 0xb4c8n },
|
||||
{ id: 'crc-16-xmodem', name: 'CRC-16/XMODEM', width: 16, polynomial: 0x1021n, initialValue: 0n, inputReflected: false, outputReflected: false, xorOut: 0n, check: 0x31c3n },
|
||||
{ id: 'crc-8', name: 'CRC-8/SMBUS (CRC-8)', width: 8, polynomial: 0x07n, initialValue: 0n, inputReflected: false, outputReflected: false, xorOut: 0n, check: 0xf4n },
|
||||
{ id: 'crc-8-maxim-dow', name: 'CRC-8/MAXIM-DOW', width: 8, polynomial: 0x31n, initialValue: 0n, inputReflected: true, outputReflected: true, xorOut: 0n, check: 0xa1n },
|
||||
{ id: 'crc-8-sae-j1850', name: 'CRC-8/SAE-J1850', width: 8, polynomial: 0x1dn, initialValue: 0xffn, inputReflected: false, outputReflected: false, xorOut: 0xffn, check: 0x4bn },
|
||||
{ id: 'crc-64-ecma-182', name: 'CRC-64/ECMA-182', width: 64, polynomial: 0x42f0e1eba9ea3693n, initialValue: 0n, inputReflected: false, outputReflected: false, xorOut: 0n, check: 0x6c40df5f0b497347n },
|
||||
{ id: 'crc-64-xz', name: 'CRC-64/XZ', width: 64, polynomial: 0x42f0e1eba9ea3693n, initialValue: 0xffffffffffffffffn, inputReflected: true, outputReflected: true, xorOut: 0xffffffffffffffffn, check: 0x995dc9bbdf1939fan },
|
||||
] as const satisfies readonly CrcPreset[]
|
||||
|
||||
function reflectBigInt(value: bigint, width: number): bigint {
|
||||
let reflected = 0n
|
||||
@@ -31,10 +53,17 @@ function reflectBigInt(value: bigint, width: number): bigint {
|
||||
}
|
||||
|
||||
function validateParameters(parameters: CrcParameters) {
|
||||
if (!parameters || typeof parameters !== 'object') throw new Error('CRC 参数无效')
|
||||
if (!Number.isInteger(parameters.width) || parameters.width < 8 || parameters.width > 64) {
|
||||
throw new Error('CRC 位宽必须是 8 至 64 的整数')
|
||||
}
|
||||
const mask = (1n << BigInt(parameters.width)) - 1n
|
||||
if (typeof parameters.polynomial !== 'bigint' || typeof parameters.initialValue !== 'bigint' || typeof parameters.xorOut !== 'bigint') {
|
||||
throw new Error('CRC 数值参数必须是 BigInt')
|
||||
}
|
||||
if (typeof parameters.inputReflected !== 'boolean' || typeof parameters.outputReflected !== 'boolean') {
|
||||
throw new Error('CRC 反转参数必须是布尔值')
|
||||
}
|
||||
if (parameters.polynomial <= 0n || parameters.polynomial > mask) throw new Error('CRC 多项式超出当前位宽')
|
||||
if (parameters.initialValue < 0n || parameters.initialValue > mask) throw new Error('CRC 初始值超出当前位宽')
|
||||
if (parameters.xorOut < 0n || parameters.xorOut > mask) throw new Error('CRC 异或输出值超出当前位宽')
|
||||
@@ -148,5 +177,8 @@ export function calculateCrc(data: Uint8Array, parameters: CrcParameters): bigin
|
||||
}
|
||||
|
||||
export function formatCrc(value: bigint, width: number): string {
|
||||
if (!Number.isInteger(width) || width < 8 || width > 64) throw new Error('CRC 位宽必须是 8 至 64 的整数')
|
||||
const mask = (1n << BigInt(width)) - 1n
|
||||
if (typeof value !== 'bigint' || value < 0n || value > mask) throw new Error('CRC 结果超出当前位宽')
|
||||
return value.toString(16).toUpperCase().padStart(Math.ceil(width / 4), '0')
|
||||
}
|
||||
|
||||
+49
-5
@@ -1,4 +1,8 @@
|
||||
import { calculateCrc, calculateCrcChunks, formatCrc, type CrcParameters } from '@/lib/crc'
|
||||
import { parseHex } from '@/lib/bytes'
|
||||
|
||||
export const MAX_INLINE_CRC_DATA_BYTES = 1024 * 1024
|
||||
export const MAX_INLINE_CRC_INPUT_CHARS = 6 * 1024 * 1024
|
||||
|
||||
export interface CrcWorkerBlock {
|
||||
startAddress: number
|
||||
@@ -17,7 +21,14 @@ export interface CrcWorkerCalculateRequest {
|
||||
parameters: CrcParameters
|
||||
}
|
||||
|
||||
export type CrcWorkerRequest = CrcWorkerParseRequest | CrcWorkerCalculateRequest
|
||||
export interface CrcWorkerCalculateHexRequest {
|
||||
type: 'calculate-hex'
|
||||
id: number
|
||||
input: string
|
||||
parameters: CrcParameters
|
||||
}
|
||||
|
||||
export type CrcWorkerRequest = CrcWorkerParseRequest | CrcWorkerCalculateRequest | CrcWorkerCalculateHexRequest
|
||||
|
||||
export interface CrcBlockResult {
|
||||
startAddress: number
|
||||
@@ -26,25 +37,58 @@ export interface CrcBlockResult {
|
||||
crc: string
|
||||
}
|
||||
|
||||
export type CrcWorkerResponse = {
|
||||
type CrcWorkerParsedResponse = {
|
||||
type: 'parsed'
|
||||
id: number
|
||||
format: 'Intel HEX' | 'BIN'
|
||||
totalBytes: number
|
||||
blockCount: number
|
||||
warnings: string[]
|
||||
} | {
|
||||
}
|
||||
|
||||
type CrcWorkerCalculatedResponse = {
|
||||
type: 'calculated'
|
||||
id: number
|
||||
combinedResult: string
|
||||
blockResults: CrcBlockResult[]
|
||||
} | {
|
||||
}
|
||||
|
||||
type CrcWorkerCalculatedHexResponse = {
|
||||
type: 'calculated-hex'
|
||||
id: number
|
||||
combinedResult: string
|
||||
totalBytes: number
|
||||
}
|
||||
|
||||
type CrcWorkerErrorResponse<TOperation extends CrcWorkerRequest['type']> = {
|
||||
type: 'error'
|
||||
id: number
|
||||
operation: CrcWorkerRequest['type']
|
||||
operation: TOperation
|
||||
error: string
|
||||
}
|
||||
|
||||
export type CrcFileWorkerResponse = CrcWorkerParsedResponse
|
||||
| CrcWorkerCalculatedResponse
|
||||
| CrcWorkerErrorResponse<'parse' | 'calculate'>
|
||||
|
||||
export type CrcHexWorkerResponse = CrcWorkerCalculatedHexResponse
|
||||
| CrcWorkerErrorResponse<'calculate-hex'>
|
||||
|
||||
export type CrcWorkerResponse = CrcFileWorkerResponse | CrcHexWorkerResponse
|
||||
|
||||
export function calculateCrcHexInput(input: string, parameters: CrcParameters) {
|
||||
const data = parseHex(input, {
|
||||
maxBytes: MAX_INLINE_CRC_DATA_BYTES,
|
||||
maxInputChars: MAX_INLINE_CRC_INPUT_CHARS,
|
||||
limitLabel: 'Hex 数据',
|
||||
})
|
||||
if (data.length === 0) throw new Error('请输入 Hex 数据')
|
||||
return {
|
||||
combinedResult: formatCrc(calculateCrc(data, parameters), parameters.width),
|
||||
totalBytes: data.length,
|
||||
}
|
||||
}
|
||||
|
||||
export function calculateCrcReport(blocks: CrcWorkerBlock[], parameters: CrcParameters) {
|
||||
const combinedValue = calculateCrcChunks(blocks.map((block) => block.data), parameters)
|
||||
const combinedResult = formatCrc(combinedValue, parameters.width)
|
||||
|
||||
+4
-2
@@ -1,6 +1,6 @@
|
||||
import { parseIntelHex, type DataBlock } from '@/lib/intelHex'
|
||||
import { DEFAULT_MAX_INTEL_HEX_BYTES, parseIntelHex, type DataBlock } from '@/lib/intelHex'
|
||||
|
||||
export const MAX_FIRMWARE_FILE_SIZE = 64 * 1024 * 1024
|
||||
export const MAX_FIRMWARE_FILE_SIZE = DEFAULT_MAX_INTEL_HEX_BYTES
|
||||
|
||||
export type FirmwareFormat = 'Intel HEX' | 'BIN'
|
||||
|
||||
@@ -22,10 +22,12 @@ export function parseFirmwareContent(fileName: string, content: string | Uint8Ar
|
||||
const format = getFirmwareFormat(fileName)
|
||||
if (format === 'Intel HEX') {
|
||||
if (typeof content !== 'string') throw new Error('Intel HEX 文件内容必须是文本')
|
||||
if (content.length > MAX_FIRMWARE_FILE_SIZE) throw new Error('Intel HEX 文件内容不能超过 64 MiB')
|
||||
return { format, ...parseIntelHex(content) }
|
||||
}
|
||||
if (typeof content === 'string') throw new Error('BIN 文件内容必须是二进制数据')
|
||||
if (content.length === 0) throw new Error('文件为空')
|
||||
if (content.length > MAX_FIRMWARE_FILE_SIZE) throw new Error('BIN 文件内容不能超过 64 MiB')
|
||||
return {
|
||||
format,
|
||||
blocks: [{ startAddress: 0, data: content }],
|
||||
|
||||
+24
-9
@@ -9,17 +9,19 @@ export interface ParsedIntelHex {
|
||||
warnings: string[]
|
||||
}
|
||||
|
||||
export interface IntelHexLimits {
|
||||
maxRecords?: number
|
||||
maxDataRecords?: number
|
||||
maxBlocks?: number
|
||||
}
|
||||
|
||||
export const UINT32_ADDRESS_SPACE_SIZE = 0x1_0000_0000
|
||||
export const MAX_UINT32_ADDRESS = UINT32_ADDRESS_SPACE_SIZE - 1
|
||||
export const DEFAULT_MAX_INTEL_HEX_RECORDS = 500_000
|
||||
export const DEFAULT_MAX_INTEL_HEX_DATA_RECORDS = 500_000
|
||||
export const DEFAULT_MAX_INTEL_HEX_BLOCKS = 1_024
|
||||
export const DEFAULT_MAX_INTEL_HEX_BYTES = 64 * 1024 * 1024
|
||||
|
||||
export interface IntelHexLimits {
|
||||
maxRecords?: number
|
||||
maxDataRecords?: number
|
||||
maxBlocks?: number
|
||||
maxBytes?: number
|
||||
}
|
||||
|
||||
interface DataRecord {
|
||||
startAddress: number
|
||||
@@ -63,9 +65,16 @@ function validateLimit(value: number, label: string): number {
|
||||
return value
|
||||
}
|
||||
|
||||
function mergeRecords(records: DataRecord[], maxBlocks: number): DataBlock[] {
|
||||
function formatByteLimit(bytes: number): string {
|
||||
return bytes >= 1024 * 1024
|
||||
? `${(bytes / 1024 / 1024).toLocaleString()} MiB`
|
||||
: `${bytes.toLocaleString()} bytes`
|
||||
}
|
||||
|
||||
function mergeRecords(records: DataRecord[], maxBlocks: number, maxBytes: number): DataBlock[] {
|
||||
const sorted = [...records].sort((left, right) => left.startAddress - right.startAddress || left.line - right.line)
|
||||
const groups: Array<{ startAddress: number; endAddress: number; firstRecord: number; lastRecord: number }> = []
|
||||
let allocatedBytes = 0
|
||||
|
||||
for (let index = 0; index < sorted.length; index += 1) {
|
||||
const record = sorted[index]
|
||||
@@ -73,10 +82,15 @@ function mergeRecords(records: DataRecord[], maxBlocks: number): DataBlock[] {
|
||||
const current = groups.at(-1)
|
||||
if (!current || record.startAddress > current.endAddress) {
|
||||
if (groups.length >= maxBlocks) throw new Error(`Intel HEX 数据块不能超过 ${maxBlocks.toLocaleString()} 个`)
|
||||
allocatedBytes += recordEnd - record.startAddress
|
||||
if (allocatedBytes > maxBytes) throw new Error(`Intel HEX 有效数据不能超过 ${formatByteLimit(maxBytes)}`)
|
||||
groups.push({ startAddress: record.startAddress, endAddress: recordEnd, firstRecord: index, lastRecord: index + 1 })
|
||||
continue
|
||||
}
|
||||
current.endAddress = Math.max(current.endAddress, recordEnd)
|
||||
const nextEnd = Math.max(current.endAddress, recordEnd)
|
||||
allocatedBytes += nextEnd - current.endAddress
|
||||
if (allocatedBytes > maxBytes) throw new Error(`Intel HEX 有效数据不能超过 ${formatByteLimit(maxBytes)}`)
|
||||
current.endAddress = nextEnd
|
||||
current.lastRecord = index + 1
|
||||
}
|
||||
|
||||
@@ -103,6 +117,7 @@ export function parseIntelHex(value: string, limits: IntelHexLimits = {}): Parse
|
||||
const maxRecords = validateLimit(limits.maxRecords ?? DEFAULT_MAX_INTEL_HEX_RECORDS, 'Intel HEX 记录上限')
|
||||
const maxDataRecords = validateLimit(limits.maxDataRecords ?? DEFAULT_MAX_INTEL_HEX_DATA_RECORDS, 'Intel HEX 数据记录上限')
|
||||
const maxBlocks = validateLimit(limits.maxBlocks ?? DEFAULT_MAX_INTEL_HEX_BLOCKS, 'Intel HEX 数据块上限')
|
||||
const maxBytes = validateLimit(limits.maxBytes ?? DEFAULT_MAX_INTEL_HEX_BYTES, 'Intel HEX 有效数据上限')
|
||||
const lines = value.replace(/^\uFEFF/, '').split(/\r?\n/)
|
||||
const records: DataRecord[] = []
|
||||
const warnings: string[] = []
|
||||
@@ -148,7 +163,7 @@ export function parseIntelHex(value: string, limits: IntelHexLimits = {}): Parse
|
||||
|
||||
if (!records.length) throw new Error('Intel HEX 文件不包含有效数据记录')
|
||||
if (!eofSeen) warnings.push('文件缺少 EOF 记录,已按现有数据计算')
|
||||
const blocks = mergeRecords(records, maxBlocks)
|
||||
const blocks = mergeRecords(records, maxBlocks, maxBytes)
|
||||
return { blocks, totalBytes: blocks.reduce((total, block) => total + block.data.length, 0), warnings }
|
||||
}
|
||||
|
||||
|
||||
+8
-2
@@ -2,11 +2,17 @@ export interface RandomSource {
|
||||
getRandomValues<T extends ArrayBufferView>(array: T): T
|
||||
}
|
||||
|
||||
export const DEFAULT_RANDOM_BYTES = 16
|
||||
export const MIN_RANDOM_BYTES = 1
|
||||
export const MAX_RANDOM_BYTES = 65_536
|
||||
|
||||
export function secureRandomBytes(length: number, source: RandomSource = globalThis.crypto): Uint8Array {
|
||||
if (!Number.isInteger(length) || length < 1 || length > 65_536) throw new Error('随机数长度必须是 1 至 65536 字节的整数')
|
||||
if (!Number.isInteger(length) || length < MIN_RANDOM_BYTES || length > MAX_RANDOM_BYTES) {
|
||||
throw new Error(`随机数长度必须是 ${MIN_RANDOM_BYTES} 至 ${MAX_RANDOM_BYTES} 字节的整数`)
|
||||
}
|
||||
if (!source?.getRandomValues) throw new Error('当前浏览器不支持安全随机数生成')
|
||||
const output = new Uint8Array(length)
|
||||
const chunkSize = 65_536
|
||||
const chunkSize = MAX_RANDOM_BYTES
|
||||
for (let offset = 0; offset < output.length; offset += chunkSize) {
|
||||
source.getRandomValues(output.subarray(offset, Math.min(offset + chunkSize, output.length)))
|
||||
}
|
||||
|
||||
+2
-3
@@ -1,5 +1,3 @@
|
||||
import forge from 'node-forge'
|
||||
|
||||
export interface GeneratedRsaKeyPair {
|
||||
publicKeyPem: string
|
||||
privateKeyPem: string
|
||||
@@ -64,7 +62,8 @@ function nativeRandomSeed(length: number): string {
|
||||
return Array.from(bytes, (byte) => String.fromCharCode(byte)).join('')
|
||||
}
|
||||
|
||||
function generateWithForge(modulusLength: number): Promise<GeneratedRsaKeyPair> {
|
||||
async function generateWithForge(modulusLength: number): Promise<GeneratedRsaKeyPair> {
|
||||
const { default: forge } = await import('node-forge')
|
||||
const prng = forge.random.createInstance()
|
||||
prng.seedFileSync = (needed) => nativeRandomSeed(needed)
|
||||
prng.seedFile = (needed, callback) => callback(null, nativeRandomSeed(needed))
|
||||
|
||||
@@ -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<string> {
|
||||
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<ParsedRsaPublicKey> {
|
||||
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<ParsedRsaPublicKey> {
|
||||
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<typeof certificateKey>, '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<ParsedRsaPublicKey> {
|
||||
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<boolean> {
|
||||
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<RsaVerificationResult> {
|
||||
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 }
|
||||
}
|
||||
@@ -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<ParsedSignatureFile> {
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
export const XML_PARSER_ERROR_NAMESPACES = [
|
||||
'http://www.mozilla.org/newlayout/xml/parsererror.xml',
|
||||
'http://www.w3.org/1999/xhtml',
|
||||
] as const
|
||||
|
||||
interface XmlParserErrorDocument {
|
||||
getElementsByTagName(localName: string): ArrayLike<{
|
||||
localName: string | null
|
||||
namespaceURI: string | null
|
||||
textContent: string | null
|
||||
}>
|
||||
}
|
||||
|
||||
interface XmlParser {
|
||||
parseFromString(value: string, type: DOMParserSupportedType): XmlParserErrorDocument
|
||||
}
|
||||
|
||||
export function validateXmlDocument(value: string, parser: XmlParser = new DOMParser()): void {
|
||||
if (!value.trim()) throw new Error('请输入 XML 内容')
|
||||
const document = parser.parseFromString(value, 'application/xml')
|
||||
const parserError = Array.from(document.getElementsByTagName('parsererror')).find((candidate) => XML_PARSER_ERROR_NAMESPACES.includes(candidate.namespaceURI as typeof XML_PARSER_ERROR_NAMESPACES[number]))
|
||||
if (parserError) throw new Error(parserError.textContent?.split('\n')[0] || 'XML 格式无效')
|
||||
}
|
||||
+4
-4
@@ -15,20 +15,20 @@ const toolRoutes: RouteRecordRaw[] = tools.map((tool) => ({
|
||||
export const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{ path: '/', redirect: '/tools/ascii-hex' },
|
||||
{ path: '/', redirect: '/tools/utf8-hex' },
|
||||
...toolRoutes,
|
||||
{
|
||||
path: '/setting',
|
||||
path: '/tools/setting',
|
||||
name: 'setting',
|
||||
component: () => import('@/views/SettingView.vue'),
|
||||
meta: { title: '工具管理' },
|
||||
},
|
||||
{ path: '/:pathMatch(.*)*', redirect: '/tools/ascii-hex' },
|
||||
{ path: '/:pathMatch(.*)*', redirect: '/tools/utf8-hex' },
|
||||
],
|
||||
scrollBehavior: () => ({ top: 0 }),
|
||||
})
|
||||
|
||||
router.beforeEach((to) => {
|
||||
if (to.meta.toolId && !toolById.has(String(to.meta.toolId))) return '/tools/ascii-hex'
|
||||
if (to.meta.toolId && !toolById.has(String(to.meta.toolId))) return '/tools/utf8-hex'
|
||||
return true
|
||||
})
|
||||
|
||||
+82
-22
@@ -79,6 +79,17 @@ button, input, select, textarea { font: inherit; letter-spacing: 0; }
|
||||
button, a, input, select, textarea { outline-color: var(--accent); }
|
||||
button { color: inherit; }
|
||||
|
||||
button:focus-visible, a:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
button:disabled, input:disabled, select:disabled, textarea:disabled,
|
||||
button[aria-disabled='true'], input[aria-disabled='true'], select[aria-disabled='true'], textarea[aria-disabled='true'] {
|
||||
cursor: not-allowed;
|
||||
opacity: .55;
|
||||
}
|
||||
|
||||
.app-layout { height: 100dvh; display: flex; overflow: hidden; background: var(--canvas); }
|
||||
.app-sidebar { width: 236px; flex: 0 0 auto; display: flex; flex-direction: column; background: var(--sidebar); border-right: 1px solid var(--border); z-index: 30; }
|
||||
.brand-row { min-height: 66px; padding: 0 18px; display: flex; align-items: center; gap: 11px; border-bottom: 1px solid var(--border); }
|
||||
@@ -97,7 +108,6 @@ button { color: inherit; }
|
||||
.tool-navigation a.is-active { color: var(--text); background: var(--surface); border-color: var(--border); box-shadow: 0 1px 2px rgba(20, 24, 28, .04); }
|
||||
.tool-navigation a.is-active::before { position: absolute; left: -1px; width: 3px; height: 18px; content: ''; background: var(--accent); border-radius: 0 2px 2px 0; }
|
||||
.tool-navigation a span { min-width: 0; overflow-wrap: anywhere; font-size: 13px; }
|
||||
.tool-navigation a small { margin-left: auto; color: var(--faint); font-size: 10px; font-variant-numeric: tabular-nums; }
|
||||
.empty-search { padding: 20px 10px; color: var(--muted); font-size: 12px; }
|
||||
.sidebar-local { min-height: 48px; margin-top: auto; padding: 0 18px; display: flex; align-items: center; gap: 7px; color: var(--muted); border-top: 1px solid var(--border); font-size: 11px; }
|
||||
|
||||
@@ -121,9 +131,8 @@ button { color: inherit; }
|
||||
|
||||
.mode-tabs { width: fit-content; min-height: 36px; padding: 3px; display: flex; align-items: center; gap: 2px; background: var(--panel); border: 1px solid var(--border); border-radius: 7px; }
|
||||
.mode-tab { min-height: 28px; padding: 0 10px; color: var(--muted); background: transparent; border: 0; border-radius: 5px; cursor: pointer; font-size: 12px; }
|
||||
.mode-tab:hover { color: var(--text); }
|
||||
.mode-tab:hover:not(:disabled) { color: var(--text); }
|
||||
.mode-tab.is-active { color: var(--text); background: var(--surface); box-shadow: 0 0 0 1px var(--border); }
|
||||
.mode-tab:disabled { cursor: not-allowed; opacity: .55; }
|
||||
.tool-controls { margin-bottom: 14px; display: flex; align-items: center; flex-wrap: wrap; gap: 10px; }
|
||||
.control-spacer, .action-spacer { flex: 1; }
|
||||
.compact-field { display: inline-flex; align-items: center; gap: 8px; color: var(--muted); font-size: 12px; }
|
||||
@@ -132,6 +141,7 @@ button { color: inherit; }
|
||||
.text-input { width: 100%; padding: 0 10px; }
|
||||
.text-input:focus, .select-input:focus, .compact-field select:focus { border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 13%, transparent); }
|
||||
.check-field { display: inline-flex; align-items: center; gap: 7px; color: var(--muted); cursor: pointer; font-size: 12px; }
|
||||
.check-field:has(input:disabled) { color: var(--faint); cursor: not-allowed; }
|
||||
.check-field input { width: 16px; height: 16px; margin: 0; accent-color: var(--accent); }
|
||||
|
||||
.editor-grid { display: grid; grid-template-columns: minmax(0, 1fr) 38px minmax(0, 1fr); gap: 13px; align-items: center; }
|
||||
@@ -141,6 +151,8 @@ button { color: inherit; }
|
||||
.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; }
|
||||
@@ -155,18 +167,17 @@ button { color: inherit; }
|
||||
.syntax-comment { color: var(--syntax-comment); font-style: italic; }
|
||||
.syntax-meta { color: var(--syntax-meta); }
|
||||
.swap-button { width: 32px; height: 32px; padding: 0; display: grid; place-items: center; justify-self: center; color: var(--muted); background: var(--surface); border: 1px solid var(--border-strong); border-radius: 50%; cursor: pointer; }
|
||||
.swap-button:hover { color: var(--accent); border-color: var(--accent); }
|
||||
.swap-button:disabled { cursor: not-allowed; opacity: .45; }
|
||||
.swap-button:hover:not(:disabled) { color: var(--accent); border-color: var(--accent); }
|
||||
|
||||
.action-row { min-height: 36px; margin-top: 14px; display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
|
||||
.action-button { min-height: 34px; padding: 0 12px; display: inline-flex; align-items: center; justify-content: center; gap: 7px; color: var(--text); background: var(--surface); border: 1px solid var(--border-strong); border-radius: 6px; cursor: pointer; font-size: 12px; font-weight: 600; }
|
||||
.action-button:hover { border-color: var(--text); }
|
||||
.action-button:hover:not(:disabled):not(.disabled) { border-color: var(--text); }
|
||||
.action-button.primary { color: var(--primary-text); background: var(--primary); border-color: var(--primary); }
|
||||
.action-button.primary:hover { background: var(--primary-hover); border-color: var(--primary-hover); }
|
||||
.action-button.primary:hover:not(:disabled):not(.disabled) { background: var(--primary-hover); border-color: var(--primary-hover); }
|
||||
.action-button.ghost { color: var(--muted); background: transparent; border-color: transparent; }
|
||||
.action-button.ghost:hover { color: var(--text); background: var(--panel); border-color: var(--border); }
|
||||
.action-button.ghost:hover:not(:disabled):not(.disabled) { color: var(--text); background: var(--panel); border-color: var(--border); }
|
||||
.action-button.danger { color: var(--danger); }
|
||||
.action-button:disabled, .action-button.disabled { cursor: not-allowed; opacity: .5; }
|
||||
.action-button.disabled { cursor: not-allowed; opacity: .55; }
|
||||
.privacy-note { min-height: 42px; margin-top: 18px; padding: 9px 13px; display: flex; align-items: center; gap: 9px; color: var(--muted); background: var(--info-soft); border-radius: 6px; font-size: 11px; }
|
||||
.privacy-note strong { color: var(--text); }
|
||||
.status-message { min-height: 38px; margin-top: 12px; padding: 9px 11px; display: flex; align-items: flex-start; gap: 8px; border-radius: 6px; font-size: 12px; }
|
||||
@@ -187,8 +198,8 @@ button { color: inherit; }
|
||||
.single-editor-layout { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 14px; }
|
||||
.single-editor-layout .editor-panel textarea { min-height: 290px; }
|
||||
|
||||
.upload-zone { min-height: 170px; padding: 24px; display: grid; place-items: center; text-align: center; color: var(--muted); background: var(--panel); border: 1px dashed var(--border-strong); border-radius: 7px; cursor: pointer; }
|
||||
.upload-zone:hover, .upload-zone.is-dragging { color: var(--text); border-color: var(--accent); background: var(--accent-soft); }
|
||||
.upload-zone { position: relative; min-height: 170px; padding: 24px; display: grid; place-items: center; overflow: hidden; text-align: center; color: var(--muted); background-color: var(--panel); background-image: none; border: 1px dashed var(--border-strong); border-radius: 7px; cursor: pointer; }
|
||||
.upload-zone:hover, .upload-zone:focus-within, .upload-zone.is-dragging { color: var(--text); background-color: var(--accent-soft); background-image: none; border-color: var(--accent); }
|
||||
.upload-zone input { position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; }
|
||||
.sr-only-file { position: absolute; width: 1px; height: 1px; overflow: hidden; opacity: 0; pointer-events: none; }
|
||||
.upload-zone strong { display: block; margin-top: 8px; color: var(--text); font-size: 13px; }
|
||||
@@ -202,15 +213,26 @@ button { color: inherit; }
|
||||
.size-option:has(input:checked) { color: var(--text); border-color: var(--accent); background: var(--accent-soft); }
|
||||
.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; }
|
||||
.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; }
|
||||
.crc-config { display: grid; grid-template-columns: minmax(190px, 1.35fr) repeat(4, minmax(110px, 1fr)) auto auto; gap: 10px; align-items: end; }
|
||||
.compact-upload { min-height: 138px; margin-bottom: 16px; }
|
||||
.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-source-controls { margin-bottom: 12px; }
|
||||
.crc-hex-editor { margin-bottom: 16px; }
|
||||
.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; }
|
||||
.crc-config .parameter-field > span { white-space: nowrap; }
|
||||
.crc-reflection-options { grid-column: 1 / -1; min-height: 34px; display: flex; align-items: center; flex-wrap: wrap; column-gap: 22px; row-gap: 8px; }
|
||||
.crc-check { min-height: 34px; white-space: nowrap; }
|
||||
.crc-results { margin-top: 16px; }
|
||||
.crc-total { min-height: 58px; padding: 10px 13px; display: flex; align-items: center; gap: 14px; color: var(--muted); background: var(--accent-soft); border: 1px solid color-mix(in srgb, var(--accent) 24%, var(--border)); border-radius: 7px; font-size: 11px; }
|
||||
@@ -250,6 +272,37 @@ button { color: inherit; }
|
||||
.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; }
|
||||
|
||||
@@ -272,12 +325,13 @@ button { color: inherit; }
|
||||
.row-actions { display: flex; gap: 3px; }
|
||||
.row-action { width: 30px; height: 30px; padding: 0; display: grid; place-items: center; color: var(--muted); background: transparent; border: 1px solid transparent; border-radius: 5px; cursor: pointer; }
|
||||
.row-action:hover:not(:disabled) { color: var(--text); background: var(--panel); border-color: var(--border); }
|
||||
.row-action:disabled { opacity: .3; cursor: default; }
|
||||
.row-action:disabled { opacity: .55; cursor: not-allowed; }
|
||||
.save-state { margin-top: 14px; }
|
||||
|
||||
@media (max-width: 1040px) {
|
||||
.crc-config { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.crc-config .preset-field { grid-column: 1 / -1; }
|
||||
.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) {
|
||||
@@ -295,6 +349,7 @@ button { color: inherit; }
|
||||
.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) {
|
||||
@@ -317,8 +372,9 @@ button { color: inherit; }
|
||||
.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; }
|
||||
@@ -327,6 +383,10 @@ button { color: inherit; }
|
||||
.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) {
|
||||
|
||||
+26
-12
@@ -6,10 +6,11 @@ import TextEditor from '@/components/TextEditor.vue'
|
||||
import ToolPage from '@/components/ToolPage.vue'
|
||||
import { copyText } from '@/lib/browser'
|
||||
import { bytesToHex, parseHex, textToBytes } from '@/lib/bytes'
|
||||
import { aes128Cmac } from '@/lib/cmac'
|
||||
import { aesCmac, type AesKeyLength } from '@/lib/cmac'
|
||||
|
||||
type MessageMode = 'hex' | 'text'
|
||||
const keyInput = ref('')
|
||||
const keyLength = ref<AesKeyLength>(16)
|
||||
const messageMode = ref<MessageMode>('hex')
|
||||
const message = ref('')
|
||||
const output = ref('')
|
||||
@@ -17,12 +18,18 @@ const uppercase = ref(true)
|
||||
const error = ref('')
|
||||
const success = ref('')
|
||||
const messageModes = [{ value: 'hex' as const, label: 'Hex 消息' }, { value: 'text' as const, label: 'UTF-8 文本' }]
|
||||
const keyLengthOptions: Array<{ value: AesKeyLength, label: string }> = [
|
||||
{ value: 16, label: '128 位(AES-128)' },
|
||||
{ value: 24, label: '192 位(AES-192)' },
|
||||
{ value: 32, label: '256 位(AES-256)' },
|
||||
]
|
||||
const keyLengthBits = computed(() => keyLength.value * 8)
|
||||
|
||||
const keyState = computed(() => {
|
||||
if (!keyInput.value.trim()) return { bytes: 0, valid: false }
|
||||
try {
|
||||
const bytes = parseHex(keyInput.value).length
|
||||
return { bytes, valid: bytes === 16 }
|
||||
return { bytes, valid: bytes === keyLength.value }
|
||||
}
|
||||
catch { return { bytes: null, valid: false } }
|
||||
})
|
||||
@@ -33,15 +40,15 @@ function clearResult() {
|
||||
success.value = ''
|
||||
}
|
||||
|
||||
watch([keyInput, message, messageMode, uppercase], clearResult)
|
||||
watch([keyInput, keyLength, message, messageMode, uppercase], clearResult)
|
||||
|
||||
function calculate() {
|
||||
error.value = ''
|
||||
success.value = ''
|
||||
try {
|
||||
const key = parseHex(keyInput.value, 16)
|
||||
const key = parseHex(keyInput.value, keyLength.value)
|
||||
const data = messageMode.value === 'hex' ? parseHex(message.value) : textToBytes(message.value)
|
||||
output.value = bytesToHex(aes128Cmac(key, data), '', uppercase.value)
|
||||
output.value = bytesToHex(aesCmac(key, data), '', uppercase.value)
|
||||
success.value = 'CMAC 计算完成'
|
||||
} catch (reason) {
|
||||
output.value = ''
|
||||
@@ -57,24 +64,31 @@ function clearAll() { keyInput.value = ''; message.value = ''; clearResult() }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ToolPage category="加密计算" title="AES-128-CMAC" description="使用 128 位密钥计算消息认证码,结果按 RFC 4493 规则生成。">
|
||||
<ToolPage category="加密计算" title="AES-CMAC" description="使用 128、192 或 256 位 AES 密钥计算 128 位消息认证码。">
|
||||
<div class="tool-controls">
|
||||
<label class="parameter-field"><span>密钥长度(Key Length)</span>
|
||||
<select id="aes-cmac-key-length" name="aes-cmac-key-length" v-model.number="keyLength" class="select-input">
|
||||
<option v-for="option in keyLengthOptions" :key="option.value" :value="option.value">{{ option.label }}</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="field-stack">
|
||||
<label for="cmac-key">128 位密钥</label>
|
||||
<textarea id="cmac-key" v-model="keyInput" class="key-input" rows="2" spellcheck="false" placeholder="例如:0x00 0x11 0x22 … 或 00112233445566778899AABBCCDDEEFF" />
|
||||
<label for="cmac-key">{{ keyLengthBits }} 位密钥</label>
|
||||
<textarea id="cmac-key" name="aes-cmac-key" v-model="keyInput" class="key-input" rows="2" spellcheck="false" placeholder="例如:0x00 0x11 0x22 … 或连续十六进制字节" />
|
||||
<div class="field-help">
|
||||
支持连续 Hex、空格、换行、英文/中文逗号及每字节可选的 0x 前缀。
|
||||
<span class="byte-count" :class="keyState.valid ? 'is-valid' : 'is-invalid'">{{ keyState.bytes === null ? '格式无效' : `${keyState.bytes}/16 字节` }}</span>
|
||||
<span class="byte-count" :class="keyState.valid ? 'is-valid' : 'is-invalid'">{{ keyState.bytes === null ? '格式无效' : `${keyState.bytes}/${keyLength} 字节` }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tool-controls">
|
||||
<ModeTabs v-model="messageMode" :options="messageModes" label="消息格式" />
|
||||
<span class="control-spacer" />
|
||||
<label class="check-field"><input v-model="uppercase" type="checkbox" />大写结果</label>
|
||||
<label class="check-field"><input id="aes-cmac-uppercase" name="aes-cmac-uppercase" v-model="uppercase" type="checkbox" />大写结果</label>
|
||||
</div>
|
||||
<div class="editor-grid">
|
||||
<TextEditor v-model="message" label="消息" :format="messageMode === 'hex' ? 'Hex 字节' : 'UTF-8 文本'" :meta="`${message.length} 字符`" placeholder="输入待认证消息;空消息也可计算" />
|
||||
<TextEditor id="aes-cmac-message" v-model="message" label="消息" :format="messageMode === 'hex' ? 'Hex 字节' : 'UTF-8 文本'" :meta="`${message.length} 字符`" placeholder="输入待认证消息;空消息也可计算" />
|
||||
<span />
|
||||
<TextEditor v-model="output" label="CMAC 结果" format="128 位 Hex" :meta="output ? '16 字节' : ''" readonly />
|
||||
<TextEditor id="aes-cmac-output" v-model="output" label="CMAC 结果" format="128 位 Hex" :meta="output ? '16 字节' : ''" readonly />
|
||||
</div>
|
||||
<div class="action-row">
|
||||
<button class="action-button primary" type="button" @click="calculate"><Play :size="15" />计算 CMAC</button>
|
||||
|
||||
@@ -103,15 +103,15 @@ function clearAll() { input.value = ''; clearConversion() }
|
||||
<div class="tool-controls">
|
||||
<ModeTabs v-model="mode" :options="modes" label="转换模式" />
|
||||
<span class="control-spacer" />
|
||||
<label class="check-field"><input v-model="urlSafe" type="checkbox" />URL 安全模式</label>
|
||||
<label class="check-field"><input id="base64-url-safe" name="base64-url-safe" v-model="urlSafe" type="checkbox" />URL 安全模式</label>
|
||||
</div>
|
||||
<div v-if="mode === 'decode'" class="tool-controls base64-output-controls">
|
||||
<ModeTabs v-model="decodeOutputMode" :options="decodeOutputModes" label="解码输出格式" />
|
||||
</div>
|
||||
<div class="editor-grid">
|
||||
<TextEditor v-model="input" label="输入" :format="mode === 'encode' ? 'UTF-8 文本' : 'Base64'" :meta="inputMeta" placeholder="输入待转换内容" />
|
||||
<TextEditor id="base64-input" v-model="input" label="输入" :format="mode === 'encode' ? 'UTF-8 文本' : 'Base64'" :meta="inputMeta" placeholder="输入待转换内容" />
|
||||
<button class="swap-button" type="button" aria-label="交换输入输出" :disabled="mode === 'decode' && decodeOutputMode !== 'text'" :title="mode === 'decode' && decodeOutputMode !== 'text' ? '仅 UTF-8 文本结果可交换' : ''" @click="swap"><ArrowLeftRight :size="16" /></button>
|
||||
<TextEditor v-model="output" label="结果" :format="outputFormat" :meta="outputMeta" readonly />
|
||||
<TextEditor id="base64-output" v-model="output" label="结果" :format="outputFormat" :meta="outputMeta" readonly />
|
||||
</div>
|
||||
<div class="action-row">
|
||||
<button class="action-button primary" type="button" @click="convert"><Play :size="15" />转换</button>
|
||||
|
||||
+223
-140
@@ -1,12 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
import { AlertCircle, Calculator, CheckCircle2, Copy, FileUp, Trash2, Upload } from '@lucide/vue'
|
||||
import { computed, onBeforeUnmount, ref, shallowRef } from 'vue'
|
||||
import { AlertCircle, Calculator, CheckCircle2, Copy, FileUp, Trash2 } from '@lucide/vue'
|
||||
import { computed, onBeforeUnmount, ref, shallowRef, watch } from 'vue'
|
||||
import FileDropField from '@/components/FileDropField.vue'
|
||||
import ModeTabs from '@/components/ModeTabs.vue'
|
||||
import TextEditor from '@/components/TextEditor.vue'
|
||||
import ToolPage from '@/components/ToolPage.vue'
|
||||
import { useTransactionalFileWorker } from '@/composables/useTransactionalFileWorker'
|
||||
import { copyText } from '@/lib/browser'
|
||||
import { CRC_PRESETS, type CrcParameters } from '@/lib/crc'
|
||||
import type { CrcBlockResult, CrcWorkerRequest, CrcWorkerResponse } from '@/lib/crcWorker'
|
||||
import { CRC_PRESETS, DEFAULT_CRC_PRESET, type CrcParameters } from '@/lib/crc'
|
||||
import {
|
||||
MAX_INLINE_CRC_INPUT_CHARS,
|
||||
type CrcBlockResult,
|
||||
type CrcFileWorkerResponse,
|
||||
type CrcHexWorkerResponse,
|
||||
type CrcWorkerCalculateHexRequest,
|
||||
type CrcWorkerCalculateRequest,
|
||||
} from '@/lib/crcWorker'
|
||||
import { MAX_FIRMWARE_FILE_SIZE, type FirmwareFormat } from '@/lib/firmware'
|
||||
|
||||
type CrcInputMode = 'hex' | 'file'
|
||||
|
||||
interface FirmwareData {
|
||||
fileName: string
|
||||
fileSize: number
|
||||
@@ -16,27 +29,31 @@ interface FirmwareData {
|
||||
warnings: string[]
|
||||
}
|
||||
|
||||
const defaultPreset = CRC_PRESETS[0]
|
||||
const fileInput = ref<HTMLInputElement>()
|
||||
const presetWidthOrder = [32, 16, 8, 64] as const
|
||||
const presetGroups = presetWidthOrder.map((presetWidth) => ({
|
||||
label: `CRC-${presetWidth}`,
|
||||
presets: CRC_PRESETS.filter((preset) => preset.width === presetWidth),
|
||||
}))
|
||||
const inputMode = ref<CrcInputMode>('file')
|
||||
const inputModeOptions = [
|
||||
{ value: 'hex' as const, label: 'Hex 输入' },
|
||||
{ value: 'file' as const, label: '文件' },
|
||||
]
|
||||
const hexInput = ref('')
|
||||
const hexResultBytes = ref<number>()
|
||||
const firmware = shallowRef<FirmwareData>()
|
||||
const dragging = ref(false)
|
||||
const selectedPreset = ref(defaultPreset.id)
|
||||
const width = ref(defaultPreset.width)
|
||||
const polynomial = ref(defaultPreset.polynomial.toString(16).toUpperCase())
|
||||
const initialValue = ref(defaultPreset.initialValue.toString(16).toUpperCase())
|
||||
const xorOut = ref(defaultPreset.xorOut.toString(16).toUpperCase())
|
||||
const inputReflected = ref(defaultPreset.inputReflected)
|
||||
const outputReflected = ref(defaultPreset.outputReflected)
|
||||
const selectedPreset = ref<string>(DEFAULT_CRC_PRESET.id)
|
||||
const width = ref<number>(DEFAULT_CRC_PRESET.width)
|
||||
const polynomial = ref(DEFAULT_CRC_PRESET.polynomial.toString(16).toUpperCase())
|
||||
const initialValue = ref(DEFAULT_CRC_PRESET.initialValue.toString(16).toUpperCase())
|
||||
const xorOut = ref(DEFAULT_CRC_PRESET.xorOut.toString(16).toUpperCase())
|
||||
const inputReflected = ref<boolean>(DEFAULT_CRC_PRESET.inputReflected)
|
||||
const outputReflected = ref<boolean>(DEFAULT_CRC_PRESET.outputReflected)
|
||||
const combinedResult = ref('')
|
||||
const blockResults = ref<CrcBlockResult[]>([])
|
||||
const parsing = ref(false)
|
||||
const calculating = ref(false)
|
||||
const error = ref('')
|
||||
const success = ref('')
|
||||
let worker: Worker | undefined
|
||||
let requestSequence = 0
|
||||
let activeParseRequest: number | undefined
|
||||
let activeCalculationRequest: number | undefined
|
||||
const inlineCalculating = ref(false)
|
||||
|
||||
const activeParameterName = computed(() => CRC_PRESETS.find((preset) => preset.id === selectedPreset.value)?.name ?? '自定义参数')
|
||||
|
||||
@@ -70,135 +87,160 @@ function parseParameter(value: string, label: string): bigint {
|
||||
function getParameters(): CrcParameters {
|
||||
return {
|
||||
width: width.value,
|
||||
polynomial: parseParameter(polynomial.value, 'Poly'),
|
||||
initialValue: parseParameter(initialValue.value, 'Init'),
|
||||
polynomial: parseParameter(polynomial.value, '多项式(Poly)'),
|
||||
initialValue: parseParameter(initialValue.value, '初始值(Init)'),
|
||||
inputReflected: inputReflected.value,
|
||||
outputReflected: outputReflected.value,
|
||||
xorOut: parseParameter(xorOut.value, 'XorOut'),
|
||||
xorOut: parseParameter(xorOut.value, '异或输出(XorOut)'),
|
||||
}
|
||||
}
|
||||
|
||||
let inlineWorker: Worker | undefined
|
||||
let inlineRequestSequence = 0
|
||||
let activeInlineRequest: number | undefined
|
||||
|
||||
function destroyInlineWorker() {
|
||||
inlineWorker?.terminate()
|
||||
inlineWorker = undefined
|
||||
activeInlineRequest = undefined
|
||||
inlineCalculating.value = false
|
||||
}
|
||||
|
||||
function clearResults() {
|
||||
invalidateCalculation()
|
||||
destroyInlineWorker()
|
||||
combinedResult.value = ''
|
||||
blockResults.value = []
|
||||
hexResultBytes.value = undefined
|
||||
success.value = ''
|
||||
}
|
||||
|
||||
function invalidateResults() {
|
||||
activeCalculationRequest = undefined
|
||||
calculating.value = false
|
||||
clearResults()
|
||||
error.value = ''
|
||||
}
|
||||
|
||||
function destroyWorker() {
|
||||
worker?.terminate()
|
||||
worker = undefined
|
||||
activeParseRequest = undefined
|
||||
activeCalculationRequest = undefined
|
||||
parsing.value = false
|
||||
calculating.value = false
|
||||
}
|
||||
|
||||
function createWorker(): Worker {
|
||||
const nextWorker = new Worker(new URL('../workers/crc.worker.ts', import.meta.url), { type: 'module' })
|
||||
nextWorker.onmessage = (event: MessageEvent<CrcWorkerResponse>) => {
|
||||
if (nextWorker !== worker) return
|
||||
const response = event.data
|
||||
if (response.type === 'parsed') {
|
||||
if (response.id !== activeParseRequest) return
|
||||
activeParseRequest = undefined
|
||||
parsing.value = false
|
||||
firmware.value = {
|
||||
fileName: pendingFileName,
|
||||
fileSize: pendingFileSize,
|
||||
format: response.format,
|
||||
totalBytes: response.totalBytes,
|
||||
blockCount: response.blockCount,
|
||||
warnings: response.warnings,
|
||||
}
|
||||
success.value = `已解析 ${response.blockCount} 个数据块`
|
||||
return
|
||||
const {
|
||||
parsing,
|
||||
calculating,
|
||||
parseFile,
|
||||
calculate: calculateInWorker,
|
||||
invalidateCalculation,
|
||||
destroyWorkers,
|
||||
} = useTransactionalFileWorker<Omit<CrcWorkerCalculateRequest, 'type' | 'id'>, CrcFileWorkerResponse>({
|
||||
createWorker: () => new Worker(new URL('../workers/crc.worker.ts', import.meta.url), { type: 'module' }),
|
||||
onParsed: (response, selected) => {
|
||||
clearResults()
|
||||
firmware.value = {
|
||||
fileName: selected.name,
|
||||
fileSize: selected.size,
|
||||
format: response.format,
|
||||
totalBytes: response.totalBytes,
|
||||
blockCount: response.blockCount,
|
||||
warnings: response.warnings,
|
||||
}
|
||||
if (response.type === 'calculated') {
|
||||
if (response.id !== activeCalculationRequest) return
|
||||
activeCalculationRequest = undefined
|
||||
calculating.value = false
|
||||
combinedResult.value = response.combinedResult
|
||||
blockResults.value = response.blockResults
|
||||
success.value = `${activeCalculationName} 计算完成`
|
||||
return
|
||||
}
|
||||
if (response.operation === 'parse' && response.id === activeParseRequest) {
|
||||
activeParseRequest = undefined
|
||||
parsing.value = false
|
||||
firmware.value = undefined
|
||||
error.value = response.error
|
||||
} else if (response.operation === 'calculate' && response.id === activeCalculationRequest) {
|
||||
activeCalculationRequest = undefined
|
||||
calculating.value = false
|
||||
error.value = response.error
|
||||
}
|
||||
}
|
||||
nextWorker.onerror = () => {
|
||||
if (nextWorker !== worker) return
|
||||
destroyWorker()
|
||||
success.value = `已解析 ${response.blockCount} 个数据块`
|
||||
},
|
||||
onCalculated: (response) => {
|
||||
combinedResult.value = response.combinedResult
|
||||
blockResults.value = response.blockResults
|
||||
success.value = `${activeCalculationName} 计算完成`
|
||||
},
|
||||
onParseError: (message) => { error.value = message },
|
||||
onCalculationError: (message) => { error.value = message },
|
||||
onPendingWorkerError: (hasCurrentFile) => {
|
||||
error.value = hasCurrentFile
|
||||
? '新文件解析失败,当前文件未更改'
|
||||
: 'CRC 后台线程异常终止,请重新选择文件'
|
||||
},
|
||||
onActiveWorkerError: () => {
|
||||
firmware.value = undefined
|
||||
clearResults()
|
||||
error.value = 'CRC 后台线程异常终止,请重新选择文件'
|
||||
}
|
||||
worker = nextWorker
|
||||
return nextWorker
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
let pendingFileName = ''
|
||||
let pendingFileSize = 0
|
||||
let activeCalculationName = ''
|
||||
const busy = computed(() => parsing.value || calculating.value || inlineCalculating.value)
|
||||
const isCalculating = computed(() => calculating.value || inlineCalculating.value)
|
||||
|
||||
function selectFile(selected?: File) {
|
||||
if (!selected) return
|
||||
destroyWorker()
|
||||
firmware.value = undefined
|
||||
clearResults()
|
||||
error.value = ''
|
||||
function selectFile(selected: File) {
|
||||
if (selected.size === 0) { error.value = '文件为空'; return }
|
||||
if (selected.size > MAX_FIRMWARE_FILE_SIZE) { error.value = '文件不能超过 64 MiB'; return }
|
||||
pendingFileName = selected.name
|
||||
pendingFileSize = selected.size
|
||||
const id = ++requestSequence
|
||||
activeParseRequest = id
|
||||
parsing.value = true
|
||||
const request: CrcWorkerRequest = { type: 'parse', id, file: selected }
|
||||
createWorker().postMessage(request)
|
||||
if (!/\.(?:hex|ihx|bin)$/i.test(selected.name)) { error.value = '请选择 .hex、.ihx 或 .bin 文件'; return }
|
||||
error.value = ''
|
||||
success.value = ''
|
||||
parseFile(selected)
|
||||
}
|
||||
|
||||
function onFileInput(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const selected = input.files?.[0]
|
||||
input.value = ''
|
||||
selectFile(selected)
|
||||
}
|
||||
function startHexCalculation(parameters: CrcParameters) {
|
||||
let worker: Worker
|
||||
try {
|
||||
worker = new Worker(new URL('../workers/crc.worker.ts', import.meta.url), { type: 'module' })
|
||||
} catch {
|
||||
error.value = 'CRC 后台线程创建失败'
|
||||
return
|
||||
}
|
||||
|
||||
function onDrop(event: DragEvent) {
|
||||
dragging.value = false
|
||||
void selectFile(event.dataTransfer?.files[0])
|
||||
const id = ++inlineRequestSequence
|
||||
inlineWorker = worker
|
||||
activeInlineRequest = id
|
||||
inlineCalculating.value = true
|
||||
|
||||
worker.onmessage = (event: MessageEvent<CrcHexWorkerResponse>) => {
|
||||
const response = event.data
|
||||
if (worker !== inlineWorker || response.id !== activeInlineRequest) return
|
||||
destroyInlineWorker()
|
||||
if (response.type === 'error') {
|
||||
error.value = response.error
|
||||
return
|
||||
}
|
||||
combinedResult.value = response.combinedResult
|
||||
hexResultBytes.value = response.totalBytes
|
||||
success.value = `${activeCalculationName} 计算完成`
|
||||
}
|
||||
|
||||
function handleWorkerFailure(event: Event) {
|
||||
event.preventDefault()
|
||||
if (worker !== inlineWorker || id !== activeInlineRequest) return
|
||||
destroyInlineWorker()
|
||||
error.value = 'CRC 后台线程异常终止,请重试'
|
||||
}
|
||||
|
||||
worker.onerror = handleWorkerFailure
|
||||
worker.onmessageerror = handleWorkerFailure
|
||||
|
||||
const request: CrcWorkerCalculateHexRequest = {
|
||||
type: 'calculate-hex',
|
||||
id,
|
||||
input: hexInput.value,
|
||||
parameters,
|
||||
}
|
||||
try {
|
||||
worker.postMessage(request)
|
||||
} catch {
|
||||
destroyInlineWorker()
|
||||
error.value = 'CRC 后台线程通信失败'
|
||||
}
|
||||
}
|
||||
|
||||
function calculate() {
|
||||
invalidateResults()
|
||||
if (!firmware.value) { error.value = '请先选择 BIN 或 Intel HEX 文件'; return }
|
||||
try {
|
||||
const parameters = getParameters()
|
||||
if (!worker) throw new Error('文件数据已失效,请重新选择文件')
|
||||
const id = ++requestSequence
|
||||
activeCalculationRequest = id
|
||||
activeCalculationName = activeParameterName.value
|
||||
calculating.value = true
|
||||
const request: CrcWorkerRequest = { type: 'calculate', id, parameters }
|
||||
worker.postMessage(request)
|
||||
if (inputMode.value === 'hex') {
|
||||
if (!hexInput.value.trim()) throw new Error('请输入 Hex 数据')
|
||||
if (hexInput.value.length > MAX_INLINE_CRC_INPUT_CHARS) {
|
||||
throw new Error(`Hex 数据输入不能超过 ${MAX_INLINE_CRC_INPUT_CHARS.toLocaleString()} 个字符`)
|
||||
}
|
||||
startHexCalculation(parameters)
|
||||
return
|
||||
}
|
||||
if (!firmware.value) throw new Error('请先选择 BIN 或 Intel HEX 文件')
|
||||
const startStatus = calculateInWorker({ parameters })
|
||||
if (startStatus === 'unavailable') throw new Error('文件数据已失效,请重新选择文件')
|
||||
} catch (reason) {
|
||||
activeCalculationRequest = undefined
|
||||
calculating.value = false
|
||||
error.value = reason instanceof Error ? reason.message : 'CRC 计算失败'
|
||||
}
|
||||
}
|
||||
@@ -209,60 +251,101 @@ async function copyResult() {
|
||||
}
|
||||
|
||||
function clearFile() {
|
||||
destroyWorker()
|
||||
destroyWorkers()
|
||||
invalidateResults()
|
||||
firmware.value = undefined
|
||||
if (fileInput.value) fileInput.value.value = ''
|
||||
}
|
||||
|
||||
onBeforeUnmount(destroyWorker)
|
||||
function clearHexInput() {
|
||||
hexInput.value = ''
|
||||
invalidateResults()
|
||||
}
|
||||
|
||||
watch(inputMode, invalidateResults)
|
||||
watch(hexInput, () => {
|
||||
if (inputMode.value === 'hex') invalidateResults()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
destroyInlineWorker()
|
||||
destroyWorkers()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ToolPage category="校验计算" title="CRC" description="解析 BIN 或 Intel HEX 有效数据,并使用预设或自定义参数计算 CRC。">
|
||||
<label v-if="!firmware" class="upload-zone compact-upload" :class="{ 'is-dragging': dragging }" @dragover.prevent="dragging = true" @dragleave.prevent="dragging = false" @drop.prevent="onDrop">
|
||||
<input ref="fileInput" type="file" accept=".hex,.ihx,.bin,application/octet-stream" :disabled="parsing" @change="onFileInput" />
|
||||
<span><FileUp :size="28" aria-hidden="true" /><strong>{{ parsing ? '正在解析文件…' : '选择或拖入 HEX / BIN 文件' }}</strong><span>Intel HEX 会校验记录并识别多个数据块</span></span>
|
||||
</label>
|
||||
<div v-else class="firmware-summary">
|
||||
<div><strong>{{ firmware.fileName }}</strong><span>{{ firmware.format }} · {{ firmware.totalBytes.toLocaleString() }} 有效字节 · {{ firmware.blockCount }} 个 block</span></div>
|
||||
<label class="action-button"><Upload :size="15" />更换文件<input class="sr-only-file" type="file" accept=".hex,.ihx,.bin,application/octet-stream" @change="onFileInput" /></label>
|
||||
<button class="action-button" type="button" @click="clearFile"><Trash2 :size="15" />清空</button>
|
||||
<ToolPage category="校验计算" title="CRC" description="输入 Hex 字节或解析 BIN、Intel HEX 有效数据,并使用预设或自定义参数计算 CRC。">
|
||||
<div class="tool-controls crc-source-controls">
|
||||
<ModeTabs v-model="inputMode" :options="inputModeOptions" label="CRC 输入模式" :disabled="busy" />
|
||||
</div>
|
||||
|
||||
<TextEditor
|
||||
v-if="inputMode === 'hex'"
|
||||
class="crc-hex-editor"
|
||||
id="crc-hex-input"
|
||||
v-model="hexInput"
|
||||
label="待校验数据"
|
||||
format="Hex 字节"
|
||||
:meta="`${hexInput.length.toLocaleString()} 字符`"
|
||||
placeholder="例如:A5 1F 7A DD;支持连续 Hex、空白、逗号和可选 0x 前缀"
|
||||
:disabled="busy"
|
||||
:rows="7"
|
||||
/>
|
||||
<FileDropField
|
||||
v-else
|
||||
input-id="crc-file"
|
||||
replacement-input-id="crc-replacement-file"
|
||||
accept=".hex,.ihx,.bin,application/octet-stream"
|
||||
:has-file="Boolean(firmware)"
|
||||
:busy="parsing"
|
||||
initial-label="选择或拖入 HEX / BIN 文件"
|
||||
initial-busy-label="正在解析文件…"
|
||||
hint="Intel HEX 会校验记录并识别多个数据块"
|
||||
compact
|
||||
@select="selectFile"
|
||||
@clear="clearFile"
|
||||
>
|
||||
<template #icon><FileUp :size="28" aria-hidden="true" /></template>
|
||||
<template v-if="firmware"><strong>{{ firmware.fileName }}</strong><span>{{ firmware.format }} · {{ firmware.totalBytes.toLocaleString() }} 有效字节 · {{ firmware.blockCount }} 个 block</span></template>
|
||||
</FileDropField>
|
||||
|
||||
<div class="crc-config">
|
||||
<label class="parameter-field preset-field"><span>参数预设</span>
|
||||
<select v-model="selectedPreset" class="select-input" :disabled="calculating" @change="applyPreset">
|
||||
<option v-for="preset in CRC_PRESETS" :key="preset.id" :value="preset.id">{{ preset.name }}</option>
|
||||
<label class="parameter-field preset-field"><span>参数预设(Preset)</span>
|
||||
<select id="crc-preset" name="crc-preset" v-model="selectedPreset" class="select-input" :disabled="busy" @change="applyPreset">
|
||||
<optgroup v-for="group in presetGroups" :key="group.label" :label="group.label">
|
||||
<option v-for="preset in group.presets" :key="preset.id" :value="preset.id">{{ preset.name }}</option>
|
||||
</optgroup>
|
||||
<option value="custom">自定义参数</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="parameter-field"><span>Width</span>
|
||||
<select v-model.number="width" class="select-input" :disabled="calculating" @change="markCustom"><option :value="8">8</option><option :value="16">16</option><option :value="32">32</option><option :value="64">64</option></select>
|
||||
<label class="parameter-field"><span>位宽(Width)</span>
|
||||
<select id="crc-width" name="crc-width" v-model.number="width" class="select-input" :disabled="busy" @change="markCustom"><option :value="8">8</option><option :value="16">16</option><option :value="32">32</option><option :value="64">64</option></select>
|
||||
</label>
|
||||
<label class="parameter-field"><span>Poly (Hex)</span><input v-model="polynomial" class="text-input" :disabled="calculating" spellcheck="false" @input="markCustom" /></label>
|
||||
<label class="parameter-field"><span>Init (Hex)</span><input v-model="initialValue" class="text-input" :disabled="calculating" spellcheck="false" @input="markCustom" /></label>
|
||||
<label class="parameter-field"><span>XorOut (Hex)</span><input v-model="xorOut" class="text-input" :disabled="calculating" spellcheck="false" @input="markCustom" /></label>
|
||||
<label class="check-field crc-check"><input v-model="inputReflected" type="checkbox" :disabled="calculating" @change="markCustom" />RefIn</label>
|
||||
<label class="check-field crc-check"><input v-model="outputReflected" type="checkbox" :disabled="calculating" @change="markCustom" />RefOut</label>
|
||||
<label class="parameter-field"><span>多项式(Poly,Hex)</span><input id="crc-polynomial" name="crc-polynomial" v-model="polynomial" class="text-input" :disabled="busy" spellcheck="false" @input="markCustom" /></label>
|
||||
<label class="parameter-field"><span>初始值(Init,Hex)</span><input id="crc-initial-value" name="crc-initial-value" v-model="initialValue" class="text-input" :disabled="busy" spellcheck="false" @input="markCustom" /></label>
|
||||
<label class="parameter-field"><span>异或输出(XorOut,Hex)</span><input id="crc-xor-out" name="crc-xor-out" v-model="xorOut" class="text-input" :disabled="busy" spellcheck="false" @input="markCustom" /></label>
|
||||
<div class="crc-reflection-options" role="group" aria-label="位反转选项">
|
||||
<label class="check-field crc-check"><input id="crc-ref-in" name="crc-ref-in" v-model="inputReflected" type="checkbox" :disabled="busy" @change="markCustom" />输入位反转(RefIn)</label>
|
||||
<label class="check-field crc-check"><input id="crc-ref-out" name="crc-ref-out" v-model="outputReflected" type="checkbox" :disabled="busy" @change="markCustom" />输出位反转(RefOut)</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="action-row">
|
||||
<button class="action-button primary" type="button" :disabled="!firmware || parsing || calculating" @click="calculate"><Calculator :size="15" />{{ calculating ? '正在计算…' : '计算 CRC' }}</button>
|
||||
<button class="action-button primary" type="button" :disabled="busy || (inputMode === 'file' ? !firmware : !hexInput.trim())" @click="calculate"><Calculator :size="15" />{{ isCalculating ? '正在计算…' : '计算 CRC' }}</button>
|
||||
<button v-if="inputMode === 'hex'" class="action-button" type="button" :disabled="busy || !hexInput" @click="clearHexInput"><Trash2 :size="15" />清空</button>
|
||||
<span class="action-spacer" />
|
||||
<button class="action-button ghost" type="button" :disabled="!combinedResult" @click="copyResult"><Copy :size="15" />复制总 CRC</button>
|
||||
</div>
|
||||
|
||||
<div v-if="combinedResult" class="crc-results">
|
||||
<div class="crc-total"><span>总有效数据 · 按地址升序拼接,不填充空洞</span><strong>{{ combinedResult }}</strong></div>
|
||||
<div class="result-table-wrap">
|
||||
<div class="crc-total"><span>{{ inputMode === 'hex' ? `Hex 输入 · ${hexResultBytes?.toLocaleString()} 字节` : '总有效数据 · 按地址升序拼接,不填充空洞' }}</span><strong>{{ combinedResult }}</strong></div>
|
||||
<div v-if="inputMode === 'file'" class="result-table-wrap">
|
||||
<table class="result-table">
|
||||
<thead><tr><th>Block</th><th>起始地址</th><th>结束地址</th><th>字节数</th><th>CRC</th></tr></thead>
|
||||
<thead><tr><th>数据块(Block)</th><th>起始地址(Start Address)</th><th>结束地址(End Address)</th><th>字节数(Length)</th><th>CRC</th></tr></thead>
|
||||
<tbody><tr v-for="(block, index) in blockResults" :key="block.startAddress"><td>#{{ index + 1 }}</td><td>{{ formatAddress(block.startAddress) }}</td><td>{{ formatAddress(block.endAddress) }}</td><td>{{ block.length.toLocaleString() }}</td><td class="crc-value">{{ block.crc }}</td></tr></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="firmware?.warnings.length" class="status-message warning"><AlertCircle :size="16" />{{ firmware.warnings.join(';') }}</div>
|
||||
<div v-if="inputMode === 'file' && firmware?.warnings.length" class="status-message warning"><AlertCircle :size="16" />{{ firmware.warnings.join(';') }}</div>
|
||||
<div v-if="error" class="status-message error"><AlertCircle :size="16" />{{ error }}</div>
|
||||
<div v-else-if="success" class="status-message success"><CheckCircle2 :size="16" />{{ success }}</div>
|
||||
</ToolPage>
|
||||
|
||||
+66
-113
@@ -1,11 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { AlertCircle, Calculator, CheckCircle2, Copy, FileKey2, Trash2, Upload } from '@lucide/vue'
|
||||
import { AlertCircle, Calculator, CheckCircle2, Copy, FileKey2 } from '@lucide/vue'
|
||||
import { onBeforeUnmount, ref, shallowRef, watch } from 'vue'
|
||||
import FileDropField from '@/components/FileDropField.vue'
|
||||
import ModeTabs from '@/components/ModeTabs.vue'
|
||||
import ToolPage from '@/components/ToolPage.vue'
|
||||
import { useTransactionalFileWorker } from '@/composables/useTransactionalFileWorker'
|
||||
import { copyText } from '@/lib/browser'
|
||||
import type { FirmwareHashBlockResult, FirmwareHashByteOrder, FirmwareHashDigest, FirmwareHashMode } from '@/lib/firmwareHash'
|
||||
import type { FirmwareHashWorkerRequest, FirmwareHashWorkerResponse } from '@/lib/firmwareHashWorker'
|
||||
import type { FirmwareHashWorkerCalculateRequest, FirmwareHashWorkerResponse } from '@/lib/firmwareHashWorker'
|
||||
import { MAX_FIRMWARE_FILE_SIZE } from '@/lib/firmware'
|
||||
|
||||
interface FirmwareSummary {
|
||||
@@ -16,8 +18,6 @@ interface FirmwareSummary {
|
||||
warnings: string[]
|
||||
}
|
||||
|
||||
const fileInput = ref<HTMLInputElement>()
|
||||
const dragging = ref(false)
|
||||
const firmware = shallowRef<FirmwareSummary>()
|
||||
const mode = ref<FirmwareHashMode>('data-only')
|
||||
const byteOrder = ref<FirmwareHashByteOrder>('big-endian')
|
||||
@@ -25,14 +25,8 @@ const combined = shallowRef<FirmwareHashDigest>()
|
||||
const blockResults = ref<FirmwareHashBlockResult[]>([])
|
||||
const hashedBytes = ref(0)
|
||||
const headerBytes = ref(0)
|
||||
const parsing = ref(false)
|
||||
const calculating = ref(false)
|
||||
const error = ref('')
|
||||
const success = ref('')
|
||||
let worker: Worker | undefined
|
||||
let requestSequence = 0
|
||||
let activeParseRequest: number | undefined
|
||||
let activeCalculationRequest: number | undefined
|
||||
|
||||
const modeOptions = [
|
||||
{ value: 'data-only' as const, label: '有效数据' },
|
||||
@@ -54,8 +48,7 @@ function formatFileSize(size: number): string {
|
||||
}
|
||||
|
||||
function clearResults() {
|
||||
activeCalculationRequest = undefined
|
||||
calculating.value = false
|
||||
invalidateCalculation()
|
||||
combined.value = undefined
|
||||
blockResults.value = []
|
||||
hashedBytes.value = 0
|
||||
@@ -68,109 +61,63 @@ function invalidateResults() {
|
||||
error.value = ''
|
||||
}
|
||||
|
||||
function destroyWorker() {
|
||||
worker?.terminate()
|
||||
worker = undefined
|
||||
activeParseRequest = undefined
|
||||
activeCalculationRequest = undefined
|
||||
parsing.value = false
|
||||
calculating.value = false
|
||||
}
|
||||
|
||||
function createWorker(): Worker {
|
||||
const nextWorker = new Worker(new URL('../workers/firmwareHash.worker.ts', import.meta.url), { type: 'module' })
|
||||
nextWorker.onmessage = (event: MessageEvent<FirmwareHashWorkerResponse>) => {
|
||||
if (nextWorker !== worker) return
|
||||
const response = event.data
|
||||
if (response.type === 'parsed') {
|
||||
if (response.id !== activeParseRequest) return
|
||||
activeParseRequest = undefined
|
||||
parsing.value = false
|
||||
firmware.value = {
|
||||
fileName: pendingFileName,
|
||||
fileSize: pendingFileSize,
|
||||
totalBytes: response.totalBytes,
|
||||
blockCount: response.blockCount,
|
||||
warnings: response.warnings,
|
||||
}
|
||||
success.value = `已解析 ${response.blockCount} 个数据块`
|
||||
return
|
||||
const {
|
||||
parsing,
|
||||
calculating,
|
||||
parseFile,
|
||||
calculate: calculateInWorker,
|
||||
invalidateCalculation,
|
||||
destroyWorkers,
|
||||
} = useTransactionalFileWorker<Omit<FirmwareHashWorkerCalculateRequest, 'type' | 'id'>, FirmwareHashWorkerResponse>({
|
||||
createWorker: () => new Worker(new URL('../workers/firmwareHash.worker.ts', import.meta.url), { type: 'module' }),
|
||||
onParsed: (response, selected) => {
|
||||
clearResults()
|
||||
firmware.value = {
|
||||
fileName: selected.name,
|
||||
fileSize: selected.size,
|
||||
totalBytes: response.totalBytes,
|
||||
blockCount: response.blockCount,
|
||||
warnings: response.warnings,
|
||||
}
|
||||
if (response.type === 'calculated') {
|
||||
if (response.id !== activeCalculationRequest) return
|
||||
activeCalculationRequest = undefined
|
||||
calculating.value = false
|
||||
combined.value = response.combined
|
||||
blockResults.value = response.blocks
|
||||
hashedBytes.value = response.hashedBytes
|
||||
headerBytes.value = response.headerBytes
|
||||
success.value = 'SHA-256 / SHA-512 计算完成'
|
||||
return
|
||||
}
|
||||
if (response.operation === 'parse' && response.id === activeParseRequest) {
|
||||
activeParseRequest = undefined
|
||||
parsing.value = false
|
||||
firmware.value = undefined
|
||||
error.value = response.error
|
||||
} else if (response.operation === 'calculate' && response.id === activeCalculationRequest) {
|
||||
activeCalculationRequest = undefined
|
||||
calculating.value = false
|
||||
error.value = response.error
|
||||
}
|
||||
}
|
||||
nextWorker.onerror = () => {
|
||||
if (nextWorker !== worker) return
|
||||
destroyWorker()
|
||||
success.value = `已解析 ${response.blockCount} 个数据块`
|
||||
},
|
||||
onCalculated: (response) => {
|
||||
combined.value = response.combined
|
||||
blockResults.value = response.blocks
|
||||
hashedBytes.value = response.hashedBytes
|
||||
headerBytes.value = response.headerBytes
|
||||
success.value = 'SHA-256 / SHA-512 计算完成'
|
||||
},
|
||||
onParseError: (message) => { error.value = message },
|
||||
onCalculationError: (message) => { error.value = message },
|
||||
onPendingWorkerError: (hasCurrentFile) => {
|
||||
error.value = hasCurrentFile
|
||||
? '新文件解析失败,当前文件未更改'
|
||||
: 'Hash 后台线程异常终止,请重新选择文件'
|
||||
},
|
||||
onActiveWorkerError: () => {
|
||||
firmware.value = undefined
|
||||
clearResults()
|
||||
error.value = 'Hash 后台线程异常终止,请重新选择文件'
|
||||
}
|
||||
worker = nextWorker
|
||||
return nextWorker
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
let pendingFileName = ''
|
||||
let pendingFileSize = 0
|
||||
|
||||
function selectFile(selected?: File) {
|
||||
if (!selected) return
|
||||
destroyWorker()
|
||||
firmware.value = undefined
|
||||
invalidateResults()
|
||||
function selectFile(selected: File) {
|
||||
if (selected.size === 0) { error.value = '文件为空'; return }
|
||||
if (selected.size > MAX_FIRMWARE_FILE_SIZE) { error.value = '文件不能超过 64 MiB'; return }
|
||||
if (!/\.(?:hex|ihx)$/i.test(selected.name)) { error.value = '请选择 .hex 或 .ihx 文件'; return }
|
||||
|
||||
pendingFileName = selected.name
|
||||
pendingFileSize = selected.size
|
||||
const id = ++requestSequence
|
||||
activeParseRequest = id
|
||||
parsing.value = true
|
||||
const request: FirmwareHashWorkerRequest = { type: 'parse', id, file: selected }
|
||||
createWorker().postMessage(request)
|
||||
}
|
||||
|
||||
function onFileInput(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const selected = input.files?.[0]
|
||||
input.value = ''
|
||||
selectFile(selected)
|
||||
}
|
||||
|
||||
function onDrop(event: DragEvent) {
|
||||
dragging.value = false
|
||||
selectFile(event.dataTransfer?.files[0])
|
||||
error.value = ''
|
||||
success.value = ''
|
||||
parseFile(selected)
|
||||
}
|
||||
|
||||
function calculate() {
|
||||
invalidateResults()
|
||||
if (!firmware.value) { error.value = '请先选择 Intel HEX 文件'; return }
|
||||
if (!worker) { error.value = '文件数据已失效,请重新选择文件'; return }
|
||||
const id = ++requestSequence
|
||||
activeCalculationRequest = id
|
||||
calculating.value = true
|
||||
const request: FirmwareHashWorkerRequest = { type: 'calculate', id, mode: mode.value, byteOrder: byteOrder.value }
|
||||
worker.postMessage(request)
|
||||
if (calculateInWorker({ mode: mode.value, byteOrder: byteOrder.value }) === 'unavailable') {
|
||||
error.value = '文件数据已失效,请重新选择文件'
|
||||
}
|
||||
}
|
||||
|
||||
async function copyDigest(label: string, value: string) {
|
||||
@@ -179,27 +126,33 @@ async function copyDigest(label: string, value: string) {
|
||||
}
|
||||
|
||||
function clearFile() {
|
||||
destroyWorker()
|
||||
destroyWorkers()
|
||||
firmware.value = undefined
|
||||
invalidateResults()
|
||||
if (fileInput.value) fileInput.value.value = ''
|
||||
}
|
||||
|
||||
watch([mode, byteOrder], invalidateResults)
|
||||
onBeforeUnmount(destroyWorker)
|
||||
onBeforeUnmount(destroyWorkers)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ToolPage category="校验计算" title="固件 Hash" description="解析 Intel HEX 有效数据,计算 SHA-256 / SHA-512,可选择加入每个 Block 的地址和长度校验头。">
|
||||
<label v-if="!firmware" class="upload-zone compact-upload" :class="{ 'is-dragging': dragging }" @dragover.prevent="dragging = true" @dragleave.prevent="dragging = false" @drop.prevent="onDrop">
|
||||
<input ref="fileInput" type="file" accept=".hex,.ihx,text/plain" :disabled="parsing" @change="onFileInput" />
|
||||
<span><FileKey2 :size="28" /><strong>{{ parsing ? '正在解析文件…' : '选择或拖入 Intel HEX 文件' }}</strong><span>校验记录、识别多个数据块,不填充地址空洞</span></span>
|
||||
</label>
|
||||
<div v-else class="firmware-summary">
|
||||
<div><strong>{{ firmware.fileName }}</strong><span>Intel HEX · {{ firmware.totalBytes.toLocaleString() }} 有效字节 · {{ firmware.blockCount }} 个 block · {{ formatFileSize(firmware.fileSize) }}</span></div>
|
||||
<label class="action-button"><Upload :size="15" />更换文件<input class="sr-only-file" type="file" accept=".hex,.ihx,text/plain" @change="onFileInput" /></label>
|
||||
<button class="action-button" type="button" @click="clearFile"><Trash2 :size="15" />清空</button>
|
||||
</div>
|
||||
<FileDropField
|
||||
input-id="firmware-hash-file"
|
||||
replacement-input-id="firmware-hash-replacement-file"
|
||||
accept=".hex,.ihx,text/plain"
|
||||
:has-file="Boolean(firmware)"
|
||||
:busy="parsing"
|
||||
initial-label="选择或拖入 Intel HEX 文件"
|
||||
initial-busy-label="正在解析文件…"
|
||||
hint="校验记录、识别多个数据块,不填充地址空洞"
|
||||
compact
|
||||
@select="selectFile"
|
||||
@clear="clearFile"
|
||||
>
|
||||
<template #icon><FileKey2 :size="28" aria-hidden="true" /></template>
|
||||
<template v-if="firmware"><strong>{{ firmware.fileName }}</strong><span>Intel HEX · {{ firmware.totalBytes.toLocaleString() }} 有效字节 · {{ firmware.blockCount }} 个 block · {{ formatFileSize(firmware.fileSize) }}</span></template>
|
||||
</FileDropField>
|
||||
|
||||
<div class="hash-config">
|
||||
<div class="parameter-field"><span>计算模式</span><ModeTabs v-model="mode" :options="modeOptions" label="Hash 计算模式" :disabled="calculating || parsing" /></div>
|
||||
|
||||
@@ -44,11 +44,11 @@ function clearAll() { input.value = ''; clearResult() }
|
||||
<ToolPage category="格式化" title="JSON" description="格式化、压缩并校验 JSON 字符串。">
|
||||
<div class="tool-controls">
|
||||
<label class="compact-field">缩进
|
||||
<select v-model="indent"><option value="2">2 空格</option><option value="4">4 空格</option><option value="tab">Tab</option></select>
|
||||
<select id="json-indent" name="json-indent" v-model="indent"><option value="2">2 空格</option><option value="4">4 空格</option><option value="tab">Tab</option></select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="single-editor-layout">
|
||||
<TextEditor v-model="input" label="输入" format="JSON" :meta="`${input.length} 字符`" placeholder="粘贴 JSON 字符串" :rows="14" />
|
||||
<TextEditor id="json-input" v-model="input" label="输入" format="JSON" :meta="`${input.length} 字符`" placeholder="粘贴 JSON 字符串" :rows="14" />
|
||||
<SyntaxViewer :value="output" label="结果" language="json" format="JSON" :meta="`${output.length} 字符`" />
|
||||
</div>
|
||||
<div class="action-row">
|
||||
|
||||
@@ -146,7 +146,7 @@ onBeforeUnmount(() => {
|
||||
<template>
|
||||
<ToolPage category="图像处理" title="PNG → ICO" description="在浏览器本地缩放 PNG,并生成包含多个尺寸的 ICO 文件。">
|
||||
<label v-if="!file" class="upload-zone" :class="{ 'is-dragging': dragging }" @dragover.prevent="dragging = true" @dragleave.prevent="dragging = false" @drop.prevent="onDrop">
|
||||
<input type="file" accept="image/png,.png" @change="onFileInput" />
|
||||
<input id="png-ico-file" name="png-ico-file" type="file" accept="image/png,.png" @change="onFileInput" />
|
||||
<span><ImagePlus :size="28" aria-hidden="true" /><strong>选择或拖入 PNG 图片</strong><span>透明背景会保留,文件不会上传</span></span>
|
||||
</label>
|
||||
<div v-else class="image-workspace">
|
||||
@@ -155,13 +155,13 @@ onBeforeUnmount(() => {
|
||||
<h2>ICO 尺寸</h2>
|
||||
<div class="size-grid">
|
||||
<label v-for="size in availableSizes" :key="size" class="size-option">
|
||||
<input type="checkbox" :checked="selectedSizes.includes(size)" :disabled="converting" @change="toggleSize(size)" />{{ size }} × {{ size }}
|
||||
<input :id="`png-ico-size-${size}`" :name="`png-ico-size-${size}`" type="checkbox" :checked="selectedSizes.includes(size)" :disabled="converting" @change="toggleSize(size)" />{{ size }} × {{ size }}
|
||||
</label>
|
||||
</div>
|
||||
<p class="file-meta">{{ file.name }} · {{ dimensions }} · {{ (file.size / 1024).toFixed(1) }} KB</p>
|
||||
<div class="action-row">
|
||||
<button class="action-button primary" type="button" :disabled="converting || !imageReady" @click="convert"><Download :size="15" />{{ converting ? '正在生成…' : imageReady ? '生成并下载 ICO' : '正在读取图片…' }}</button>
|
||||
<label class="action-button" :class="{ disabled: converting }" :aria-disabled="converting"><Upload :size="15" />更换图片<input class="sr-only-file" type="file" accept="image/png,.png" :disabled="converting" @change="onFileInput" /></label>
|
||||
<label class="action-button" :class="{ disabled: converting }" :aria-disabled="converting"><Upload :size="15" />更换图片<input id="png-ico-replacement-file" name="png-ico-replacement-file" class="sr-only-file" type="file" accept="image/png,.png" :disabled="converting" @change="onFileInput" /></label>
|
||||
<button class="action-button" type="button" :disabled="converting" @click="clearFile"><Trash2 :size="15" />清空</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,9 +6,9 @@ import TextEditor from '@/components/TextEditor.vue'
|
||||
import ToolPage from '@/components/ToolPage.vue'
|
||||
import { copyText } from '@/lib/browser'
|
||||
import { formatBytes, type ByteDisplayFormat } from '@/lib/bytes'
|
||||
import { secureRandomBytes } from '@/lib/random'
|
||||
import { DEFAULT_RANDOM_BYTES, MAX_RANDOM_BYTES, MIN_RANDOM_BYTES, secureRandomBytes } from '@/lib/random'
|
||||
|
||||
const length = ref(8)
|
||||
const length = ref(DEFAULT_RANDOM_BYTES)
|
||||
const outputFormat = ref<ByteDisplayFormat>('continuous-hex')
|
||||
const uppercase = ref(true)
|
||||
const bytes = shallowRef<Uint8Array>()
|
||||
@@ -55,11 +55,11 @@ watch(length, clearAll)
|
||||
<template>
|
||||
<ToolPage category="加密计算" title="安全随机数" description="使用浏览器密码学随机源生成指定字节长度的随机数据。">
|
||||
<div class="random-controls">
|
||||
<label class="parameter-field"><span>字节长度</span><input v-model.number="length" class="text-input" type="number" min="1" max="65536" step="1" /></label>
|
||||
<label class="parameter-field"><span>字节长度</span><input id="random-length" name="random-length" v-model.number="length" class="text-input" type="number" :min="MIN_RANDOM_BYTES" :max="MAX_RANDOM_BYTES" step="1" /></label>
|
||||
<div class="parameter-field random-format-field"><span>输出格式</span><ModeTabs v-model="outputFormat" :options="outputFormats" label="输出格式" /></div>
|
||||
<label class="check-field random-check"><input v-model="uppercase" type="checkbox" />大写 Hex</label>
|
||||
<label class="check-field random-check"><input id="random-uppercase" name="random-uppercase" v-model="uppercase" type="checkbox" />大写 Hex</label>
|
||||
</div>
|
||||
<TextEditor :model-value="output" label="随机数" :format="formatLabel" :meta="`${bytes?.length ?? 0} 字节 · ${output.length} 字符`" readonly :rows="11" />
|
||||
<TextEditor id="random-output" :model-value="output" label="随机数" :format="formatLabel" :meta="`${bytes?.length ?? 0} 字节 · ${output.length} 字符`" readonly :rows="11" />
|
||||
<div class="action-row">
|
||||
<button class="action-button primary" type="button" @click="generate"><Dices :size="15" />生成随机数</button>
|
||||
<button class="action-button" type="button" :disabled="!output" @click="clearAll"><Trash2 :size="15" />清空</button>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { AlertCircle, CheckCircle2, Copy, KeyRound, Trash2 } from '@lucide/vue'
|
||||
import { computed, ref, shallowRef, watch } from 'vue'
|
||||
import { computed, onBeforeUnmount, ref, shallowRef, watch } from 'vue'
|
||||
import ModeTabs from '@/components/ModeTabs.vue'
|
||||
import TextEditor from '@/components/TextEditor.vue'
|
||||
import ToolPage from '@/components/ToolPage.vue'
|
||||
@@ -17,6 +17,7 @@ const result = shallowRef<GeneratedRsaKeyPair>()
|
||||
const generating = ref(false)
|
||||
const error = ref('')
|
||||
const success = ref('')
|
||||
let generationVersion = 0
|
||||
const outputModes = [
|
||||
{ value: 'public' as const, label: '公钥 PEM' },
|
||||
{ value: 'private' as const, label: '私钥 PEM' },
|
||||
@@ -52,15 +53,19 @@ async function generate() {
|
||||
error.value = ''
|
||||
success.value = ''
|
||||
generating.value = true
|
||||
const version = ++generationVersion
|
||||
try {
|
||||
result.value = await generateRsaKeyPair(modulusLength.value)
|
||||
const generated = await generateRsaKeyPair(modulusLength.value)
|
||||
if (version !== generationVersion) return
|
||||
result.value = generated
|
||||
outputMode.value = 'public'
|
||||
success.value = `RSA 密钥对已生成 · ${result.value.source}`
|
||||
} catch (reason) {
|
||||
if (version !== generationVersion) return
|
||||
result.value = undefined
|
||||
error.value = reason instanceof Error ? reason.message : 'RSA 密钥生成失败'
|
||||
} finally {
|
||||
generating.value = false
|
||||
if (version === generationVersion) generating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,11 +75,15 @@ async function copyValue(value: string, label: string) {
|
||||
}
|
||||
|
||||
function clearAll() {
|
||||
generationVersion += 1
|
||||
result.value = undefined
|
||||
error.value = ''
|
||||
success.value = ''
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
generationVersion += 1
|
||||
})
|
||||
|
||||
watch(modulusLength, clearAll)
|
||||
</script>
|
||||
@@ -83,7 +92,7 @@ watch(modulusLength, clearAll)
|
||||
<ToolPage category="加密计算" title="RSA 密钥对" description="使用安全随机源生成 RSA 密钥对,并导出标准 PEM 或公钥 N/E 参数。">
|
||||
<div class="tool-controls">
|
||||
<label class="compact-field">密钥长度
|
||||
<select v-model.number="modulusLength" :disabled="generating"><option :value="2048">2048 位</option><option :value="3072">3072 位</option><option :value="4096">4096 位</option></select>
|
||||
<select id="rsa-modulus-length" name="rsa-modulus-length" v-model.number="modulusLength" :disabled="generating"><option :value="2048">2048 位</option><option :value="3072">3072 位</option><option :value="4096">4096 位</option></select>
|
||||
</label>
|
||||
<span class="fixed-parameter">Public exponent <strong>65537 · 0x010001</strong></span>
|
||||
</div>
|
||||
@@ -103,16 +112,16 @@ watch(modulusLength, clearAll)
|
||||
</div>
|
||||
<div class="rsa-parameter-grid">
|
||||
<section>
|
||||
<TextEditor :model-value="modulusOutput" label="模数 N" :format="parameterFormatLabel" :meta="`${modulusBytes} 字节 · ${modulusOutput.length} 字符`" readonly :rows="14" />
|
||||
<TextEditor id="rsa-modulus-output" :model-value="modulusOutput" label="模数 N" :format="parameterFormatLabel" :meta="`${modulusBytes} 字节 · ${modulusOutput.length} 字符`" readonly :rows="14" />
|
||||
<div class="action-row rsa-copy-row"><button class="action-button ghost" type="button" @click="copyValue(modulusOutput, 'N 参数')"><Copy :size="15" />复制 N</button></div>
|
||||
</section>
|
||||
<section>
|
||||
<TextEditor :model-value="exponentOutput" label="指数 E" :format="parameterFormatLabel" :meta="`${exponentBytes} 字节 · ${exponentOutput.length} 字符`" readonly :rows="4" />
|
||||
<TextEditor id="rsa-exponent-output" :model-value="exponentOutput" label="指数 E" :format="parameterFormatLabel" :meta="`${exponentBytes} 字节 · ${exponentOutput.length} 字符`" readonly :rows="4" />
|
||||
<div class="action-row rsa-copy-row"><button class="action-button ghost" type="button" @click="copyValue(exponentOutput, 'E 参数')"><Copy :size="15" />复制 E</button></div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
<TextEditor v-else :model-value="pemOutput" label="生成结果" :format="outputFormat" :meta="`${pemOutput.length} 字符`" readonly :rows="16" />
|
||||
<TextEditor v-else id="rsa-pem-output" :model-value="pemOutput" label="生成结果" :format="outputFormat" :meta="`${pemOutput.length} 字符`" readonly :rows="16" />
|
||||
</template>
|
||||
<div v-if="error" class="status-message error"><AlertCircle :size="16" />{{ error }}</div>
|
||||
<div v-else-if="success" class="status-message success"><CheckCircle2 :size="16" />{{ success }}</div>
|
||||
|
||||
@@ -0,0 +1,410 @@
|
||||
<script setup lang="ts">
|
||||
import { AlertCircle, CheckCircle2, FileSignature, Play, ShieldAlert, Trash2, XCircle } from '@lucide/vue'
|
||||
import { computed, onBeforeUnmount, ref, shallowRef, watch } from 'vue'
|
||||
import FileDropField from '@/components/FileDropField.vue'
|
||||
import ModeTabs from '@/components/ModeTabs.vue'
|
||||
import TextEditor from '@/components/TextEditor.vue'
|
||||
import ToolPage from '@/components/ToolPage.vue'
|
||||
import { useTransactionalFileWorker } from '@/composables/useTransactionalFileWorker'
|
||||
import { parseHex, textToBytesLimited } from '@/lib/bytes'
|
||||
import { MAX_FIRMWARE_FILE_SIZE } from '@/lib/firmware'
|
||||
import type { FirmwareHashByteOrder } from '@/lib/firmwareHash'
|
||||
import {
|
||||
decodeSignature,
|
||||
defaultPssSaltLength,
|
||||
hashLength,
|
||||
MAX_INLINE_SIGNATURE_DATA_BYTES,
|
||||
MAX_INLINE_SIGNATURE_INPUT_CHARS,
|
||||
maximumPssSaltLength,
|
||||
parseRsaPublicKeyParameters,
|
||||
parseRsaPublicKeyPem,
|
||||
verifyRsaSignature,
|
||||
type ParsedRsaPublicKey,
|
||||
type RsaSignatureScheme,
|
||||
type RsaVerificationResult,
|
||||
type SignatureEncoding,
|
||||
type SignatureHashAlgorithm,
|
||||
} from '@/lib/signatureVerification'
|
||||
import type { SignatureFileMode } from '@/lib/signatureVerificationFile'
|
||||
import type {
|
||||
SignatureVerificationWorkerCalculateRequest,
|
||||
SignatureVerificationWorkerResponse,
|
||||
} from '@/lib/signatureVerificationWorker'
|
||||
|
||||
type KeyMode = 'pem' | 'parameters'
|
||||
type DataSource = 'inline' | 'file'
|
||||
type InlineDataMode = 'text' | 'hex'
|
||||
|
||||
interface FileSummary {
|
||||
name: string
|
||||
size: number
|
||||
dataBytes: number
|
||||
blockCount?: number
|
||||
warnings: string[]
|
||||
mode: SignatureFileMode
|
||||
byteOrder: FirmwareHashByteOrder
|
||||
effectiveDataBytes: number
|
||||
headerBytes: number
|
||||
}
|
||||
|
||||
const scheme = ref<RsaSignatureScheme>('pss')
|
||||
const hash = ref<SignatureHashAlgorithm>('SHA-256')
|
||||
const saltLength = ref(hashLength(hash.value))
|
||||
const saltLengthCustomized = ref(false)
|
||||
const keyMode = ref<KeyMode>('pem')
|
||||
const pemInput = ref('')
|
||||
const modulusInput = ref('')
|
||||
const exponentInput = ref('10001')
|
||||
const parsedKey = shallowRef<ParsedRsaPublicKey>()
|
||||
const dataSource = ref<DataSource>('inline')
|
||||
const inlineDataMode = ref<InlineDataMode>('text')
|
||||
const inlineData = ref('')
|
||||
const fileMode = ref<SignatureFileMode>('raw-file')
|
||||
const blockHeaderByteOrder = ref<FirmwareHashByteOrder>('big-endian')
|
||||
const activeFile = shallowRef<FileSummary>()
|
||||
const signatureEncoding = ref<SignatureEncoding>('hex')
|
||||
const signatureInput = ref('')
|
||||
const result = shallowRef<RsaVerificationResult>()
|
||||
const verifyingInline = ref(false)
|
||||
const error = ref('')
|
||||
const success = ref('')
|
||||
let inlineRequestVersion = 0
|
||||
|
||||
const schemeOptions = [
|
||||
{ value: 'pkcs1-v1_5' as const, label: 'PKCS#1 v1.5' },
|
||||
{ value: 'pss' as const, label: 'RSA-PSS' },
|
||||
]
|
||||
const keyModeOptions = [
|
||||
{ value: 'pem' as const, label: 'PEM / 证书' },
|
||||
{ value: 'parameters' as const, label: 'N / E 参数' },
|
||||
]
|
||||
const dataSourceOptions = [
|
||||
{ value: 'inline' as const, label: '文本 / Hex' },
|
||||
{ value: 'file' as const, label: '文件' },
|
||||
]
|
||||
const inlineModeOptions = [
|
||||
{ value: 'text' as const, label: 'UTF-8 文本' },
|
||||
{ value: 'hex' as const, label: 'Hex 字节' },
|
||||
]
|
||||
const fileModeOptions = [
|
||||
{ value: 'raw-file' as const, label: '原始文件' },
|
||||
{ value: 'intel-hex' as const, label: 'Intel HEX 有效数据' },
|
||||
{ value: 'intel-hex-block-header' as const, label: 'Block 校验头' },
|
||||
]
|
||||
const byteOrderOptions = [
|
||||
{ value: 'big-endian' as const, label: 'Big Endian' },
|
||||
{ value: 'little-endian' as const, label: 'Little Endian' },
|
||||
]
|
||||
const signatureEncodingOptions = [
|
||||
{ value: 'hex' as const, label: 'Hex' },
|
||||
{ value: 'base64' as const, label: 'Base64' },
|
||||
{ value: 'base64url' as const, label: 'Base64 URL' },
|
||||
]
|
||||
|
||||
const expectedSignatureBytes = computed(() => parsedKey.value ? Math.ceil(parsedKey.value.modulusBits / 8) : undefined)
|
||||
const maximumSaltLength = computed(() => parsedKey.value ? maximumPssSaltLength(parsedKey.value.modulusBits, hash.value) : undefined)
|
||||
const signatureBytes = computed<number | undefined>(() => {
|
||||
try { return decodeSignature(signatureInput.value, signatureEncoding.value).length }
|
||||
catch { return undefined }
|
||||
})
|
||||
const busy = computed(() => parsing.value || calculating.value || verifyingInline.value)
|
||||
const algorithmLabel = computed(() => scheme.value === 'pss'
|
||||
? `RSA-PSS / ${hash.value} / Salt ${saltLength.value} B`
|
||||
: `RSA PKCS#1 v1.5 / ${hash.value}`)
|
||||
const dataSourceLabel = computed(() => {
|
||||
if (dataSource.value === 'inline') return inlineDataMode.value === 'text' ? 'UTF-8 文本' : 'Hex 字节'
|
||||
if (!activeFile.value) {
|
||||
if (fileMode.value === 'intel-hex') return 'Intel HEX 有效数据'
|
||||
if (fileMode.value === 'intel-hex-block-header') return `Intel HEX Block 校验头 · ${formatByteOrder(blockHeaderByteOrder.value)}`
|
||||
return '原始文件'
|
||||
}
|
||||
if (activeFile.value.mode === 'intel-hex') return `${activeFile.value.name} · Intel HEX 有效数据`
|
||||
if (activeFile.value.mode === 'intel-hex-block-header') {
|
||||
return `${activeFile.value.name} · Intel HEX Block 校验头 · ${formatByteOrder(activeFile.value.byteOrder)}`
|
||||
}
|
||||
return `${activeFile.value.name} · 原始文件`
|
||||
})
|
||||
|
||||
function formatFileSize(size: number): string {
|
||||
if (size < 1024) return `${size.toLocaleString()} B`
|
||||
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KiB`
|
||||
return `${(size / 1024 / 1024).toFixed(1)} MiB`
|
||||
}
|
||||
|
||||
function formatByteOrder(byteOrder: FirmwareHashByteOrder): string {
|
||||
return byteOrder === 'big-endian' ? 'Big Endian' : 'Little Endian'
|
||||
}
|
||||
|
||||
function clearResult() {
|
||||
inlineRequestVersion += 1
|
||||
invalidateCalculation()
|
||||
verifyingInline.value = false
|
||||
result.value = undefined
|
||||
error.value = ''
|
||||
success.value = ''
|
||||
}
|
||||
|
||||
function invalidateKey() {
|
||||
parsedKey.value = undefined
|
||||
clearResult()
|
||||
}
|
||||
|
||||
async function resolveKey(): Promise<ParsedRsaPublicKey> {
|
||||
return keyMode.value === 'pem'
|
||||
? parseRsaPublicKeyPem(pemInput.value)
|
||||
: parseRsaPublicKeyParameters(modulusInput.value, exponentInput.value)
|
||||
}
|
||||
|
||||
function inlineBytes(): Uint8Array {
|
||||
if (inlineDataMode.value === 'hex') {
|
||||
return parseHex(inlineData.value, {
|
||||
maxBytes: MAX_INLINE_SIGNATURE_DATA_BYTES,
|
||||
maxInputChars: MAX_INLINE_SIGNATURE_INPUT_CHARS,
|
||||
limitLabel: '文本或 Hex 数据',
|
||||
})
|
||||
}
|
||||
return textToBytesLimited(inlineData.value, MAX_INLINE_SIGNATURE_DATA_BYTES, MAX_INLINE_SIGNATURE_INPUT_CHARS, '文本或 Hex 数据')
|
||||
}
|
||||
|
||||
const {
|
||||
parsing,
|
||||
calculating,
|
||||
parseFile,
|
||||
calculate: calculateInWorker,
|
||||
invalidateCalculation,
|
||||
destroyWorkers,
|
||||
} = useTransactionalFileWorker<Omit<SignatureVerificationWorkerCalculateRequest, 'type' | 'id'>, SignatureVerificationWorkerResponse>({
|
||||
createWorker: () => new Worker(new URL('../workers/signatureVerification.worker.ts', import.meta.url), { type: 'module' }),
|
||||
createParseRequest: (id, file) => ({
|
||||
type: 'parse',
|
||||
id,
|
||||
file,
|
||||
mode: fileMode.value,
|
||||
byteOrder: blockHeaderByteOrder.value,
|
||||
}),
|
||||
onParsed: (response, file) => {
|
||||
clearResult()
|
||||
activeFile.value = {
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
dataBytes: response.dataBytes,
|
||||
blockCount: response.blockCount,
|
||||
warnings: response.warnings,
|
||||
mode: response.mode,
|
||||
byteOrder: response.byteOrder,
|
||||
effectiveDataBytes: response.effectiveDataBytes,
|
||||
headerBytes: response.headerBytes,
|
||||
}
|
||||
success.value = response.mode !== 'raw-file'
|
||||
? `已解析 ${response.blockCount} 个数据块`
|
||||
: '已读取原始文件'
|
||||
},
|
||||
onCalculated: (response) => {
|
||||
result.value = response
|
||||
success.value = response.valid ? '签名有效' : ''
|
||||
},
|
||||
onParseError: (message) => { error.value = message },
|
||||
onCalculationError: (message) => { error.value = message },
|
||||
onPendingWorkerError: (hasCurrentFile) => {
|
||||
error.value = hasCurrentFile ? '新文件解析失败,当前文件未更改' : '文件后台线程异常终止,请重新选择文件'
|
||||
},
|
||||
onActiveWorkerError: () => {
|
||||
activeFile.value = undefined
|
||||
clearResult()
|
||||
error.value = '文件后台线程异常终止,请重新选择文件'
|
||||
},
|
||||
})
|
||||
|
||||
function selectFile(file: File) {
|
||||
if (file.size > MAX_FIRMWARE_FILE_SIZE) { error.value = '文件不能超过 64 MiB'; return }
|
||||
if (fileMode.value !== 'raw-file' && !/\.(?:hex|ihx)$/i.test(file.name)) {
|
||||
error.value = 'Intel HEX 模式请选择 .hex 或 .ihx 文件'
|
||||
return
|
||||
}
|
||||
error.value = ''
|
||||
success.value = ''
|
||||
parseFile(file)
|
||||
}
|
||||
|
||||
function clearFile() {
|
||||
destroyWorkers()
|
||||
activeFile.value = undefined
|
||||
clearResult()
|
||||
}
|
||||
|
||||
async function verify() {
|
||||
clearResult()
|
||||
const version = ++inlineRequestVersion
|
||||
verifyingInline.value = true
|
||||
try {
|
||||
const key = await resolveKey()
|
||||
if (version !== inlineRequestVersion) return
|
||||
parsedKey.value = key
|
||||
if (scheme.value === 'pss' && !saltLengthCustomized.value) {
|
||||
saltLength.value = defaultPssSaltLength(key.modulusBits, hash.value)
|
||||
}
|
||||
const signature = decodeSignature(signatureInput.value, signatureEncoding.value)
|
||||
const payload = { key, signature, scheme: scheme.value, hash: hash.value, saltLength: saltLength.value }
|
||||
if (dataSource.value === 'file') {
|
||||
if (!activeFile.value) throw new Error('请先选择待验证文件')
|
||||
verifyingInline.value = false
|
||||
const status = calculateInWorker(payload)
|
||||
if (status === 'unavailable') throw new Error('文件数据已失效,请重新选择文件')
|
||||
return
|
||||
}
|
||||
const verification = await verifyRsaSignature(key, inlineBytes(), signature, payload)
|
||||
if (version !== inlineRequestVersion) return
|
||||
result.value = verification
|
||||
success.value = verification.valid ? '签名有效' : ''
|
||||
} catch (reason) {
|
||||
if (version !== inlineRequestVersion) return
|
||||
error.value = reason instanceof Error ? reason.message : '签名验证失败'
|
||||
} finally {
|
||||
if (version === inlineRequestVersion) verifyingInline.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function clearAll() {
|
||||
pemInput.value = ''
|
||||
modulusInput.value = ''
|
||||
exponentInput.value = '10001'
|
||||
inlineData.value = ''
|
||||
signatureInput.value = ''
|
||||
saltLengthCustomized.value = false
|
||||
saltLength.value = hashLength(hash.value)
|
||||
parsedKey.value = undefined
|
||||
clearFile()
|
||||
}
|
||||
|
||||
function customizeSaltLength() {
|
||||
saltLengthCustomized.value = true
|
||||
clearResult()
|
||||
}
|
||||
|
||||
watch([scheme, hash], () => {
|
||||
if (scheme.value === 'pss' && !saltLengthCustomized.value) {
|
||||
saltLength.value = parsedKey.value
|
||||
? defaultPssSaltLength(parsedKey.value.modulusBits, hash.value)
|
||||
: hashLength(hash.value)
|
||||
}
|
||||
clearResult()
|
||||
})
|
||||
watch([keyMode, pemInput, modulusInput, exponentInput], invalidateKey)
|
||||
watch([dataSource, inlineDataMode, inlineData, signatureEncoding, signatureInput], clearResult)
|
||||
watch([fileMode, blockHeaderByteOrder], clearFile)
|
||||
onBeforeUnmount(() => {
|
||||
inlineRequestVersion += 1
|
||||
destroyWorkers()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ToolPage category="加密计算" title="RSA 签名验证" description="使用 RSA 公钥验证文本、Hex、原始文件、Intel HEX 有效数据或带 Block 校验头的数据签名。">
|
||||
<section class="signature-section">
|
||||
<div class="signature-section-heading"><strong>算法</strong><span>仅支持 RSA PKCS#1 v1.5 与 RSA-PSS</span></div>
|
||||
<div class="signature-config-grid">
|
||||
<div class="parameter-field"><span>签名算法</span><ModeTabs v-model="scheme" :options="schemeOptions" label="RSA 签名算法" :disabled="busy" /></div>
|
||||
<label class="parameter-field"><span>Hash 算法</span>
|
||||
<select id="signature-hash" name="signature-hash" v-model="hash" class="select-input" :disabled="busy">
|
||||
<option>SHA-256</option><option>SHA-384</option><option>SHA-512</option>
|
||||
</select>
|
||||
</label>
|
||||
<label v-if="scheme === 'pss'" class="parameter-field"><span>Salt Length(字节)</span>
|
||||
<input id="signature-salt-length" name="signature-salt-length" v-model.number="saltLength" class="text-input" type="number" min="0" :max="maximumSaltLength" step="1" :disabled="busy" @input="customizeSaltLength" />
|
||||
<small>默认取 Hash 长度与当前上限中的较小值{{ maximumSaltLength === undefined ? '' : ` · 当前最大 ${maximumSaltLength}` }}</small>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="signature-section">
|
||||
<div class="signature-section-heading"><strong>RSA 公钥</strong><span>证书只提取公钥,不验证有效期或信任链</span></div>
|
||||
<div class="signature-toolbar"><ModeTabs v-model="keyMode" :options="keyModeOptions" label="公钥输入格式" :disabled="busy" /></div>
|
||||
<TextEditor v-if="keyMode === 'pem'" id="signature-public-key-pem" v-model="pemInput" label="公钥或证书" format="SPKI / PKCS#1 / X.509 PEM" placeholder="粘贴且仅粘贴一个 PUBLIC KEY、RSA PUBLIC KEY 或 CERTIFICATE PEM 块" :disabled="busy" :rows="9" />
|
||||
<div v-else class="signature-key-grid">
|
||||
<TextEditor id="signature-modulus" v-model="modulusInput" label="模数 N" format="Hex" placeholder="连续 Hex,或使用空白、逗号分隔" :disabled="busy" :rows="7" />
|
||||
<TextEditor id="signature-exponent" v-model="exponentInput" label="指数 E" format="Hex 整数" placeholder="常用值:10001(十进制 65537)" :disabled="busy" :rows="3" />
|
||||
</div>
|
||||
<div v-if="parsedKey" class="signature-key-summary">
|
||||
<div><span>密钥</span><strong>RSA {{ parsedKey.modulusBits }} 位 · E={{ parsedKey.exponentDecimal }}</strong></div>
|
||||
<div><span>来源</span><strong>{{ parsedKey.source }}</strong></div>
|
||||
<div class="signature-fingerprint"><span>SPKI SHA-256 指纹</span><code>{{ parsedKey.fingerprintSha256 }}</code></div>
|
||||
<div v-if="parsedKey.certificate" class="signature-certificate-info">
|
||||
<span>证书 Subject</span><strong>{{ parsedKey.certificate.subject }}</strong>
|
||||
<small>{{ parsedKey.certificate.notBefore }} 至 {{ parsedKey.certificate.notAfter }} · Serial {{ parsedKey.certificate.serialNumber }}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="parsedKey && parsedKey.modulusBits < 2048" class="status-message warning"><ShieldAlert :size="16" />该 RSA 公钥低于 2048 位,仅建议用于兼容旧系统。</div>
|
||||
</section>
|
||||
|
||||
<section class="signature-section">
|
||||
<div class="signature-section-heading"><strong>待验证数据</strong><span>文件模式不会自动判断数据语义</span></div>
|
||||
<div class="signature-toolbar"><ModeTabs v-model="dataSource" :options="dataSourceOptions" label="待验证数据来源" :disabled="busy" /></div>
|
||||
<template v-if="dataSource === 'inline'">
|
||||
<div class="signature-toolbar"><ModeTabs v-model="inlineDataMode" :options="inlineModeOptions" label="待验证数据格式" :disabled="busy" /></div>
|
||||
<TextEditor id="signature-inline-data" v-model="inlineData" label="数据" :format="inlineDataMode === 'text' ? 'UTF-8 原文' : 'Hex 字节'" :meta="`${inlineData.length.toLocaleString()} 字符`" :placeholder="inlineDataMode === 'text' ? '输入原始文本;空文本也可以验证' : '输入待验证字节;空消息也可以验证'" :disabled="busy" :rows="8" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="signature-toolbar"><ModeTabs v-model="fileMode" :options="fileModeOptions" label="文件数据模式" :disabled="busy" /></div>
|
||||
<div v-if="fileMode === 'intel-hex-block-header'" class="signature-toolbar">
|
||||
<div class="parameter-field"><span>校验头字节序</span><ModeTabs v-model="blockHeaderByteOrder" :options="byteOrderOptions" label="Block 校验头字节序" :disabled="busy" /></div>
|
||||
</div>
|
||||
<FileDropField
|
||||
input-id="signature-data-file"
|
||||
replacement-input-id="signature-replacement-data-file"
|
||||
:accept="fileMode !== 'raw-file' ? '.hex,.ihx,text/plain' : '*/*'"
|
||||
:has-file="Boolean(activeFile)"
|
||||
:busy="parsing"
|
||||
:initial-label="fileMode !== 'raw-file' ? '选择或拖入 Intel HEX 文件' : '选择或拖入原始文件'"
|
||||
initial-busy-label="正在读取文件…"
|
||||
:hint="fileMode === 'intel-hex'
|
||||
? '按地址升序拼接有效数据,不填充地址空洞'
|
||||
: fileMode === 'intel-hex-block-header'
|
||||
? '每个 Block 前加入 startAddress + dataLength,共 8 个二进制字节'
|
||||
: '严格验证文件原始字节,空文件也允许'"
|
||||
replacement-busy-label="正在验证新文件…"
|
||||
compact
|
||||
@select="selectFile"
|
||||
@clear="clearFile"
|
||||
>
|
||||
<template #icon><FileSignature :size="28" aria-hidden="true" /></template>
|
||||
<template v-if="activeFile">
|
||||
<strong>{{ activeFile.name }}</strong>
|
||||
<span v-if="activeFile.mode === 'intel-hex-block-header'">Intel HEX · {{ activeFile.blockCount }} 个 block · {{ activeFile.effectiveDataBytes.toLocaleString() }} 有效字节 + {{ activeFile.headerBytes.toLocaleString() }} 头部字节 · {{ formatByteOrder(activeFile.byteOrder) }} · {{ formatFileSize(activeFile.size) }}</span>
|
||||
<span v-else>{{ activeFile.mode === 'intel-hex' ? `Intel HEX · ${activeFile.blockCount} 个 block · ` : '原始字节 · ' }}{{ activeFile.dataBytes.toLocaleString() }} 字节 · {{ formatFileSize(activeFile.size) }}</span>
|
||||
</template>
|
||||
</FileDropField>
|
||||
<div v-if="activeFile?.warnings.length" class="status-message warning"><AlertCircle :size="16" />{{ activeFile.warnings.join(';') }}</div>
|
||||
</template>
|
||||
</section>
|
||||
|
||||
<section class="signature-section">
|
||||
<div class="signature-section-heading"><strong>签名</strong><span v-if="expectedSignatureBytes">当前公钥要求 {{ expectedSignatureBytes }} 字节</span></div>
|
||||
<div class="signature-toolbar"><ModeTabs v-model="signatureEncoding" :options="signatureEncodingOptions" label="签名编码" :disabled="busy" /></div>
|
||||
<TextEditor id="signature-value" v-model="signatureInput" label="签名值" :format="signatureEncoding === 'base64url' ? 'Base64 URL' : signatureEncoding === 'base64' ? 'Base64' : 'Hex'" :meta="signatureBytes === undefined ? '' : `${signatureBytes} 字节`" placeholder="输入待验证签名" :disabled="busy" :rows="6" />
|
||||
</section>
|
||||
|
||||
<div class="action-row signature-actions">
|
||||
<button class="action-button primary" type="button" :disabled="busy" @click="verify"><Play :size="15" />{{ busy ? '正在处理…' : '验证签名' }}</button>
|
||||
<button class="action-button" type="button" :disabled="busy" @click="clearAll"><Trash2 :size="15" />清空</button>
|
||||
</div>
|
||||
|
||||
<section v-if="result" class="signature-result" :class="result.valid ? 'is-valid' : 'is-invalid'" aria-live="polite">
|
||||
<div class="signature-result-title">
|
||||
<CheckCircle2 v-if="result.valid" :size="22" aria-hidden="true" />
|
||||
<XCircle v-else :size="22" aria-hidden="true" />
|
||||
<div><strong>{{ result.valid ? '签名有效' : '签名无效' }}</strong><span>{{ algorithmLabel }}</span></div>
|
||||
</div>
|
||||
<dl>
|
||||
<div><dt>数据长度</dt><dd>{{ result.dataBytes.toLocaleString() }} 字节</dd></div>
|
||||
<div><dt>签名长度</dt><dd>{{ result.signatureBytes.toLocaleString() }} 字节</dd></div>
|
||||
<div><dt>验证实现</dt><dd>{{ result.provider }}</dd></div>
|
||||
<div><dt>数据来源</dt><dd>{{ dataSourceLabel }}</dd></div>
|
||||
<div v-if="parsedKey"><dt>公钥</dt><dd>RSA {{ parsedKey.modulusBits }} 位 · E={{ parsedKey.exponentDecimal }}</dd></div>
|
||||
<div v-if="parsedKey" class="signature-result-fingerprint"><dt>SPKI SHA-256 指纹</dt><dd><code>{{ parsedKey.fingerprintSha256 }}</code></dd></div>
|
||||
<div class="signature-result-hash"><dt>数据 {{ hash }}</dt><dd><code>{{ result.dataHash }}</code></dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
<div v-if="error" class="status-message error"><AlertCircle :size="16" />{{ error }}</div>
|
||||
<div v-else-if="success && !result" class="status-message success"><CheckCircle2 :size="16" />{{ success }}</div>
|
||||
</ToolPage>
|
||||
</template>
|
||||
@@ -79,23 +79,23 @@ function clearAll() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ToolPage category="编码转换" title="ASCII / Hex" description="在 UTF-8 文本与十六进制字节之间转换。">
|
||||
<ToolPage category="编码转换" title="UTF-8 / Hex" description="在 UTF-8 文本与十六进制字节之间转换。">
|
||||
<div class="tool-controls">
|
||||
<ModeTabs v-model="mode" :options="modes" label="转换方向" />
|
||||
<span class="control-spacer" />
|
||||
<label v-if="mode === 'text-to-hex'" class="compact-field">分隔符
|
||||
<select v-model="delimiter">
|
||||
<select id="utf8-hex-delimiter" name="utf8-hex-delimiter" v-model="delimiter">
|
||||
<option value=" ">空格</option>
|
||||
<option value="">无</option>
|
||||
<option value=",">逗号</option>
|
||||
</select>
|
||||
</label>
|
||||
<label v-if="mode === 'text-to-hex'" class="check-field"><input v-model="uppercase" type="checkbox" />大写 Hex</label>
|
||||
<label v-if="mode === 'text-to-hex'" class="check-field"><input id="utf8-hex-uppercase" name="utf8-hex-uppercase" v-model="uppercase" type="checkbox" />大写 Hex</label>
|
||||
</div>
|
||||
<div class="editor-grid">
|
||||
<TextEditor v-model="input" label="输入" :format="mode === 'text-to-hex' ? 'UTF-8 文本' : 'Hex 字节'" :meta="inputMeta" placeholder="输入待转换内容" />
|
||||
<TextEditor id="utf8-hex-input" v-model="input" label="输入" :format="mode === 'text-to-hex' ? 'UTF-8 文本' : 'Hex 字节'" :meta="inputMeta" placeholder="输入待转换内容" />
|
||||
<button class="swap-button" type="button" aria-label="交换输入输出" @click="swap"><ArrowLeftRight :size="16" /></button>
|
||||
<TextEditor v-model="output" label="结果" :format="mode === 'text-to-hex' ? 'Hex 字节' : 'UTF-8 文本'" :meta="outputMeta" readonly />
|
||||
<TextEditor id="utf8-hex-output" v-model="output" label="结果" :format="mode === 'text-to-hex' ? 'Hex 字节' : 'UTF-8 文本'" :meta="outputMeta" readonly />
|
||||
</div>
|
||||
<div class="action-row">
|
||||
<button class="action-button primary" type="button" @click="convert"><Play :size="15" />转换</button>
|
||||
+7
-12
@@ -6,8 +6,9 @@ import SyntaxViewer from '@/components/SyntaxViewer.vue'
|
||||
import TextEditor from '@/components/TextEditor.vue'
|
||||
import ToolPage from '@/components/ToolPage.vue'
|
||||
import { copyText } from '@/lib/browser'
|
||||
import { validateXmlDocument } from '@/lib/xml'
|
||||
|
||||
const input = ref('<tools><tool id="hex">ASCII / Hex</tool><tool id="cmac">AES-128-CMAC</tool></tools>')
|
||||
const input = ref('<tools><tool id="hex">UTF-8 / Hex</tool><tool id="cmac">AES-CMAC</tool></tools>')
|
||||
const output = ref('')
|
||||
const indent = ref('2')
|
||||
const error = ref('')
|
||||
@@ -21,25 +22,19 @@ function clearResult() {
|
||||
|
||||
watch([input, indent], clearResult)
|
||||
|
||||
function validateXml() {
|
||||
if (!input.value.trim()) throw new Error('请输入 XML 内容')
|
||||
const document = new DOMParser().parseFromString(input.value, 'application/xml')
|
||||
const parserError = document.querySelector('parsererror')
|
||||
if (parserError) throw new Error(parserError.textContent?.split('\n')[0] || 'XML 格式无效')
|
||||
}
|
||||
function format() {
|
||||
try {
|
||||
validateXml()
|
||||
validateXmlDocument(input.value)
|
||||
output.value = xmlFormatter(input.value, { indentation: ' '.repeat(Number(indent.value)), lineSeparator: '\n', strictMode: true })
|
||||
error.value = ''; success.value = 'XML 格式正确'
|
||||
} catch (reason) { output.value = ''; success.value = ''; error.value = reason instanceof Error ? reason.message : 'XML 无效' }
|
||||
}
|
||||
function minify() {
|
||||
try { validateXml(); output.value = xmlFormatter.minify(input.value, { strictMode: true }); error.value = ''; success.value = 'XML 已压缩' }
|
||||
try { validateXmlDocument(input.value); output.value = xmlFormatter.minify(input.value, { strictMode: true }); error.value = ''; success.value = 'XML 已压缩' }
|
||||
catch (reason) { output.value = ''; success.value = ''; error.value = reason instanceof Error ? reason.message : 'XML 无效' }
|
||||
}
|
||||
function validate() {
|
||||
try { validateXml(); error.value = ''; success.value = 'XML 格式正确' }
|
||||
try { validateXmlDocument(input.value); error.value = ''; success.value = 'XML 格式正确' }
|
||||
catch (reason) { success.value = ''; error.value = reason instanceof Error ? reason.message : 'XML 无效' }
|
||||
}
|
||||
async function copyResult() { try { await copyText(output.value); success.value = '结果已复制' } catch { error.value = '复制失败' } }
|
||||
@@ -50,11 +45,11 @@ function clearAll() { input.value = ''; clearResult() }
|
||||
<ToolPage category="格式化" title="XML" description="格式化、压缩并校验 XML 字符串。">
|
||||
<div class="tool-controls">
|
||||
<label class="compact-field">缩进
|
||||
<select v-model="indent"><option value="2">2 空格</option><option value="4">4 空格</option></select>
|
||||
<select id="xml-indent" name="xml-indent" v-model="indent"><option value="2">2 空格</option><option value="4">4 空格</option></select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="single-editor-layout">
|
||||
<TextEditor v-model="input" label="输入" format="XML" :meta="`${input.length} 字符`" placeholder="粘贴 XML 字符串" :rows="14" />
|
||||
<TextEditor id="xml-input" v-model="input" label="输入" format="XML" :meta="`${input.length} 字符`" placeholder="粘贴 XML 字符串" :rows="14" />
|
||||
<SyntaxViewer :value="output" label="结果" language="xml" format="XML" :meta="`${output.length} 字符`" />
|
||||
</div>
|
||||
<div class="action-row">
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
Hash,
|
||||
Image,
|
||||
KeyRound,
|
||||
Signature,
|
||||
ShieldCheck,
|
||||
} from '@lucide/vue'
|
||||
import { defineAsyncComponent, type Component } from 'vue'
|
||||
@@ -24,6 +25,7 @@ export const iconComponents: Record<string, Component> = {
|
||||
Hash,
|
||||
Image,
|
||||
KeyRound,
|
||||
Signature,
|
||||
ShieldCheck,
|
||||
}
|
||||
|
||||
@@ -36,12 +38,14 @@ export const tools = (catalogData.tools as CatalogTool[]).map((tool) => {
|
||||
|
||||
export const categoryById = new Map(categories.map((category) => [category.id, category]))
|
||||
export const toolById = new Map(tools.map((tool) => [tool.id, tool]))
|
||||
const reservedToolIds = new Set(['setting'])
|
||||
|
||||
export const toolComponents: Record<string, Component> = {
|
||||
'ascii-hex': defineAsyncComponent(() => import('./AsciiHexTool.vue')),
|
||||
'utf8-hex': defineAsyncComponent(() => import('./Utf8HexTool.vue')),
|
||||
base64: defineAsyncComponent(() => import('./Base64Tool.vue')),
|
||||
'aes-cmac': defineAsyncComponent(() => import('./AesCmacTool.vue')),
|
||||
'rsa-key-pair': defineAsyncComponent(() => import('./RsaKeyPairTool.vue')),
|
||||
'signature-verify': defineAsyncComponent(() => import('./SignatureVerifyTool.vue')),
|
||||
random: defineAsyncComponent(() => import('./RandomTool.vue')),
|
||||
crc: defineAsyncComponent(() => import('./CrcTool.vue')),
|
||||
'firmware-hash': defineAsyncComponent(() => import('./FirmwareHashTool.vue')),
|
||||
@@ -59,6 +63,7 @@ export function assertToolRegistryConsistency() {
|
||||
|
||||
const toolIds = new Set<string>()
|
||||
for (const tool of tools) {
|
||||
if (reservedToolIds.has(tool.id)) throw new Error(`工具 ID 为保留值:${tool.id}`)
|
||||
if (toolIds.has(tool.id)) throw new Error(`工具 ID 重复:${tool.id}`)
|
||||
if (!categoryIds.has(tool.category)) throw new Error(`工具 ${tool.id} 使用了不存在的分类 ${tool.category}`)
|
||||
toolIds.add(tool.id)
|
||||
|
||||
@@ -145,7 +145,7 @@ onMounted(async () => {
|
||||
<GripVertical class="drag-handle" :size="16" aria-hidden="true" />
|
||||
<span class="tool-row-icon"><component :is="toolById.get(toolId)?.iconComponent" :size="15" /></span>
|
||||
<strong>{{ toolById.get(toolId)?.title }}</strong>
|
||||
<select class="select-input" :value="navigationCategory.id" aria-label="所属分类" @change="changeToolCategory(toolId, categoryIndex, ($event.target as HTMLSelectElement).value)">
|
||||
<select :id="`setting-tool-category-${toolId}`" :name="`setting-tool-category-${toolId}`" class="select-input" :value="navigationCategory.id" aria-label="所属分类" @change="changeToolCategory(toolId, categoryIndex, ($event.target as HTMLSelectElement).value)">
|
||||
<option v-for="category in categories" :key="category.id" :value="category.id">{{ category.title }}</option>
|
||||
</select>
|
||||
<span class="row-actions">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { calculateCrcReport, type CrcWorkerRequest, type CrcWorkerResponse } from '@/lib/crcWorker'
|
||||
import { calculateCrcHexInput, calculateCrcReport, type CrcWorkerRequest, type CrcWorkerResponse } from '@/lib/crcWorker'
|
||||
import { getFirmwareFormat, MAX_FIRMWARE_FILE_SIZE, parseFirmwareContent } from '@/lib/firmware'
|
||||
import type { DataBlock } from '@/lib/intelHex'
|
||||
|
||||
@@ -31,6 +31,14 @@ workerScope.onmessage = async (event) => {
|
||||
})
|
||||
return
|
||||
}
|
||||
if (request.type === 'calculate-hex') {
|
||||
workerScope.postMessage({
|
||||
type: 'calculated-hex',
|
||||
id: request.id,
|
||||
...calculateCrcHexInput(request.input, request.parameters),
|
||||
})
|
||||
return
|
||||
}
|
||||
if (!firmwareBlocks) throw new Error('请先选择 BIN 或 Intel HEX 文件')
|
||||
workerScope.postMessage({ type: 'calculated', id: request.id, ...calculateCrcReport(firmwareBlocks, request.parameters) })
|
||||
} catch (reason) {
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { verifyRsaSignature } from '@/lib/signatureVerification'
|
||||
import { parseSignatureFile } from '@/lib/signatureVerificationFile'
|
||||
import type {
|
||||
SignatureVerificationWorkerRequest,
|
||||
SignatureVerificationWorkerResponse,
|
||||
} from '@/lib/signatureVerificationWorker'
|
||||
|
||||
const workerScope = globalThis as unknown as {
|
||||
onmessage: ((event: MessageEvent<SignatureVerificationWorkerRequest>) => void) | null
|
||||
postMessage(message: SignatureVerificationWorkerResponse): void
|
||||
}
|
||||
|
||||
let activeData: Uint8Array | undefined
|
||||
|
||||
workerScope.onmessage = async (event) => {
|
||||
const request = event.data
|
||||
try {
|
||||
if (request.type === 'parse') {
|
||||
const parsed = await parseSignatureFile(request.file, request.mode, request.byteOrder)
|
||||
activeData = parsed.data
|
||||
workerScope.postMessage({
|
||||
type: 'parsed',
|
||||
id: request.id,
|
||||
mode: request.mode,
|
||||
byteOrder: request.byteOrder,
|
||||
dataBytes: parsed.dataBytes,
|
||||
effectiveDataBytes: parsed.effectiveDataBytes,
|
||||
headerBytes: parsed.headerBytes,
|
||||
blockCount: parsed.blockCount,
|
||||
warnings: parsed.warnings,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (!activeData) throw new Error('请先选择待验证文件')
|
||||
const result = await verifyRsaSignature(request.key, activeData, request.signature, request)
|
||||
workerScope.postMessage({ type: 'calculated', id: request.id, ...result })
|
||||
} catch (reason) {
|
||||
workerScope.postMessage({
|
||||
type: 'error',
|
||||
id: request.id,
|
||||
operation: request.type,
|
||||
error: reason instanceof Error ? reason.message : '签名验证失败',
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
formatBytes,
|
||||
parseHex,
|
||||
textToBytes,
|
||||
textToBytesLimited,
|
||||
} from '../src/lib/bytes'
|
||||
|
||||
describe('parseHex', () => {
|
||||
@@ -25,6 +26,22 @@ describe('parseHex', () => {
|
||||
expect(() => parseHex('AA0xBB')).toThrow('无效')
|
||||
expect(() => parseHex('ABC')).toThrow('偶数')
|
||||
expect(() => parseHex('0011', 16)).toThrow('当前为 2 字节')
|
||||
expect(() => parseHex(',AA')).toThrow('片段缺少')
|
||||
expect(() => parseHex('AA,')).toThrow('片段缺少')
|
||||
expect(() => parseHex('AA,,BB')).toThrow('片段缺少')
|
||||
expect(() => parseHex('AA, ,BB')).toThrow('片段缺少')
|
||||
})
|
||||
|
||||
it('supports bounded parsing without allocating oversized output', () => {
|
||||
expect(parseHex('00 11', { maxBytes: 2 })).toEqual(Uint8Array.from([0, 0x11]))
|
||||
expect(() => parseHex('00 11 22', { maxBytes: 2, limitLabel: '测试数据' })).toThrow('测试数据不能超过')
|
||||
expect(() => parseHex('0'.repeat(2_000), { maxInputChars: 100 })).toThrow('输入不能超过')
|
||||
})
|
||||
|
||||
it('encodes bounded UTF-8 text before allocating the result', () => {
|
||||
expect(textToBytesLimited('你好', 6)).toEqual(textToBytes('你好'))
|
||||
expect(() => textToBytesLimited('你好', 5)).toThrow('不能超过 5 字节')
|
||||
expect(() => textToBytesLimited('abc', 10, 2, '测试数据')).toThrow('输入不能超过')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -50,6 +67,9 @@ describe('UTF-8 and Base64', () => {
|
||||
it('rejects invalid Base64', () => {
|
||||
expect(() => decodeBase64('abcde')).toThrow('长度')
|
||||
expect(() => decodeBase64('ab*c')).toThrow('无效')
|
||||
expect(() => decodeBase64('TQ=')).toThrow('填充长度')
|
||||
expect(() => decodeBase64('TWE==')).toThrow('填充长度')
|
||||
expect(() => decodeBase64('T=Q=')).toThrow('填充位置')
|
||||
expect(() => decodeBase64('Zh==')).toThrow('无效')
|
||||
expect(() => decodeBase64('Zm9=')).toThrow('无效')
|
||||
})
|
||||
|
||||
@@ -25,4 +25,11 @@ describe('tool catalog consistency', () => {
|
||||
expect(navigationToolIds.sort()).toEqual(catalog.tools.map(({ id }) => id).sort())
|
||||
expect(new Set(navigationToolIds).size).toBe(navigationToolIds.length)
|
||||
})
|
||||
|
||||
it('places checksum tools before cryptographic tools by default', () => {
|
||||
const categoryIds = defaultNavigation.categories.map(({ id }) => id)
|
||||
expect(categoryIds.indexOf('checksum')).toBeGreaterThanOrEqual(0)
|
||||
expect(categoryIds.indexOf('crypto')).toBeGreaterThanOrEqual(0)
|
||||
expect(categoryIds.indexOf('checksum')).toBeLessThan(categoryIds.indexOf('crypto'))
|
||||
})
|
||||
})
|
||||
|
||||
+40
-1
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { bytesToHex, parseHex } from '../src/lib/bytes'
|
||||
import { aes128Cmac } from '../src/lib/cmac'
|
||||
import { aes128Cmac, aesCmac } from '../src/lib/cmac'
|
||||
|
||||
describe('AES-128-CMAC RFC 4493 vectors', () => {
|
||||
const key = parseHex('2B7E151628AED2A6ABF7158809CF4F3C')
|
||||
@@ -15,3 +15,42 @@ describe('AES-128-CMAC RFC 4493 vectors', () => {
|
||||
expect(bytesToHex(aes128Cmac(key, parseHex(message)), '')).toBe(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe('AES-CMAC NIST SP 800-38B vectors', () => {
|
||||
it.each([
|
||||
{
|
||||
name: 'AES-192 empty message',
|
||||
key: '8E73B0F7DA0E6452C810F32B809079E562F8EAD2522C6B7B',
|
||||
message: '',
|
||||
expected: 'D17DDF46ADAACDE531CAC483DE7A9367',
|
||||
},
|
||||
{
|
||||
name: 'AES-192 one block',
|
||||
key: '8E73B0F7DA0E6452C810F32B809079E562F8EAD2522C6B7B',
|
||||
message: '6BC1BEE22E409F96E93D7E117393172A',
|
||||
expected: '9E99A7BF31E710900662F65E617C5184',
|
||||
},
|
||||
{
|
||||
name: 'AES-256 empty message',
|
||||
key: '603DEB1015CA71BE2B73AEF0857D77811F352C073B6108D72D9810A30914DFF4',
|
||||
message: '',
|
||||
expected: '028962F61B7BF89EFC6B551F4667D983',
|
||||
},
|
||||
{
|
||||
name: 'AES-256 one block',
|
||||
key: '603DEB1015CA71BE2B73AEF0857D77811F352C073B6108D72D9810A30914DFF4',
|
||||
message: '6BC1BEE22E409F96E93D7E117393172A',
|
||||
expected: '28A7023F452E8F82BD4BF28D8C37C35C',
|
||||
},
|
||||
])('matches $name', ({ key, message, expected }) => {
|
||||
expect(bytesToHex(aesCmac(parseHex(key), parseHex(message)), '')).toBe(expected)
|
||||
})
|
||||
|
||||
it.each([0, 15, 17, 23, 25, 31, 33])('rejects a %s-byte key', (length) => {
|
||||
expect(() => aesCmac(new Uint8Array(length), new Uint8Array())).toThrow('16、24 或 32 字节')
|
||||
})
|
||||
|
||||
it('keeps the AES-128 compatibility wrapper strict', () => {
|
||||
expect(() => aes128Cmac(new Uint8Array(24), new Uint8Array())).toThrow('AES-128 密钥必须是 16 字节')
|
||||
})
|
||||
})
|
||||
|
||||
+123
-26
@@ -1,6 +1,18 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { calculateCrc, calculateCrcChunks, CRC_PRESETS, formatCrc } from '../src/lib/crc'
|
||||
import { calculateCrcReport } from '../src/lib/crcWorker'
|
||||
import {
|
||||
calculateCrc,
|
||||
calculateCrcChunks,
|
||||
CRC_PRESETS,
|
||||
DEFAULT_CRC_PRESET,
|
||||
formatCrc,
|
||||
type CrcPreset,
|
||||
} from '../src/lib/crc'
|
||||
import {
|
||||
calculateCrcHexInput,
|
||||
calculateCrcReport,
|
||||
MAX_INLINE_CRC_DATA_BYTES,
|
||||
MAX_INLINE_CRC_INPUT_CHARS,
|
||||
} from '../src/lib/crcWorker'
|
||||
import { getFirmwareFormat, parseFirmwareContent } from '../src/lib/firmware'
|
||||
import { concatenateBlocks, parseIntelHex } from '../src/lib/intelHex'
|
||||
import { textToBytes } from '../src/lib/bytes'
|
||||
@@ -11,45 +23,121 @@ function intelHexRecord(address: number, data: number[]): string {
|
||||
return `:${[...bytes, checksum].map((byte) => byte.toString(16).padStart(2, '0')).join('').toUpperCase()}`
|
||||
}
|
||||
|
||||
const STANDARD_CRC_PRESETS = [
|
||||
{ id: 'crc-32-iso-hdlc', name: 'CRC-32/ISO-HDLC', width: 32, polynomial: 0x04c11db7n, initialValue: 0xffffffffn, inputReflected: true, outputReflected: true, xorOut: 0xffffffffn, check: 0xcbf43926n },
|
||||
{ id: 'crc-32-iscsi', name: 'CRC-32/ISCSI (CRC-32C)', width: 32, polynomial: 0x1edc6f41n, initialValue: 0xffffffffn, inputReflected: true, outputReflected: true, xorOut: 0xffffffffn, check: 0xe3069283n },
|
||||
{ id: 'crc-32-mpeg-2', name: 'CRC-32/MPEG-2', width: 32, polynomial: 0x04c11db7n, initialValue: 0xffffffffn, inputReflected: false, outputReflected: false, xorOut: 0n, check: 0x0376e6e7n },
|
||||
{ id: 'crc-16-arc', name: 'CRC-16/ARC', width: 16, polynomial: 0x8005n, initialValue: 0n, inputReflected: true, outputReflected: true, xorOut: 0n, check: 0xbb3dn },
|
||||
{ id: 'crc-16-dnp', name: 'CRC-16/DNP', width: 16, polynomial: 0x3d65n, initialValue: 0n, inputReflected: true, outputReflected: true, xorOut: 0xffffn, check: 0xea82n },
|
||||
{ id: 'crc-16-ccitt-false', name: 'CRC-16/IBM-3740 (CCITT-FALSE)', width: 16, polynomial: 0x1021n, initialValue: 0xffffn, inputReflected: false, outputReflected: false, xorOut: 0n, check: 0x29b1n },
|
||||
{ id: 'crc-16-ibm-sdlc', name: 'CRC-16/IBM-SDLC (X-25)', width: 16, polynomial: 0x1021n, initialValue: 0xffffn, inputReflected: true, outputReflected: true, xorOut: 0xffffn, check: 0x906en },
|
||||
{ id: 'crc-16-kermit', name: 'CRC-16/KERMIT (CCITT)', width: 16, polynomial: 0x1021n, initialValue: 0n, inputReflected: true, outputReflected: true, xorOut: 0n, check: 0x2189n },
|
||||
{ id: 'crc-16-modbus', name: 'CRC-16/MODBUS', width: 16, polynomial: 0x8005n, initialValue: 0xffffn, inputReflected: true, outputReflected: true, xorOut: 0n, check: 0x4b37n },
|
||||
{ id: 'crc-16-usb', name: 'CRC-16/USB', width: 16, polynomial: 0x8005n, initialValue: 0xffffn, inputReflected: true, outputReflected: true, xorOut: 0xffffn, check: 0xb4c8n },
|
||||
{ id: 'crc-16-xmodem', name: 'CRC-16/XMODEM', width: 16, polynomial: 0x1021n, initialValue: 0n, inputReflected: false, outputReflected: false, xorOut: 0n, check: 0x31c3n },
|
||||
{ id: 'crc-8', name: 'CRC-8/SMBUS (CRC-8)', width: 8, polynomial: 0x07n, initialValue: 0n, inputReflected: false, outputReflected: false, xorOut: 0n, check: 0xf4n },
|
||||
{ id: 'crc-8-maxim-dow', name: 'CRC-8/MAXIM-DOW', width: 8, polynomial: 0x31n, initialValue: 0n, inputReflected: true, outputReflected: true, xorOut: 0n, check: 0xa1n },
|
||||
{ id: 'crc-8-sae-j1850', name: 'CRC-8/SAE-J1850', width: 8, polynomial: 0x1dn, initialValue: 0xffn, inputReflected: false, outputReflected: false, xorOut: 0xffn, check: 0x4bn },
|
||||
{ id: 'crc-64-ecma-182', name: 'CRC-64/ECMA-182', width: 64, polynomial: 0x42f0e1eba9ea3693n, initialValue: 0n, inputReflected: false, outputReflected: false, xorOut: 0n, check: 0x6c40df5f0b497347n },
|
||||
{ id: 'crc-64-xz', name: 'CRC-64/XZ', width: 64, polynomial: 0x42f0e1eba9ea3693n, initialValue: 0xffffffffffffffffn, inputReflected: true, outputReflected: true, xorOut: 0xffffffffffffffffn, check: 0x995dc9bbdf1939fan },
|
||||
] as const satisfies readonly CrcPreset[]
|
||||
|
||||
function presetById(id: string): CrcPreset {
|
||||
const preset = CRC_PRESETS.find((candidate) => candidate.id === id)
|
||||
if (!preset) throw new Error(`Missing test preset: ${id}`)
|
||||
return preset
|
||||
}
|
||||
|
||||
describe('CRC', () => {
|
||||
it('publishes the reviewed canonical preset names and parameters', () => {
|
||||
expect(CRC_PRESETS).toEqual(STANDARD_CRC_PRESETS)
|
||||
expect(DEFAULT_CRC_PRESET).toEqual(STANDARD_CRC_PRESETS[0])
|
||||
expect(new Set(CRC_PRESETS.map((preset) => preset.id)).size).toBe(CRC_PRESETS.length)
|
||||
expect(new Set(CRC_PRESETS.map((preset) => preset.name)).size).toBe(CRC_PRESETS.length)
|
||||
expect(new Set(CRC_PRESETS.map((preset) => preset.width))).toEqual(new Set([8, 16, 32, 64]))
|
||||
|
||||
const parameterSignatures = CRC_PRESETS.map((preset) => [
|
||||
preset.width,
|
||||
preset.polynomial,
|
||||
preset.initialValue,
|
||||
preset.inputReflected,
|
||||
preset.outputReflected,
|
||||
preset.xorOut,
|
||||
].join(':'))
|
||||
expect(new Set(parameterSignatures).size).toBe(CRC_PRESETS.length)
|
||||
})
|
||||
|
||||
it('matches standard check values for 123456789', () => {
|
||||
const input = textToBytes('123456789')
|
||||
for (const preset of CRC_PRESETS) {
|
||||
for (const preset of STANDARD_CRC_PRESETS) {
|
||||
expect(calculateCrc(input, preset), preset.name).toBe(preset.check)
|
||||
expect(formatCrc(preset.check, preset.width)).toHaveLength(Math.ceil(preset.width / 4))
|
||||
}
|
||||
})
|
||||
|
||||
it('supports CRC-64 / ECMA-182 parameters', () => {
|
||||
expect(calculateCrc(textToBytes('123456789'), {
|
||||
width: 64,
|
||||
polynomial: 0x42f0e1eba9ea3693n,
|
||||
initialValue: 0n,
|
||||
inputReflected: false,
|
||||
outputReflected: false,
|
||||
xorOut: 0n,
|
||||
})).toBe(0x6c40df5f0b497347n)
|
||||
})
|
||||
|
||||
it('supports reflected CRC-64 / XZ parameters', () => {
|
||||
expect(calculateCrc(textToBytes('123456789'), {
|
||||
width: 64,
|
||||
polynomial: 0x42f0e1eba9ea3693n,
|
||||
initialValue: 0xffffffffffffffffn,
|
||||
inputReflected: true,
|
||||
outputReflected: true,
|
||||
xorOut: 0xffffffffffffffffn,
|
||||
})).toBe(0x995dc9bbdf1939fan)
|
||||
it('handles empty input and fixed-width result boundaries', () => {
|
||||
for (const preset of STANDARD_CRC_PRESETS) {
|
||||
const mask = (1n << BigInt(preset.width)) - 1n
|
||||
expect(calculateCrc(new Uint8Array(), preset), preset.name).toBe((preset.initialValue ^ preset.xorOut) & mask)
|
||||
}
|
||||
expect(formatCrc(0n, 8)).toBe('00')
|
||||
expect(formatCrc(0xffffffffffffffffn, 64)).toBe('FFFFFFFFFFFFFFFF')
|
||||
})
|
||||
|
||||
it('calculates chunked data as one continuous byte stream', () => {
|
||||
const preset = CRC_PRESETS[0]
|
||||
const chunks = [textToBytes('123'), textToBytes('456'), textToBytes('789')]
|
||||
expect(calculateCrcChunks(chunks, preset)).toBe(calculateCrc(textToBytes('123456789'), preset))
|
||||
for (const preset of [DEFAULT_CRC_PRESET, presetById('crc-64-xz')]) {
|
||||
expect(calculateCrcChunks(chunks, preset)).toBe(calculateCrc(textToBytes('123456789'), preset))
|
||||
}
|
||||
})
|
||||
|
||||
it('calculates inline Hex with the same separators used by other tools', () => {
|
||||
const preset = DEFAULT_CRC_PRESET
|
||||
const inputs = [
|
||||
'A51F7ADD',
|
||||
'A5 1F 7A DD',
|
||||
'A5,1F,7A,DD',
|
||||
'A5,1F,7A,DD',
|
||||
'0xA5 0x1F 0x7A 0xDD',
|
||||
'0xa5, 1F\n0x7a,DD',
|
||||
]
|
||||
for (const input of inputs) {
|
||||
expect(calculateCrcHexInput(input, preset)).toEqual({
|
||||
combinedResult: '6139ED73',
|
||||
totalBytes: 4,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('calculates the CCITT/KERMIT preset through the inline Hex path', () => {
|
||||
expect(calculateCrcHexInput('31 32 33 34 35 36 37 38 39', presetById('crc-16-kermit'))).toEqual({
|
||||
combinedResult: '2189',
|
||||
totalBytes: 9,
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects empty, malformed, and oversized inline Hex', () => {
|
||||
const preset = DEFAULT_CRC_PRESET
|
||||
expect(() => calculateCrcHexInput(' ', preset)).toThrow('请输入 Hex 数据')
|
||||
expect(() => calculateCrcHexInput('ABC', preset)).toThrow('偶数')
|
||||
expect(() => calculateCrcHexInput('A5,,1F', preset)).toThrow('片段缺少')
|
||||
expect(() => calculateCrcHexInput('GG', preset)).toThrow('无效')
|
||||
expect(() => calculateCrcHexInput('AA'.repeat(MAX_INLINE_CRC_DATA_BYTES + 1), preset)).toThrow('Hex 数据不能超过 1 MiB')
|
||||
expect(() => calculateCrcHexInput(' '.repeat(MAX_INLINE_CRC_INPUT_CHARS + 1), preset)).toThrow('输入不能超过')
|
||||
})
|
||||
|
||||
it('rejects malformed runtime parameters instead of coercing them', () => {
|
||||
const preset = DEFAULT_CRC_PRESET
|
||||
expect(() => calculateCrc(new Uint8Array(), { ...preset, inputReflected: undefined as never })).toThrow('布尔值')
|
||||
expect(() => calculateCrc(new Uint8Array(), { ...preset, polynomial: 7 as never })).toThrow('BigInt')
|
||||
expect(() => formatCrc(0x100n, 8)).toThrow('超出当前位宽')
|
||||
expect(() => formatCrc(0n, 7)).toThrow('位宽')
|
||||
expect(() => calculateCrc(new Uint8Array(), { ...preset, width: 65 })).toThrow('位宽')
|
||||
expect(() => calculateCrc(new Uint8Array(), { ...preset, polynomial: 0n })).toThrow('多项式')
|
||||
})
|
||||
|
||||
it('reports both the combined CRC and each block CRC', () => {
|
||||
const parameters = CRC_PRESETS.find((preset) => preset.id === 'crc-32-iso-hdlc')!
|
||||
const parameters = DEFAULT_CRC_PRESET
|
||||
const blocks = [
|
||||
{ startAddress: 0x1000, data: textToBytes('1234') },
|
||||
{ startAddress: 0x2000, data: textToBytes('56789') },
|
||||
@@ -61,6 +149,15 @@ describe('CRC', () => {
|
||||
{ startAddress: 0x2000, endAddress: 0x2004, length: 5, crc: formatCrc(calculateCrc(blocks[1].data, parameters), 32) },
|
||||
])
|
||||
})
|
||||
|
||||
it('preserves full 64-bit CRC values in block reports', () => {
|
||||
const parameters = presetById('crc-64-xz')
|
||||
const report = calculateCrcReport([{ startAddress: 0, data: textToBytes('123456789') }], parameters)
|
||||
expect(report.combinedResult).toBe('995DC9BBDF1939FA')
|
||||
expect(report.blockResults).toEqual([
|
||||
{ startAddress: 0, endAddress: 8, length: 9, crc: '995DC9BBDF1939FA' },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Intel HEX', () => {
|
||||
|
||||
@@ -95,4 +95,28 @@ describe('deployment configuration', () => {
|
||||
expect(agentGuide).toContain('git status --short --branch')
|
||||
expect(completeReadme).toContain('## AI 接手清单')
|
||||
})
|
||||
|
||||
it('keeps the management page under the reserved tools namespace', async () => {
|
||||
const router = await readProjectFile('src/router.ts')
|
||||
|
||||
expect(router).toContain("path: '/tools/setting'")
|
||||
expect(router).not.toContain("path: '/setting'")
|
||||
})
|
||||
|
||||
it('uses the UTF-8 / Hex tool ID as the canonical default route', async () => {
|
||||
const [router, catalogText, navigationText, registry] = await Promise.all([
|
||||
readProjectFile('src/router.ts'),
|
||||
readProjectFile('shared/tool-catalog.json'),
|
||||
readProjectFile('shared/default-navigation.json'),
|
||||
readProjectFile('src/tools/registry.ts'),
|
||||
])
|
||||
const catalog = JSON.parse(catalogText)
|
||||
const navigation = JSON.parse(navigationText)
|
||||
|
||||
expect(catalog.tools.find(({ title }) => title === 'UTF-8 / Hex')?.id).toBe('utf8-hex')
|
||||
expect(navigation.categories.flatMap(({ tools }) => tools)).toContain('utf8-hex')
|
||||
expect(router).toContain("redirect: '/tools/utf8-hex'")
|
||||
expect(router).not.toContain("'/tools/ascii-hex'")
|
||||
expect(registry).toContain("'utf8-hex': defineAsyncComponent(() => import('./Utf8HexTool.vue'))")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { readdir, readFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
||||
|
||||
async function findVueFiles(directory) {
|
||||
const entries = await readdir(directory, { withFileTypes: true })
|
||||
const files = await Promise.all(entries.map((entry) => {
|
||||
const entryPath = path.join(directory, entry.name)
|
||||
if (entry.isDirectory()) return findVueFiles(entryPath)
|
||||
return entry.isFile() && entry.name.endsWith('.vue') ? [entryPath] : []
|
||||
}))
|
||||
return files.flat()
|
||||
}
|
||||
|
||||
describe('form field identity', () => {
|
||||
it('gives every native form field an id and name', async () => {
|
||||
const vueFiles = await findVueFiles(path.join(projectRoot, 'src'))
|
||||
const missing = []
|
||||
|
||||
for (const filePath of vueFiles) {
|
||||
const source = await readFile(filePath, 'utf8')
|
||||
for (const match of source.matchAll(/<(?:input|select|textarea)\b[^>]*>/gs)) {
|
||||
const tag = match[0]
|
||||
const line = source.slice(0, match.index).split('\n').length
|
||||
if (!/\s(?::)?id=/.test(tag) || !/\s(?::)?name=/.test(tag)) {
|
||||
missing.push(`${path.relative(projectRoot, filePath)}:${line} ${tag.replace(/\s+/g, ' ')}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(missing).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps automatic PSS salt correction separate from user edits', async () => {
|
||||
const source = await readFile(path.join(projectRoot, 'src/tools/SignatureVerifyTool.vue'), 'utf8')
|
||||
expect(source).toContain("const scheme = ref<RsaSignatureScheme>('pss')")
|
||||
expect(source).toContain('@input="customizeSaltLength"')
|
||||
expect(source).toContain('function customizeSaltLength()')
|
||||
expect(source).not.toMatch(/watch\s*\(\s*saltLength\b/)
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
DEFAULT_MAX_INTEL_HEX_BYTES,
|
||||
DEFAULT_MAX_INTEL_HEX_RECORDS,
|
||||
MAX_UINT32_ADDRESS,
|
||||
parseIntelHex,
|
||||
@@ -63,6 +64,16 @@ describe('Intel HEX structural limits', () => {
|
||||
expect(() => parseIntelHex(record(0, 0, [1]), { maxRecords: 1.5 })).toThrow('正整数')
|
||||
expect(() => parseIntelHex(record(0, 0, [1]), { maxRecords: Number.POSITIVE_INFINITY })).toThrow('正整数')
|
||||
expect(() => parseIntelHex(record(0, 0, [1]), { maxRecords: Number.MAX_SAFE_INTEGER + 1 })).toThrow('正整数')
|
||||
expect(() => parseIntelHex(record(0, 0, [1]), { maxBytes: 0 })).toThrow('正整数')
|
||||
expect(DEFAULT_MAX_INTEL_HEX_BYTES).toBe(64 * 1024 * 1024)
|
||||
})
|
||||
|
||||
it('rejects a merged data block before allocating beyond the byte limit', () => {
|
||||
expect(() => parseIntelHex([
|
||||
record(0, 0, [0xaa]),
|
||||
record(1, 0, [0xbb]),
|
||||
eof,
|
||||
].join('\n'), { maxBytes: 1 })).toThrow('有效数据不能超过 1 bytes')
|
||||
})
|
||||
|
||||
it('rejects records after EOF and malformed control records', () => {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { mkdtemp, readFile, rm } from 'node:fs/promises'
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
normalizeNavigation,
|
||||
readOrCreateNavigation,
|
||||
validateNavigation,
|
||||
writeJsonAtomic,
|
||||
} from '../server/navigation.mjs'
|
||||
@@ -41,6 +42,34 @@ describe('navigation config', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('migrates the legacy ASCII/Hex ID without changing its saved position', async () => {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), 'web-tools-navigation-'))
|
||||
temporaryDirectories.push(directory)
|
||||
const filePath = path.join(directory, 'navigation.json')
|
||||
const utf8Catalog = {
|
||||
categories: [{ id: 'encoding' }],
|
||||
tools: [
|
||||
{ id: 'utf8-hex', category: 'encoding' },
|
||||
{ id: 'base64', category: 'encoding' },
|
||||
],
|
||||
}
|
||||
const utf8Defaults = {
|
||||
version: 1,
|
||||
categories: [{ id: 'encoding', tools: ['utf8-hex', 'base64'] }],
|
||||
}
|
||||
await writeFile(filePath, JSON.stringify({
|
||||
version: 1,
|
||||
categories: [{ id: 'encoding', tools: ['base64', 'ascii-hex'] }],
|
||||
}))
|
||||
|
||||
const expected = {
|
||||
version: 1,
|
||||
categories: [{ id: 'encoding', tools: ['base64', 'utf8-hex'] }],
|
||||
}
|
||||
await expect(readOrCreateNavigation(filePath, utf8Catalog, utf8Defaults)).resolves.toEqual(expected)
|
||||
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual(expected)
|
||||
})
|
||||
|
||||
it('rejects duplicate or incomplete configurations', () => {
|
||||
expect(() => validateNavigation({ version: 1, categories: [{ id: 'encoding', tools: ['hex', 'hex'] }] }, catalog)).toThrow('重复')
|
||||
expect(() => validateNavigation({ version: 1, categories: [{ id: 'encoding', tools: ['hex'] }, { id: 'crypto', tools: ['cmac'] }] }, catalog)).toThrow('全部')
|
||||
|
||||
+32
-2
@@ -1,6 +1,12 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { formatBytes } from '../src/lib/bytes'
|
||||
import { secureRandomBytes, type RandomSource } from '../src/lib/random'
|
||||
import {
|
||||
DEFAULT_RANDOM_BYTES,
|
||||
MAX_RANDOM_BYTES,
|
||||
MIN_RANDOM_BYTES,
|
||||
secureRandomBytes,
|
||||
type RandomSource,
|
||||
} from '../src/lib/random'
|
||||
|
||||
describe('secure random bytes', () => {
|
||||
it('uses the supplied cryptographic source and preserves requested length', () => {
|
||||
@@ -12,8 +18,32 @@ describe('secure random bytes', () => {
|
||||
return array
|
||||
},
|
||||
}
|
||||
expect(DEFAULT_RANDOM_BYTES).toBe(16)
|
||||
expect(secureRandomBytes(4, source)).toEqual(Uint8Array.from([0, 1, 2, 3]))
|
||||
expect(() => secureRandomBytes(0, source)).toThrow('1 至 65536')
|
||||
})
|
||||
|
||||
it.each([0, -1, 1.5, Number.NaN, MAX_RANDOM_BYTES + 1])('rejects invalid length %s', (length) => {
|
||||
expect(() => secureRandomBytes(length, crypto)).toThrow(`${MIN_RANDOM_BYTES} 至 ${MAX_RANDOM_BYTES}`)
|
||||
})
|
||||
|
||||
it('accepts both length boundaries without exceeding the browser call limit', () => {
|
||||
const calls: number[] = []
|
||||
const source: RandomSource = {
|
||||
getRandomValues<T extends ArrayBufferView>(array: T): T {
|
||||
calls.push(array.byteLength)
|
||||
return array
|
||||
},
|
||||
}
|
||||
expect(secureRandomBytes(MIN_RANDOM_BYTES, source)).toHaveLength(MIN_RANDOM_BYTES)
|
||||
expect(secureRandomBytes(MAX_RANDOM_BYTES, source)).toHaveLength(MAX_RANDOM_BYTES)
|
||||
expect(calls).toEqual([MIN_RANDOM_BYTES, MAX_RANDOM_BYTES])
|
||||
})
|
||||
|
||||
it('fails closed when the cryptographic source is unavailable or throws', () => {
|
||||
expect(() => secureRandomBytes(DEFAULT_RANDOM_BYTES, null as unknown as RandomSource)).toThrow('不支持安全随机数')
|
||||
const failure = new Error('entropy source failed')
|
||||
const source: RandomSource = { getRandomValues: () => { throw failure } }
|
||||
expect(() => secureRandomBytes(DEFAULT_RANDOM_BYTES, source)).toThrow(failure)
|
||||
})
|
||||
|
||||
it('formats the same bytes for OpenSSL and source-code styles', () => {
|
||||
|
||||
+18
-1
@@ -1,8 +1,12 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { formatBytes, parseHex } from '../src/lib/bytes'
|
||||
import { base64UrlToHex, formatPem, generateRsaKeyPair } from '../src/lib/rsa'
|
||||
|
||||
describe('RSA key generation', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('formats DER and JWK integer values', () => {
|
||||
expect(formatPem(Uint8Array.from([0, 1, 2, 3]), 'PUBLIC KEY')).toBe('-----BEGIN PUBLIC KEY-----\nAAECAw==\n-----END PUBLIC KEY-----\n')
|
||||
expect(base64UrlToHex('AQAB')).toBe('010001')
|
||||
@@ -17,4 +21,17 @@ describe('RSA key generation', () => {
|
||||
expect(formatBytes(parseHex(result.exponentHex), { format: 'byte-array' })).toBe('0x01, 0x00, 0x01')
|
||||
expect(['Web Crypto', 'node-forge']).toContain(result.source)
|
||||
}, 20_000)
|
||||
|
||||
it('uses the node-forge fallback with a cryptographic random source when SubtleCrypto is unavailable', async () => {
|
||||
const getRandomValues = globalThis.crypto.getRandomValues.bind(globalThis.crypto)
|
||||
vi.stubGlobal('crypto', { getRandomValues })
|
||||
|
||||
const result = await generateRsaKeyPair(2048)
|
||||
|
||||
expect(result.source).toBe('node-forge')
|
||||
expect(result.publicKeyPem).toMatch(/^-----BEGIN PUBLIC KEY-----/)
|
||||
expect(result.privateKeyPem).toMatch(/^-----BEGIN PRIVATE KEY-----/)
|
||||
expect(result.modulusHex).toHaveLength(512)
|
||||
expect(result.exponentHex).toBe('010001')
|
||||
}, 30_000)
|
||||
})
|
||||
|
||||
+66
-2
@@ -2,7 +2,7 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { assertProductionBuild, createWebToolsServer, MAX_JSON_BODY_SIZE } from '../server/index.mjs'
|
||||
import { assertProductionBuild, createWebToolsServer, CSP_NONCE_BYTES, MAX_JSON_BODY_SIZE } from '../server/index.mjs'
|
||||
|
||||
const temporaryDirectories = []
|
||||
const servers = []
|
||||
@@ -34,8 +34,11 @@ async function createFixtureServer() {
|
||||
const dataDir = path.join(directory, 'data')
|
||||
await writeFile(path.join(directory, 'placeholder'), '')
|
||||
await mkdir(staticRoot, { recursive: true })
|
||||
await mkdir(path.join(staticRoot, 'assets'), { recursive: true })
|
||||
await writeFile(path.join(staticRoot, 'index.html'), '<!doctype html><title>Web Tools</title>')
|
||||
await writeFile(path.join(staticRoot, 'app.js'), 'console.log("ok")')
|
||||
await writeFile(path.join(staticRoot, 'assets', 'app-ABCD1234.js'), 'console.log("hashed")')
|
||||
await writeFile(path.join(staticRoot, 'assets', 'unversioned.js'), 'console.log("stable")')
|
||||
await writeFile(path.join(staticRoot, 'site.webmanifest'), '{}')
|
||||
const server = await createWebToolsServer({ staticRoot, dataDir, catalog, defaults, logger: { error: () => undefined } })
|
||||
servers.push(server)
|
||||
@@ -68,6 +71,26 @@ describe('HTTP server', () => {
|
||||
expect(extensionlessAsset.status).toBe(404)
|
||||
})
|
||||
|
||||
it('uses a unique cryptographic CSP nonce without allowing arbitrary inline scripts', async () => {
|
||||
const { baseUrl } = await createFixtureServer()
|
||||
const first = await fetch(`${baseUrl}/`)
|
||||
const second = await fetch(`${baseUrl}/tools/base64`, { headers: { Accept: 'text/html' } })
|
||||
const firstPolicy = first.headers.get('content-security-policy') ?? ''
|
||||
const secondPolicy = second.headers.get('content-security-policy') ?? ''
|
||||
const noncePattern = /script-src 'self' 'nonce-([A-Za-z0-9+/]+={0,2})'/
|
||||
const firstNonce = firstPolicy.match(noncePattern)?.[1]
|
||||
const secondNonce = secondPolicy.match(noncePattern)?.[1]
|
||||
|
||||
expect(firstNonce).toBeTruthy()
|
||||
expect(secondNonce).toBeTruthy()
|
||||
expect(Buffer.from(firstNonce, 'base64')).toHaveLength(CSP_NONCE_BYTES)
|
||||
expect(Buffer.from(secondNonce, 'base64')).toHaveLength(CSP_NONCE_BYTES)
|
||||
expect(firstNonce).not.toBe(secondNonce)
|
||||
expect(firstPolicy).not.toContain("'unsafe-inline'")
|
||||
expect(firstPolicy).toContain("object-src 'none'")
|
||||
expect(firstPolicy).toContain("frame-ancestors 'none'")
|
||||
})
|
||||
|
||||
it('returns no-store JSON health and API responses', async () => {
|
||||
const { baseUrl } = await createFixtureServer()
|
||||
const health = await fetch(`${baseUrl}/healthz`)
|
||||
@@ -85,6 +108,18 @@ describe('HTTP server', () => {
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.headers.get('content-type')).toBe('application/manifest+json; charset=utf-8')
|
||||
expect(response.headers.get('cache-control')).toBe('no-cache')
|
||||
})
|
||||
|
||||
it('only gives immutable caching to fingerprinted build assets', async () => {
|
||||
const { baseUrl } = await createFixtureServer()
|
||||
const stableAsset = await fetch(`${baseUrl}/app.js`)
|
||||
const nestedStableAsset = await fetch(`${baseUrl}/assets/unversioned.js`)
|
||||
const fingerprintedAsset = await fetch(`${baseUrl}/assets/app-ABCD1234.js`)
|
||||
|
||||
expect(stableAsset.headers.get('cache-control')).toBe('no-cache')
|
||||
expect(nestedStableAsset.headers.get('cache-control')).toBe('no-cache')
|
||||
expect(fingerprintedAsset.headers.get('cache-control')).toBe('public, max-age=31536000, immutable')
|
||||
})
|
||||
|
||||
it('creates, reads and updates navigation through the API', async () => {
|
||||
@@ -103,6 +138,21 @@ describe('HTTP server', () => {
|
||||
expect(JSON.parse(await readFile(path.join(dataDir, 'navigation.json'), 'utf8'))).toEqual(updated)
|
||||
})
|
||||
|
||||
it('keeps concurrent navigation writes valid and complete', async () => {
|
||||
const { baseUrl, dataDir } = await createFixtureServer()
|
||||
const first = { version: 1, categories: defaults.categories }
|
||||
const second = { version: 1, categories: [...defaults.categories].reverse() }
|
||||
const responses = await Promise.all([first, second].map((navigation) => fetch(`${baseUrl}/api/navigation`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(navigation),
|
||||
})))
|
||||
|
||||
expect(responses.map((response) => response.status)).toEqual([200, 200])
|
||||
const stored = JSON.parse(await readFile(path.join(dataDir, 'navigation.json'), 'utf8'))
|
||||
expect([first, second]).toContainEqual(stored)
|
||||
})
|
||||
|
||||
it('normalizes an existing navigation file when a later release adds a tool', async () => {
|
||||
const { baseUrl, dataDir } = await createFixtureServer()
|
||||
const legacy = { version: 1, categories: [{ id: 'encoding', tools: ['hex'] }, { id: 'crypto', tools: ['cmac'] }] }
|
||||
@@ -144,7 +194,7 @@ describe('HTTP server', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects invalid, oversized and cross-site navigation writes', async () => {
|
||||
it('rejects invalid, oversized and cross-origin browser navigation writes', async () => {
|
||||
const { baseUrl } = await createFixtureServer()
|
||||
const invalid = await fetch(`${baseUrl}/api/navigation`, {
|
||||
method: 'PUT',
|
||||
@@ -167,5 +217,19 @@ describe('HTTP server', () => {
|
||||
body: JSON.stringify(defaults),
|
||||
})
|
||||
expect(crossSite.status).toBe(403)
|
||||
|
||||
const sameSite = await fetch(`${baseUrl}/api/navigation`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', 'Sec-Fetch-Site': 'same-site' },
|
||||
body: JSON.stringify(defaults),
|
||||
})
|
||||
expect(sameSite.status).toBe(403)
|
||||
|
||||
const sameOrigin = await fetch(`${baseUrl}/api/navigation`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', 'Sec-Fetch-Site': 'same-origin' },
|
||||
body: JSON.stringify(defaults),
|
||||
})
|
||||
expect(sameOrigin.status).toBe(200)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
import {
|
||||
constants,
|
||||
createPublicKey,
|
||||
generateKeyPairSync,
|
||||
sign,
|
||||
type KeyObject,
|
||||
} from 'node:crypto'
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
import { encodeBase64, formatBytes } from '../src/lib/bytes'
|
||||
import { parseSignatureFile } from '../src/lib/signatureVerificationFile'
|
||||
import {
|
||||
decodeSignature,
|
||||
defaultPssSaltLength,
|
||||
hashLength,
|
||||
maximumPssSaltLength,
|
||||
parseRsaPublicKeyParameters,
|
||||
parseRsaPublicKeyPem,
|
||||
verifyRsaSignature,
|
||||
type ParsedRsaPublicKey,
|
||||
type SignatureHashAlgorithm,
|
||||
} from '../src/lib/signatureVerification'
|
||||
|
||||
let privateKey: KeyObject
|
||||
let publicKey: KeyObject
|
||||
let spkiPem = ''
|
||||
let pkcs1Pem = ''
|
||||
let certificatePem = ''
|
||||
let parsedSpki: ParsedRsaPublicKey
|
||||
const message = new TextEncoder().encode('Web Tools RSA signature verification')
|
||||
|
||||
function nodeHash(hash: SignatureHashAlgorithm): string {
|
||||
return hash.toLowerCase().replace('-', '')
|
||||
}
|
||||
|
||||
function createSignature(
|
||||
data: Uint8Array,
|
||||
hash: SignatureHashAlgorithm,
|
||||
scheme: 'pkcs1-v1_5' | 'pss',
|
||||
saltLength = hashLength(hash),
|
||||
): Uint8Array {
|
||||
return sign(nodeHash(hash), data, {
|
||||
key: privateKey,
|
||||
padding: scheme === 'pss' ? constants.RSA_PKCS1_PSS_PADDING : constants.RSA_PKCS1_PADDING,
|
||||
...(scheme === 'pss' ? { saltLength } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
const generated = generateKeyPairSync('rsa', {
|
||||
modulusLength: 2048,
|
||||
publicExponent: 0x10001,
|
||||
})
|
||||
privateKey = generated.privateKey
|
||||
publicKey = generated.publicKey
|
||||
spkiPem = publicKey.export({ type: 'spki', format: 'pem' }).toString()
|
||||
pkcs1Pem = publicKey.export({ type: 'pkcs1', format: 'pem' }).toString()
|
||||
parsedSpki = await parseRsaPublicKeyPem(spkiPem)
|
||||
|
||||
const { default: forge } = await import('node-forge')
|
||||
const forgePrivateKey = forge.pki.privateKeyFromPem(privateKey.export({ type: 'pkcs8', format: 'pem' }).toString())
|
||||
const forgePublicKey = forge.pki.publicKeyFromPem(spkiPem)
|
||||
const certificate = forge.pki.createCertificate()
|
||||
certificate.publicKey = forgePublicKey
|
||||
certificate.serialNumber = '01AB'
|
||||
certificate.validity.notBefore = new Date('2025-01-01T00:00:00.000Z')
|
||||
certificate.validity.notAfter = new Date('2030-01-01T00:00:00.000Z')
|
||||
certificate.setSubject([{ name: 'commonName', value: 'Web Tools Test' }])
|
||||
certificate.setIssuer([{ name: 'commonName', value: 'Web Tools Test' }])
|
||||
certificate.sign(forgePrivateKey, forge.md.sha256.create())
|
||||
certificatePem = forge.pki.certificateToPem(certificate)
|
||||
}, 20_000)
|
||||
|
||||
afterAll(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('RSA public-key parsing', () => {
|
||||
it('parses SPKI and PKCS#1 PEM with a stable SPKI fingerprint', async () => {
|
||||
const [spki, pkcs1] = await Promise.all([
|
||||
parseRsaPublicKeyPem(spkiPem),
|
||||
parseRsaPublicKeyPem(pkcs1Pem),
|
||||
])
|
||||
|
||||
expect(spki.source).toBe('SPKI')
|
||||
expect(pkcs1.source).toBe('PKCS#1')
|
||||
expect(spki.modulusBits).toBe(2048)
|
||||
expect(spki.exponentHex).toBe('010001')
|
||||
expect(pkcs1.fingerprintSha256).toBe(spki.fingerprintSha256)
|
||||
expect(createPublicKey({ key: Buffer.from(spki.spkiDer), type: 'spki', format: 'der' }).asymmetricKeyType).toBe('rsa')
|
||||
})
|
||||
|
||||
it('extracts an RSA key and display-only metadata from one X.509 certificate', async () => {
|
||||
const parsed = await parseRsaPublicKeyPem(certificatePem)
|
||||
expect(parsed.source).toBe('X.509 certificate')
|
||||
expect(parsed.fingerprintSha256).toBe(parsedSpki.fingerprintSha256)
|
||||
expect(parsed.certificate).toMatchObject({
|
||||
subject: 'CN=Web Tools Test',
|
||||
serialNumber: '01AB',
|
||||
notBefore: '2025-01-01T00:00:00.000Z',
|
||||
notAfter: '2030-01-01T00:00:00.000Z',
|
||||
})
|
||||
})
|
||||
|
||||
it('builds the same public key from N/E Hex parameters', async () => {
|
||||
const jwk = publicKey.export({ format: 'jwk' })
|
||||
if (!jwk.n || !jwk.e) throw new Error('Node did not export RSA JWK parameters')
|
||||
const fromParameters = await parseRsaPublicKeyParameters(
|
||||
Buffer.from(jwk.n, 'base64url').toString('hex'),
|
||||
Buffer.from(jwk.e, 'base64url').toString('hex'),
|
||||
)
|
||||
expect(fromParameters.source).toBe('N/E parameters')
|
||||
expect(fromParameters.fingerprintSha256).toBe(parsedSpki.fingerprintSha256)
|
||||
})
|
||||
|
||||
it('accepts an odd-length Hex public exponent and pads it on the left', async () => {
|
||||
const fromOddLengthExponent = await parseRsaPublicKeyParameters(parsedSpki.modulusHex, '10001')
|
||||
const fromEvenLengthExponent = await parseRsaPublicKeyParameters(parsedSpki.modulusHex, '010001')
|
||||
const fromPrefixedExponent = await parseRsaPublicKeyParameters(parsedSpki.modulusHex, '0x10001')
|
||||
const fromLeadingZeros = await parseRsaPublicKeyParameters(parsedSpki.modulusHex, '00010001')
|
||||
|
||||
expect(fromOddLengthExponent.exponentHex).toBe('010001')
|
||||
expect(fromOddLengthExponent.exponentDecimal).toBe('65537')
|
||||
expect(fromOddLengthExponent.fingerprintSha256).toBe(parsedSpki.fingerprintSha256)
|
||||
expect(fromEvenLengthExponent.fingerprintSha256).toBe(parsedSpki.fingerprintSha256)
|
||||
expect(fromPrefixedExponent.fingerprintSha256).toBe(parsedSpki.fingerprintSha256)
|
||||
expect(fromLeadingZeros.fingerprintSha256).toBe(parsedSpki.fingerprintSha256)
|
||||
})
|
||||
|
||||
it('rejects ambiguous, private, malformed and out-of-policy key input', async () => {
|
||||
await expect(parseRsaPublicKeyPem(`${spkiPem}${pkcs1Pem}`)).rejects.toThrow('一个完整的 PEM 块')
|
||||
await expect(parseRsaPublicKeyPem(privateKey.export({ type: 'pkcs8', format: 'pem' }).toString())).rejects.toThrow('仅支持')
|
||||
await expect(parseRsaPublicKeyPem('-----BEGIN PUBLIC KEY-----\nAA==\n-----END PUBLIC KEY-----')).rejects.toThrow('格式无效')
|
||||
await expect(parseRsaPublicKeyParameters('AA', '02')).rejects.toThrow('RSA 模数必须')
|
||||
await expect(parseRsaPublicKeyParameters(`${parsedSpki.modulusHex.slice(0, -1)}2`, '010001')).rejects.toThrow('模数 N 必须是奇数')
|
||||
})
|
||||
})
|
||||
|
||||
describe('RSA signature verification', () => {
|
||||
for (const hash of ['SHA-256', 'SHA-384', 'SHA-512'] as const) {
|
||||
it(`verifies PKCS#1 v1.5 with ${hash} and rejects changed data`, async () => {
|
||||
const signature = createSignature(message, hash, 'pkcs1-v1_5')
|
||||
const valid = await verifyRsaSignature(parsedSpki, message, signature, { scheme: 'pkcs1-v1_5', hash })
|
||||
const invalid = await verifyRsaSignature(parsedSpki, new Uint8Array([...message, 0]), signature, { scheme: 'pkcs1-v1_5', hash })
|
||||
expect(valid.valid).toBe(true)
|
||||
expect(invalid.valid).toBe(false)
|
||||
expect(valid.dataHash).toHaveLength(hashLength(hash) * 2)
|
||||
})
|
||||
}
|
||||
|
||||
for (const hash of ['SHA-256', 'SHA-384', 'SHA-512'] as const) {
|
||||
it(`verifies RSA-PSS with ${hash} and an explicit salt length`, async () => {
|
||||
const saltLength = hashLength(hash)
|
||||
const signature = createSignature(message, hash, 'pss', saltLength)
|
||||
const valid = await verifyRsaSignature(parsedSpki, message, signature, { scheme: 'pss', hash, saltLength })
|
||||
const wrongSalt = await verifyRsaSignature(parsedSpki, message, signature, { scheme: 'pss', hash, saltLength: Math.max(0, saltLength - 1) })
|
||||
expect(valid.valid).toBe(true)
|
||||
expect(wrongSalt.valid).toBe(false)
|
||||
})
|
||||
}
|
||||
|
||||
it('supports an empty message and reports a corrupted signature as invalid', async () => {
|
||||
const empty = new Uint8Array()
|
||||
const signature = createSignature(empty, 'SHA-256', 'pkcs1-v1_5')
|
||||
const corrupted = Uint8Array.from(signature)
|
||||
corrupted[corrupted.length - 1] ^= 1
|
||||
await expect(verifyRsaSignature(parsedSpki, empty, signature, { scheme: 'pkcs1-v1_5', hash: 'SHA-256' }))
|
||||
.resolves.toMatchObject({ valid: true, dataBytes: 0 })
|
||||
await expect(verifyRsaSignature(parsedSpki, empty, corrupted, { scheme: 'pkcs1-v1_5', hash: 'SHA-256' }))
|
||||
.resolves.toMatchObject({ valid: false })
|
||||
})
|
||||
|
||||
it('verifies the exact Intel HEX block-header byte stream', async () => {
|
||||
const intelHex = ':020000040001F9\n:040000001122334452\n:02001000AABB89\n:00000001FF\n'
|
||||
const bytes = new TextEncoder().encode(intelHex)
|
||||
const file = {
|
||||
name: 'firmware.hex',
|
||||
size: bytes.length,
|
||||
text: async () => intelHex,
|
||||
arrayBuffer: async () => bytes.slice().buffer,
|
||||
} as File
|
||||
const parsed = await parseSignatureFile(file, 'intel-hex-block-header', 'big-endian')
|
||||
const signature = createSignature(parsed.data, 'SHA-256', 'pkcs1-v1_5')
|
||||
|
||||
await expect(verifyRsaSignature(parsedSpki, parsed.data, signature, { scheme: 'pkcs1-v1_5', hash: 'SHA-256' }))
|
||||
.resolves.toMatchObject({ valid: true, dataBytes: 22 })
|
||||
})
|
||||
|
||||
it('validates signature length and the RSA-PSS salt boundary before verification', async () => {
|
||||
const maximum = maximumPssSaltLength(parsedSpki.modulusBits, 'SHA-512')
|
||||
await expect(verifyRsaSignature(parsedSpki, message, new Uint8Array(255), { scheme: 'pkcs1-v1_5', hash: 'SHA-256' }))
|
||||
.rejects.toThrow('签名长度必须为 256 字节')
|
||||
await expect(verifyRsaSignature(parsedSpki, message, new Uint8Array(256), { scheme: 'pss', hash: 'SHA-512', saltLength: maximum + 1 }))
|
||||
.rejects.toThrow(`0 至 ${maximum}`)
|
||||
})
|
||||
|
||||
it('keeps the default RSA-PSS salt valid for a 1024-bit key with SHA-512', () => {
|
||||
expect(defaultPssSaltLength(1024, 'SHA-512')).toBe(62)
|
||||
expect(defaultPssSaltLength(2048, 'SHA-512')).toBe(64)
|
||||
})
|
||||
|
||||
it('verifies RSA-PSS SHA-512 with the maximum valid salt on a 1024-bit key', async () => {
|
||||
const generated = generateKeyPairSync('rsa', { modulusLength: 1024, publicExponent: 0x10001 })
|
||||
const shortKey = await parseRsaPublicKeyPem(generated.publicKey.export({ type: 'spki', format: 'pem' }).toString())
|
||||
const saltLength = defaultPssSaltLength(shortKey.modulusBits, 'SHA-512')
|
||||
const signature = sign('sha512', message, {
|
||||
key: generated.privateKey,
|
||||
padding: constants.RSA_PKCS1_PSS_PADDING,
|
||||
saltLength,
|
||||
})
|
||||
await expect(verifyRsaSignature(shortKey, message, signature, { scheme: 'pss', hash: 'SHA-512', saltLength }))
|
||||
.resolves.toMatchObject({ valid: true })
|
||||
})
|
||||
|
||||
it('uses node-forge when SubtleCrypto is unavailable', async () => {
|
||||
const getRandomValues = globalThis.crypto.getRandomValues.bind(globalThis.crypto)
|
||||
const signature = createSignature(message, 'SHA-256', 'pss', 32)
|
||||
vi.stubGlobal('crypto', { getRandomValues })
|
||||
const verification = await verifyRsaSignature(parsedSpki, message, signature, { scheme: 'pss', hash: 'SHA-256', saltLength: 32 })
|
||||
expect(verification).toMatchObject({ valid: true, provider: 'node-forge' })
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
})
|
||||
|
||||
describe('signature decoding', () => {
|
||||
it('decodes strict Hex, Base64 and Base64 URL representations', () => {
|
||||
const bytes = Uint8Array.from([0xFB, 0xEF, 0xFF])
|
||||
expect(decodeSignature(formatBytes(bytes), 'hex')).toEqual(bytes)
|
||||
expect(decodeSignature(encodeBase64(bytes), 'base64')).toEqual(bytes)
|
||||
expect(decodeSignature(encodeBase64(bytes, true), 'base64url')).toEqual(bytes)
|
||||
expect(() => decodeSignature('--__', 'base64')).toThrow('Base64 URL 字符')
|
||||
expect(() => decodeSignature('++//', 'base64url')).toThrow('标准 Base64 字符')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parseSignatureFile } from '../src/lib/signatureVerificationFile'
|
||||
|
||||
const intelHex = ':020000040001F9\n:040000001122334452\n:02001000AABB89\n:00000001FF\n'
|
||||
|
||||
function testFile(name: string, bytes: Uint8Array): File {
|
||||
return {
|
||||
name,
|
||||
size: bytes.length,
|
||||
arrayBuffer: async () => bytes.slice().buffer,
|
||||
text: async () => new TextDecoder().decode(bytes),
|
||||
} as File
|
||||
}
|
||||
|
||||
describe('signature verification file semantics', () => {
|
||||
it('keeps every byte in raw-file mode, including an empty file', async () => {
|
||||
const bytes = new TextEncoder().encode(intelHex)
|
||||
const raw = await parseSignatureFile(testFile('firmware.hex', bytes), 'raw-file')
|
||||
const empty = await parseSignatureFile(testFile('empty.bin', new Uint8Array()), 'raw-file')
|
||||
expect(raw.data).toEqual(bytes)
|
||||
expect(raw.blockCount).toBeUndefined()
|
||||
expect(empty.dataBytes).toBe(0)
|
||||
})
|
||||
|
||||
it('parses and concatenates only Intel HEX effective data without gap filling', async () => {
|
||||
const parsed = await parseSignatureFile(testFile('firmware.hex', new TextEncoder().encode(intelHex)), 'intel-hex')
|
||||
expect(parsed.blockCount).toBe(2)
|
||||
expect(parsed.dataBytes).toBe(6)
|
||||
expect([...parsed.data]).toEqual([0x11, 0x22, 0x33, 0x44, 0xAA, 0xBB])
|
||||
expect(parsed).toMatchObject({ effectiveDataBytes: 6, headerBytes: 0 })
|
||||
})
|
||||
|
||||
it('prepends an 8-byte address and length header to every block', async () => {
|
||||
const file = testFile('firmware.hex', new TextEncoder().encode(intelHex))
|
||||
const bigEndian = await parseSignatureFile(file, 'intel-hex-block-header', 'big-endian')
|
||||
const littleEndian = await parseSignatureFile(file, 'intel-hex-block-header', 'little-endian')
|
||||
|
||||
expect(bigEndian).toMatchObject({ dataBytes: 22, effectiveDataBytes: 6, headerBytes: 16, blockCount: 2 })
|
||||
expect([...bigEndian.data]).toEqual([
|
||||
0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x04, 0x11, 0x22, 0x33, 0x44,
|
||||
0x00, 0x01, 0x00, 0x10, 0x00, 0x00, 0x00, 0x02, 0xAA, 0xBB,
|
||||
])
|
||||
expect([...littleEndian.data]).toEqual([
|
||||
0x00, 0x00, 0x01, 0x00, 0x04, 0x00, 0x00, 0x00, 0x11, 0x22, 0x33, 0x44,
|
||||
0x10, 0x00, 0x01, 0x00, 0x02, 0x00, 0x00, 0x00, 0xAA, 0xBB,
|
||||
])
|
||||
})
|
||||
|
||||
it('rejects invalid modes and malformed Intel HEX', async () => {
|
||||
await expect(parseSignatureFile(testFile('bad.hex', new TextEncoder().encode(':00000001FE\n')), 'intel-hex')).rejects.toThrow()
|
||||
await expect(parseSignatureFile(testFile('data.bin', Uint8Array.from([1])), 'automatic' as never)).rejects.toThrow('文件数据模式无效')
|
||||
await expect(parseSignatureFile(testFile('firmware.hex', new TextEncoder().encode(intelHex)), 'intel-hex-block-header', 'middle-endian' as never)).rejects.toThrow('字节序')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,194 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { useTransactionalFileWorker } from '../src/composables/useTransactionalFileWorker'
|
||||
|
||||
type TestResponse = {
|
||||
type: 'parsed'
|
||||
id: number
|
||||
value: string
|
||||
} | {
|
||||
type: 'calculated'
|
||||
id: number
|
||||
value: string
|
||||
} | {
|
||||
type: 'error'
|
||||
id: number
|
||||
operation: 'parse' | 'calculate'
|
||||
error: string
|
||||
}
|
||||
|
||||
class FakeWorker {
|
||||
onmessage: ((event: MessageEvent<TestResponse>) => void) | null = null
|
||||
onerror: ((event: ErrorEvent) => void) | null = null
|
||||
onmessageerror: ((event: MessageEvent) => void) | null = null
|
||||
messages: Array<Record<string, unknown>> = []
|
||||
terminated = false
|
||||
|
||||
postMessage(message: Record<string, unknown>) {
|
||||
this.messages.push(message)
|
||||
}
|
||||
|
||||
terminate() {
|
||||
this.terminated = true
|
||||
}
|
||||
|
||||
respond(response: TestResponse) {
|
||||
this.onmessage?.({ data: response } as MessageEvent<TestResponse>)
|
||||
}
|
||||
|
||||
fail() {
|
||||
this.onerror?.({ preventDefault: vi.fn() } as unknown as ErrorEvent)
|
||||
}
|
||||
}
|
||||
|
||||
function file(name: string): File {
|
||||
return { name, size: 1 } as File
|
||||
}
|
||||
|
||||
function createHarness() {
|
||||
const workers: FakeWorker[] = []
|
||||
const parsed: string[] = []
|
||||
const calculated: string[] = []
|
||||
const parseErrors: string[] = []
|
||||
const calculationErrors: string[] = []
|
||||
const pendingWorkerErrors: boolean[] = []
|
||||
const activeWorkerError = vi.fn()
|
||||
const manager = useTransactionalFileWorker<Record<string, unknown>, TestResponse>({
|
||||
createWorker: () => {
|
||||
const worker = new FakeWorker()
|
||||
workers.push(worker)
|
||||
return worker as unknown as Worker
|
||||
},
|
||||
onParsed: (response, selected) => parsed.push(`${selected.name}:${response.value}`),
|
||||
onCalculated: (response) => calculated.push(response.value),
|
||||
onParseError: (message) => parseErrors.push(message),
|
||||
onCalculationError: (message) => calculationErrors.push(message),
|
||||
onPendingWorkerError: (hasCurrentFile) => pendingWorkerErrors.push(hasCurrentFile),
|
||||
onActiveWorkerError: activeWorkerError,
|
||||
})
|
||||
return {
|
||||
manager,
|
||||
workers,
|
||||
parsed,
|
||||
calculated,
|
||||
parseErrors,
|
||||
calculationErrors,
|
||||
pendingWorkerErrors,
|
||||
activeWorkerError,
|
||||
}
|
||||
}
|
||||
|
||||
describe('transactional file Worker', () => {
|
||||
it('commits a parsed candidate and calculates with its Worker', () => {
|
||||
const harness = createHarness()
|
||||
expect(harness.manager.parseFile(file('first.hex'))).toBe(true)
|
||||
expect(harness.manager.parsing.value).toBe(true)
|
||||
expect(harness.workers[0].messages[0]).toMatchObject({ type: 'parse', id: 1 })
|
||||
|
||||
harness.workers[0].respond({ type: 'parsed', id: 1, value: 'ready' })
|
||||
expect(harness.manager.parsing.value).toBe(false)
|
||||
expect(harness.parsed).toEqual(['first.hex:ready'])
|
||||
|
||||
expect(harness.manager.calculate({ parameters: 'crc-32' })).toBe('started')
|
||||
expect(harness.manager.calculating.value).toBe(true)
|
||||
expect(harness.workers[0].messages[1]).toEqual({ type: 'calculate', id: 2, parameters: 'crc-32' })
|
||||
harness.workers[0].respond({ type: 'calculated', id: 2, value: 'result' })
|
||||
expect(harness.calculated).toEqual(['result'])
|
||||
expect(harness.manager.calculating.value).toBe(false)
|
||||
})
|
||||
|
||||
it('supports an explicit parse-request payload without changing default callers', () => {
|
||||
const workers: FakeWorker[] = []
|
||||
const manager = useTransactionalFileWorker<Record<string, unknown>, TestResponse>({
|
||||
createWorker: () => {
|
||||
const worker = new FakeWorker()
|
||||
workers.push(worker)
|
||||
return worker as unknown as Worker
|
||||
},
|
||||
createParseRequest: (id, selected) => ({ type: 'parse', id, file: selected, mode: 'intel-hex' }),
|
||||
onParsed: vi.fn(),
|
||||
onCalculated: vi.fn(),
|
||||
onParseError: vi.fn(),
|
||||
onCalculationError: vi.fn(),
|
||||
onPendingWorkerError: vi.fn(),
|
||||
onActiveWorkerError: vi.fn(),
|
||||
})
|
||||
|
||||
const selected = file('firmware.hex')
|
||||
manager.parseFile(selected)
|
||||
expect(workers[0].messages[0]).toEqual({ type: 'parse', id: 1, file: selected, mode: 'intel-hex' })
|
||||
})
|
||||
|
||||
it('preserves the active Worker when a replacement parse fails', () => {
|
||||
const harness = createHarness()
|
||||
harness.manager.parseFile(file('first.hex'))
|
||||
harness.workers[0].respond({ type: 'parsed', id: 1, value: 'first' })
|
||||
|
||||
harness.manager.parseFile(file('invalid.hex'))
|
||||
harness.workers[1].respond({ type: 'error', id: 2, operation: 'parse', error: 'bad checksum' })
|
||||
expect(harness.parseErrors).toEqual(['bad checksum'])
|
||||
expect(harness.workers[0].terminated).toBe(false)
|
||||
expect(harness.workers[1].terminated).toBe(true)
|
||||
expect(harness.manager.calculate({})).toBe('started')
|
||||
})
|
||||
|
||||
it('atomically replaces the active Worker and ignores its stale result', () => {
|
||||
const harness = createHarness()
|
||||
harness.manager.parseFile(file('first.hex'))
|
||||
harness.workers[0].respond({ type: 'parsed', id: 1, value: 'first' })
|
||||
harness.manager.calculate({})
|
||||
|
||||
harness.manager.parseFile(file('second.hex'))
|
||||
harness.workers[1].respond({ type: 'parsed', id: 3, value: 'second' })
|
||||
expect(harness.workers[0].terminated).toBe(true)
|
||||
expect(harness.parsed).toEqual(['first.hex:first', 'second.hex:second'])
|
||||
expect(harness.manager.calculating.value).toBe(false)
|
||||
|
||||
harness.workers[0].respond({ type: 'calculated', id: 2, value: 'stale' })
|
||||
expect(harness.calculated).toEqual([])
|
||||
})
|
||||
|
||||
it('distinguishes pending Worker failures with and without a current file', () => {
|
||||
const harness = createHarness()
|
||||
harness.manager.parseFile(file('first.hex'))
|
||||
harness.workers[0].fail()
|
||||
expect(harness.pendingWorkerErrors).toEqual([false])
|
||||
|
||||
harness.manager.parseFile(file('current.hex'))
|
||||
harness.workers[1].respond({ type: 'parsed', id: 2, value: 'current' })
|
||||
harness.manager.parseFile(file('replacement.hex'))
|
||||
harness.workers[2].fail()
|
||||
expect(harness.pendingWorkerErrors).toEqual([false, true])
|
||||
expect(harness.workers[1].terminated).toBe(false)
|
||||
})
|
||||
|
||||
it('terminates active and pending Workers and ignores late responses', () => {
|
||||
const harness = createHarness()
|
||||
harness.manager.parseFile(file('first.hex'))
|
||||
harness.workers[0].respond({ type: 'parsed', id: 1, value: 'first' })
|
||||
harness.manager.parseFile(file('second.hex'))
|
||||
harness.manager.destroyWorkers()
|
||||
|
||||
expect(harness.workers.every((worker) => worker.terminated)).toBe(true)
|
||||
harness.workers[1].respond({ type: 'parsed', id: 2, value: 'late' })
|
||||
expect(harness.parsed).toEqual(['first.hex:first'])
|
||||
expect(harness.manager.parsing.value).toBe(false)
|
||||
expect(harness.manager.calculating.value).toBe(false)
|
||||
})
|
||||
|
||||
it('reports Worker construction failure without entering a busy state', () => {
|
||||
const pendingWorkerErrors: boolean[] = []
|
||||
const manager = useTransactionalFileWorker<Record<string, unknown>, TestResponse>({
|
||||
createWorker: () => { throw new Error('unsupported') },
|
||||
onParsed: vi.fn(),
|
||||
onCalculated: vi.fn(),
|
||||
onParseError: vi.fn(),
|
||||
onCalculationError: vi.fn(),
|
||||
onPendingWorkerError: (hasCurrentFile) => pendingWorkerErrors.push(hasCurrentFile),
|
||||
onActiveWorkerError: vi.fn(),
|
||||
})
|
||||
|
||||
expect(manager.parseFile(file('first.hex'))).toBe(false)
|
||||
expect(pendingWorkerErrors).toEqual([false])
|
||||
expect(manager.parsing.value).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { validateXmlDocument, XML_PARSER_ERROR_NAMESPACES } from '../src/lib/xml'
|
||||
|
||||
function parserWithCandidates(candidates: Array<{ localName: string; namespaceURI: string | null; textContent: string | null }>) {
|
||||
return {
|
||||
parseFromString: () => ({
|
||||
getElementsByTagName: (localName: string) => {
|
||||
expect(localName).toBe('parsererror')
|
||||
return candidates
|
||||
},
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
describe('XML validation', () => {
|
||||
it('accepts valid content even when the user root element is named parsererror', () => {
|
||||
expect(() => validateXmlDocument('<parsererror>valid</parsererror>', parserWithCandidates([
|
||||
{ localName: 'parsererror', namespaceURI: null, textContent: 'valid' },
|
||||
]))).not.toThrow()
|
||||
})
|
||||
|
||||
it('reports parser errors from the browser parser-error namespace', () => {
|
||||
for (const namespaceURI of XML_PARSER_ERROR_NAMESPACES) {
|
||||
expect(() => validateXmlDocument('<root>', parserWithCandidates([
|
||||
{ localName: 'parsererror', namespaceURI, textContent: 'XML parsing error\nDetails' },
|
||||
]))).toThrow('XML parsing error')
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects empty input before invoking the parser', () => {
|
||||
expect(() => validateXmlDocument(' ', parserWithCandidates([]))).toThrow('请输入 XML')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user