Compare commits

...
Author SHA1 Message Date
dependabot[bot] 7d33df7968 build(deps): bump fast-xml-parser from 5.7.1 to 5.12.0
Bumps [fast-xml-parser](https://github.com/NaturalIntelligence/fast-xml-parser) from 5.7.1 to 5.12.0.
- [Release notes](https://github.com/NaturalIntelligence/fast-xml-parser/releases)
- [Changelog](https://github.com/NaturalIntelligence/fast-xml-parser/blob/master/CHANGELOG.md)
- [Commits](https://github.com/NaturalIntelligence/fast-xml-parser/compare/v5.7.1...v5.12.0)

---
updated-dependencies:
- dependency-name: fast-xml-parser
  dependency-version: 5.12.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-11-12 08:28:46 +00:00
Alam 73044e5ea6 Merge pull request #974 from alam00000/dependabot/npm_and_yarn/vite-8.3.6
build(deps-dev): bump vite from 8.3.5 to 8.3.6
2026-11-12 13:56:24 +05:30
Alam 058053bffc Merge pull request #982 from atpanos/fix/i18n-polish-language-detection
fix(i18n): detect Polish and derive language routing
2026-11-12 13:55:43 +05:30
Alam 4dd34a659a Merge pull request #956 from AB527/fix/952-validate-doc-timestamps
(Bug) Detect and verify document timestamps in Validate Signature
2026-11-12 13:50:09 +05:30
Alam 25c2d34a9c Merge pull request #962 from AB527/feat/879-top-bar-hover-text
(Feature) Hover text on top bar buttons that hide their labels
2026-11-12 13:49:17 +05:30
Athanasios Panos 3c7eb141fc test(i18n): keep Polish-prefixed links unchanged in rewriteLinks
Cover the regression hinted at in review: an already /pl/-prefixed link
must not be prefixed a second time now that pl is part of the language
pattern.
2026-11-11 07:09:16 +01:00
Athanasios Panos edd824e1e3 fix(i18n): detect Polish and derive language routing from supportedLanguages
Polish (pl) was missing from the hand-maintained language regexes, so
getLanguageFromUrl() returned en for /pl/... pages and the UI fell back
to English after the initial server-rendered Polish content.

Derive the pattern from supportedLanguages so new locales cannot be
missed again, and cover Polish in getLanguageFromUrl(), changeLanguage()
and rewriteLinks() with regression tests.

Refs #972
2026-11-10 23:16:37 +01:00
Alam 18c4aac8bc Merge pull request #978 from alam00000/chore/update-sponsors
docs: update sponsors
2026-11-10 00:44:43 +05:30
dependabot[bot] bfc269cb14 build(deps-dev): bump vite from 8.3.5 to 8.3.6
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 8.3.5 to 8.3.6.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.3.6/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 8.3.6
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-11-06 10:28:11 +00:00
Atharva Bedekar 8e04279c17 feat(ui): add hover text to top bar buttons that hide their labels
Reset, FAQ and Close in PDF Multi Tool and the PDF Workflow toolbar lose
their text on small screens, leaving only an icon. Give them a title so
the action can still be identified on hover.

Multi Tool reuses existing translation keys. PDF Workflow is English
only, so its titles are plain text like its labels.

Fixes #879
2026-10-31 15:44:31 +05:30
Atharva Bedekar c244fbe8aa fix(validate-signature): flag weak timestamp imprints and ignore signer entries
Treat an MD5 or SHA-1 messageImprint as an insecure digest and show the
imprint algorithm in Technical Details.

Ignore Reason, Location, ContactInfo and Name on document timestamps, as
ISO 32000-2 Table 255 requires for ETSI.RFC3161.

The insecure digest status could not be reached because such results are
never valid, so these cards read "Invalid Signature". Show it in the
invalid branch instead.
2026-10-31 15:21:54 +05:30
Atharva Bedekar 2daa7e2ad0 fix(validate-signature): detect and verify document timestamps
PDFs whose only signature is an RFC 3161 document timestamp
(/Type /DocTimeStamp, /SubFilter /ETSI.RFC3161) were reported as having
no signatures, because timestamp dictionaries were skipped on purpose.
node-forge also cannot parse a timestamp token directly, since it only
accepts SignedData that wraps plain data.

Timestamps are now extracted and flagged as such. The TSTInfo is read
from the token, and verification checks that its messageImprint matches
the signed byte range and that the TSA signature covers the TSTInfo.
The timestamp's genTime is shown as the date, and the result card uses
timestamp wording.

Fixes #952
2026-10-26 17:33:16 +05:30
13 changed files with 696 additions and 151 deletions

No files matched your search

+151 -104
View File
@@ -1,12 +1,12 @@
{
"name": "bento-pdf",
"version": "2.8.10",
"version": "2.8.11",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "bento-pdf",
"version": "2.8.10",
"version": "2.8.11",
"license": "AGPL-3.0-only",
"dependencies": {
"@fontsource/cedarville-cursive": "^5.2.7",
@@ -44,7 +44,7 @@
"cropperjs": "^1.6.2",
"diff": "^8.0.3",
"dompurify": "^3.4.17",
"fast-xml-parser": "^5.7.1",
"fast-xml-parser": "^5.12.0",
"heic2any": "^0.0.4",
"highlight.js": "^11.12.0",
"html2canvas": "^1.4.1",
@@ -108,7 +108,7 @@
"prettier": "^3.8.1",
"typescript": "~5.9.3",
"typescript-eslint": "^8.56.1",
"vite": "^8.3.5",
"vite": "^8.3.6",
"vite-plugin-handlebars": "^2.0.0",
"vite-plugin-node-polyfills": "^0.26.0",
"vitepress": "^1.6.4",
@@ -1750,9 +1750,9 @@
"license": "ISC"
},
"node_modules/@nodable/entities": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz",
"integrity": "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==",
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-3.1.0.tgz",
"integrity": "sha512-LsS/DjHr+uDM647Gru/cA8+J3a3HfhttwCKLyuoyN7yXTFCxKBtSgMQBvrW9yNPa2/zDRUNuGPaH5QUFoa4arQ==",
"funding": [
{
"type": "github",
@@ -1762,9 +1762,9 @@
"license": "MIT"
},
"node_modules/@oxc-project/types": {
"version": "0.155.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.155.0.tgz",
"integrity": "sha512-SeE8JPwW1xs1tHeNycjUtUBdhorRa8o4jlvjwk49R7/Z291i+4DQa7JJIgOm/TaYQZx+QrPQbVJ/9c8dMIZN8Q==",
"version": "0.156.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.156.0.tgz",
"integrity": "sha512-1Duyg48TdJNdyhEB8Xn1L36bT9Pv6GUtlKjHxOys8xkaF/rQq3XJE+bAurSK/AgIz5mGFsb77cn9Pb20+WCScw==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/oxc-project"
@@ -1833,9 +1833,9 @@
}
},
"node_modules/@rolldown/binding-android-arm-eabi": {
"version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.15.tgz",
"integrity": "sha512-aLcgK9gIJ3YRvrrtfSBbRa27F5WrpiTaPulnFpsHPxpUHgFlSgnU4854MHwoRGEBhfRJDWCS2WQ7lIjskE9Olw==",
"version": "1.2.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.16.tgz",
"integrity": "sha512-ia3DwAs0OsMGS7+IConptSiyk9pEXkOx/AbJ2n/lAGxH3QkWQus1t8d7gnAT38FCU2sQu8lUbeR6AiZFv4JxaA==",
"cpu": [
"arm"
],
@@ -1849,9 +1849,9 @@
}
},
"node_modules/@rolldown/binding-android-arm64": {
"version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.15.tgz",
"integrity": "sha512-xD7DdZCrbThKuxNg7ooQmvp07ys4J/pnSEMTG3/OdOWl6daC67m/Q4fL78sRHMxTH/N4fGUbYhcPM0MF5WFHkg==",
"version": "1.2.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.16.tgz",
"integrity": "sha512-kx5wBiMB6E7TEvpl2B9gjLU004BkvEm2Dr941BFjx/SontPwSzNuQUIoVKxHHoSjpFIfHMmcguyLOtyn0hObRg==",
"cpu": [
"arm64"
],
@@ -1865,9 +1865,9 @@
}
},
"node_modules/@rolldown/binding-darwin-arm64": {
"version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.15.tgz",
"integrity": "sha512-m2WuQ6r0B4OTYxyPpNLtXaMRbRDUbG8YQV2Q3KTdSSK8Th6KHCnC8jVCJt8bvYeSB5WivkkNbc85mKadaSdOxA==",
"version": "1.2.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.16.tgz",
"integrity": "sha512-qslZrXnKJU4cUpjoAmtAo/KQM9e2IRMlmpig6Zpwj+8KWxW8v+nkAf9216Bsek9lOC1jvg2yCjygCFo2f83msQ==",
"cpu": [
"arm64"
],
@@ -1881,9 +1881,9 @@
}
},
"node_modules/@rolldown/binding-darwin-x64": {
"version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.15.tgz",
"integrity": "sha512-5RtnsHM36nodM9WbMy+zip0LGS/G+y5wro5RHrpOlr4ac24+1zSi9kiV2MP1myZeZ/ifEHfulQcWjnAXzc2C2A==",
"version": "1.2.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.16.tgz",
"integrity": "sha512-FdF+y48ND13eAyM2mQn+85kvAqNlvRWROy2rhgA463X/0Gv4vGtazZtfaF1mRBLscYvtOlh4QnLjOWiFzX69OQ==",
"cpu": [
"x64"
],
@@ -1897,9 +1897,9 @@
}
},
"node_modules/@rolldown/binding-freebsd-x64": {
"version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.15.tgz",
"integrity": "sha512-g3l09pyFqhu+135JKkbkwiKGQmwU5hUMUPJ4VdBADvuqR8d33L6H578rh8m7rtxbdp+VmX/+nzI7nvhSZlzpUQ==",
"version": "1.2.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.16.tgz",
"integrity": "sha512-ddjqFM5e95AgLbUPtKIS7V1KTVlA0RUgCVr5mn+/IEhsdy3pr+Vuj/HZ7nlMN2FyxIwUktOH1LEPIQXO1v9y1Q==",
"cpu": [
"x64"
],
@@ -1913,9 +1913,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
"version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.15.tgz",
"integrity": "sha512-nIe3VugI97nGQtspPxDqA4sXMUpeiocKIP/CqpY/u/vgbT+alX+cVxu1C/oVBPKg4feNTmYovE91v4DgXvXNwQ==",
"version": "1.2.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.16.tgz",
"integrity": "sha512-tHdKWbFkFBI687iim3LIyIvyL7j66VvQYkDPss5Stn/qyE2tWkR1YD0dXmXPFKKqT/o5ZEJVsaRsWS2meQwGOA==",
"cpu": [
"arm"
],
@@ -1929,12 +1929,15 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-gnu": {
"version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.15.tgz",
"integrity": "sha512-iTBjXx+B5QSvgI89lJfmqh4wTh8NabR1YZIIN3hNx4zSG0EVfVqkOvLulNpG4251pbd6/IAmZ/LefVk1Y4rPhw==",
"version": "1.2.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.16.tgz",
"integrity": "sha512-FJiLGAblPBMlBEdbOqw6kW6QcXDWAf/oJu7gYs9ZJZwmg+K+/zjFLxPTIY9izJUzxzc24FHvmseyA0IvJSl17w==",
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1945,12 +1948,15 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-musl": {
"version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.15.tgz",
"integrity": "sha512-uN26XOnVR7rqQupG3V9oAG2EGGatWjcJHOeDOHUFCh4TBWmP2tHTFoVN13aYbwfm0aHqJAEmcwhAu6sooIjfqQ==",
"version": "1.2.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.16.tgz",
"integrity": "sha512-hrJpblMsrQ58U9veWpbXzkhV5zStunds6HWLprqq1qauvgBr1E6P+24kLj6lSLI9vjRn/RYpjprhkHQPKGWpNg==",
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1961,12 +1967,15 @@
}
},
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
"version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.15.tgz",
"integrity": "sha512-9nWkcdd+H+XhaA3fQIwpHnKIrfx/PQ6rO0pivr78Og07tXlUlctBBJoEYLPfhTmCR/QxEVdn6uPjHV5SymPhdw==",
"version": "1.2.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.16.tgz",
"integrity": "sha512-qJd/dC6j6CwLG9/s/RP2nb1RZ0GIgj7Ioj7x/SYa3RzjVrlzrmH0fhIOpfvqQDeIsdP0loi+kHaMhJzY1PBIaA==",
"cpu": [
"ppc64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1977,12 +1986,15 @@
}
},
"node_modules/@rolldown/binding-linux-s390x-gnu": {
"version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.15.tgz",
"integrity": "sha512-Ggh0ciFjLwIusLSLz7LZYEaGdxOAUJXI+Qu5JCbhPJJHEt8vP+IjRATTJheiJsdzzTYgUiHUiw3X1Ea3ZpMBdA==",
"version": "1.2.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.16.tgz",
"integrity": "sha512-Vx7Xg5uUJuNcujJjI70OICaH0EYj80BtOLZTSN7uOYg8QOLlyKnaG0IW7Fn/J0IpJsaKzmDKMhOQv1JBM9m9vg==",
"cpu": [
"s390x"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1993,12 +2005,15 @@
}
},
"node_modules/@rolldown/binding-linux-x64-gnu": {
"version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.15.tgz",
"integrity": "sha512-FHg60rS6DBePAYZ9X9F67x+JObFdmgn6tupA1C/a3B+CufFmTD4mX+e8M75tCiAO3foR/6xwVBI0KQiI7sn/rw==",
"version": "1.2.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.16.tgz",
"integrity": "sha512-TNKhgE3uJTsTzatUSSYt0Kgo+Oa2uSlSjz4r1aEIea7HX6ULBym4fAlYUhG1i6pjNf2njfCUAfKwr6C3E9Xg1w==",
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2009,12 +2024,15 @@
}
},
"node_modules/@rolldown/binding-linux-x64-musl": {
"version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.15.tgz",
"integrity": "sha512-/cvV/7v5VGz/moiz0XKRj2dE8DzsKyhIu1KpOvJGPjRywg3yscON889UGvAe8zfiJLHup7VLjBduoGWi3HusWQ==",
"version": "1.2.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.16.tgz",
"integrity": "sha512-7chXdpjXZ8qG0+mIxECN3cT9kvhZ3/oZhMX6Pp876P7CDrIy8nJswva6Mq357JR7eYXfuskwZxMeV1SzoZdHrA==",
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2025,9 +2043,9 @@
}
},
"node_modules/@rolldown/binding-openharmony-arm64": {
"version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.15.tgz",
"integrity": "sha512-MyzC5Hrv/sm+Z7TSGKP/j8001RO2xQm1zOpCNVL4j0cY/VhBZviludA2YFQ7i4lH6ZAXxQm0lcMdBzM+99mE7w==",
"version": "1.2.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.16.tgz",
"integrity": "sha512-Kwjt+7uNTJK5/MDkGWGVO2KQPnj8yAV75dMpfwyHZrJSXWHrniOB0aUEWlVYf3H1hOqFLIStLdM5QXq6bnKmOQ==",
"cpu": [
"arm64"
],
@@ -2041,9 +2059,9 @@
}
},
"node_modules/@rolldown/binding-win32-arm64-msvc": {
"version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.15.tgz",
"integrity": "sha512-+PgYiS+AH5omy6L45ZLxybasauJaj3M8buoT83D+nzPmCayXlxUV9hydhciCKVyIg0B/v0BtXPDE1uGqNwCPpg==",
"version": "1.2.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.16.tgz",
"integrity": "sha512-/LH6WfjDRXXJz/p5xl4TmU+jpcnwcxbKEQBW/GZE0JHfN0lGIyTtsUEGaQ/q/GikkGJ2yLKe6pwm7AnEWO8pdw==",
"cpu": [
"arm64"
],
@@ -2057,9 +2075,9 @@
}
},
"node_modules/@rolldown/binding-win32-x64-msvc": {
"version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.15.tgz",
"integrity": "sha512-HFZd2QNQYn1JbIi+Hpd7H+r/lkWxcfIu28nirZ6eCn2LafuvmExMlb/P96Sfx5tf7pPArw39IdhmCF8vmV+NhQ==",
"version": "1.2.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.16.tgz",
"integrity": "sha512-1lv1KvX0Z871zfk7vw+dnXMMnHNyDwMzWG8Kv3HxDXMOhzCX4SHc7HK319fInP0i+0p9D8brkfzL7pJOaGl+tw==",
"cpu": [
"x64"
],
@@ -4242,6 +4260,18 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/anynum": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/anynum/-/anynum-1.0.1.tgz",
"integrity": "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT"
},
"node_modules/archiver": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/archiver/-/archiver-7.0.1.tgz",
@@ -6576,9 +6606,9 @@
"license": "(MIT AND Zlib)"
},
"node_modules/fast-xml-builder": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz",
"integrity": "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==",
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.3.1.tgz",
"integrity": "sha512-pIM/1n3ntFXKYrUZwW7QCK0gAW7XY+wzj1YMIV3tLDvPj/V+zTGJK5e3/4WJfwj0qWw2ElNXiTixda/R+3YSug==",
"funding": [
{
"type": "github",
@@ -6587,14 +6617,14 @@
],
"license": "MIT",
"dependencies": {
"path-expression-matcher": "^1.5.0",
"xml-naming": "^0.1.0"
"path-expression-matcher": "^1.6.2",
"xml-naming": "^0.3.0"
}
},
"node_modules/fast-xml-parser": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.7.1.tgz",
"integrity": "sha512-8Cc3f8GUGUULg34pBch/KGyPLglS+OFs05deyOlY7fL2MTagYPKrVQNmR1fLF/yJ9PH5ZSTd3YDF6pnmeZU+zA==",
"version": "5.12.0",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.12.0.tgz",
"integrity": "sha512-/pXxRBtwxY9w8z7YbgsvIPPcPvFDsTbIkWwTQJhzNJPokj+TbN6ftmcv4j/pEQDvOCNnKGDzuk5Bo2oogIOiFg==",
"funding": [
{
"type": "github",
@@ -6603,10 +6633,12 @@
],
"license": "MIT",
"dependencies": {
"@nodable/entities": "^2.1.0",
"fast-xml-builder": "^1.1.5",
"path-expression-matcher": "^1.5.0",
"strnum": "^2.2.3"
"@nodable/entities": "^3.1.0",
"fast-xml-builder": "^1.3.1",
"is-unsafe": "^2.0.3",
"path-expression-matcher": "^1.6.2",
"strnum": "^2.5.1",
"xml-naming": "^0.3.0"
},
"bin": {
"fxparser": "src/cli/cli.js"
@@ -7582,6 +7614,18 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-unsafe": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/is-unsafe/-/is-unsafe-2.0.3.tgz",
"integrity": "sha512-ZBllczCCO267vgdFb6Tq3pv+LhHaCiGMdr+ZsuL+OopruAg7S+3ZDs1vD0RPC0uSrkjzbraxaGjtX1YBxjPeFQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT"
},
"node_modules/is-url": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/is-url/-/is-url-1.2.4.tgz",
@@ -9402,9 +9446,9 @@
}
},
"node_modules/path-expression-matcher": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz",
"integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==",
"version": "1.6.2",
"resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.6.2.tgz",
"integrity": "sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==",
"funding": [
{
"type": "github",
@@ -9640,9 +9684,9 @@
"license": "MIT-0"
},
"node_modules/postcss": {
"version": "8.5.31",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.31.tgz",
"integrity": "sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg==",
"version": "8.5.32",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.32.tgz",
"integrity": "sha512-LJlKCCAUFKOuZxipUdYw1NzIITEsPcgyOG9LkvRJvuKj/bRzbuRY0ARXvL0pWGpsZvn8jku+LMep2eU0seHp8g==",
"funding": [
{
"type": "opencollective",
@@ -10183,12 +10227,12 @@
"license": "Unlicense"
},
"node_modules/rolldown": {
"version": "1.2.15",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.15.tgz",
"integrity": "sha512-6odND+fel5zVw3I4Yc2e69gvguDnX0CAAGiNDZSq4ik5WUcaLrkD2GLuog0XcODbor31hZ5ayEMgVDno+KPV5g==",
"version": "1.2.16",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.16.tgz",
"integrity": "sha512-bWvlMpFKiTfDKfnK7FC2MPgPeWCf/Kpwsq49ApdW+FQaBZb6O6dZ4MAPbPUjp7hYQrw/RobqCzn64BRrQecGHw==",
"license": "MIT",
"dependencies": {
"@oxc-project/types": "=0.155.0",
"@oxc-project/types": "=0.156.0",
"@rolldown/pluginutils": "^1.0.0"
},
"bin": {
@@ -10198,21 +10242,21 @@
"node": "^20.19.0 || >=22.12.0"
},
"optionalDependencies": {
"@rolldown/binding-android-arm-eabi": "1.2.15",
"@rolldown/binding-android-arm64": "1.2.15",
"@rolldown/binding-darwin-arm64": "1.2.15",
"@rolldown/binding-darwin-x64": "1.2.15",
"@rolldown/binding-freebsd-x64": "1.2.15",
"@rolldown/binding-linux-arm-gnueabihf": "1.2.15",
"@rolldown/binding-linux-arm64-gnu": "1.2.15",
"@rolldown/binding-linux-arm64-musl": "1.2.15",
"@rolldown/binding-linux-ppc64-gnu": "1.2.15",
"@rolldown/binding-linux-s390x-gnu": "1.2.15",
"@rolldown/binding-linux-x64-gnu": "1.2.15",
"@rolldown/binding-linux-x64-musl": "1.2.15",
"@rolldown/binding-openharmony-arm64": "1.2.15",
"@rolldown/binding-win32-arm64-msvc": "1.2.15",
"@rolldown/binding-win32-x64-msvc": "1.2.15"
"@rolldown/binding-android-arm-eabi": "1.2.16",
"@rolldown/binding-android-arm64": "1.2.16",
"@rolldown/binding-darwin-arm64": "1.2.16",
"@rolldown/binding-darwin-x64": "1.2.16",
"@rolldown/binding-freebsd-x64": "1.2.16",
"@rolldown/binding-linux-arm-gnueabihf": "1.2.16",
"@rolldown/binding-linux-arm64-gnu": "1.2.16",
"@rolldown/binding-linux-arm64-musl": "1.2.16",
"@rolldown/binding-linux-ppc64-gnu": "1.2.16",
"@rolldown/binding-linux-s390x-gnu": "1.2.16",
"@rolldown/binding-linux-x64-gnu": "1.2.16",
"@rolldown/binding-linux-x64-musl": "1.2.16",
"@rolldown/binding-openharmony-arm64": "1.2.16",
"@rolldown/binding-win32-arm64-msvc": "1.2.16",
"@rolldown/binding-win32-x64-msvc": "1.2.16"
}
},
"node_modules/rollup": {
@@ -10867,16 +10911,19 @@
}
},
"node_modules/strnum": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.3.tgz",
"integrity": "sha512-oKx6RUCuHfT3oyVjtnrmn19H1SiCqgJSg+54XqURKp5aCMbrXrhLjRN9TjuwMjiYstZ0MzDrHqkGZ5dFTKd+zg==",
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.5.1.tgz",
"integrity": "sha512-UM4nTLWzQiDBseVxUGxN0wJzIk0UU89Qi7pKXz9qISo+lhviJikTuikBsK/oFRlpvYHvSdz0TVoLyQ+sEpYwxA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT"
"license": "MIT",
"dependencies": {
"anynum": "^1.0.1"
}
},
"node_modules/stylis": {
"version": "4.3.6",
@@ -11589,15 +11636,15 @@
}
},
"node_modules/vite": {
"version": "8.3.5",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.5.tgz",
"integrity": "sha512-z1YpjieJ9g0ebTmQpLBv/oq9E3F9EIlOe+7LahW8SrE3LTiG3udBRUXQOlYXP9b+IC2q2phHeVxj9xGlw34OEQ==",
"version": "8.3.6",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.6.tgz",
"integrity": "sha512-UI7dK29NnS54Fk+mmo7o+jI6U8Fp7hDl+X5Q1k45WH+d4dSlbLVUETGQ+hfkNgvLQGyoElPj837nZyv4MBc0VA==",
"license": "MIT",
"dependencies": {
"lightningcss": "^1.33.0",
"picomatch": "^4.0.7",
"postcss": "^8.5.30",
"rolldown": "~1.2.14",
"postcss": "^8.5.32",
"rolldown": "~1.2.16",
"tinyglobby": "^0.2.17"
},
"bin": {
@@ -12945,9 +12992,9 @@
}
},
"node_modules/xml-naming": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz",
"integrity": "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==",
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.3.0.tgz",
"integrity": "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ==",
"funding": [
{
"type": "github",
+2 -2
View File
@@ -65,7 +65,7 @@
"prettier": "^3.8.1",
"typescript": "~5.9.3",
"typescript-eslint": "^8.56.1",
"vite": "^8.3.5",
"vite": "^8.3.6",
"vite-plugin-handlebars": "^2.0.0",
"vite-plugin-node-polyfills": "^0.26.0",
"vitepress": "^1.6.4",
@@ -108,7 +108,7 @@
"cropperjs": "^1.6.2",
"diff": "^8.0.3",
"dompurify": "^3.4.17",
"fast-xml-parser": "^5.7.1",
"fast-xml-parser": "^5.12.0",
"heic2any": "^0.0.4",
"highlight.js": "^11.12.0",
"html2canvas": "^1.4.1",
+11 -7
View File
@@ -61,6 +61,14 @@ export const languageNames: Record<SupportedLanguage, string> = {
fa: 'فارسی',
};
// Longest codes first so multi-part tags like zh-TW win over their prefix (zh).
const LANGUAGE_PATTERN = [...supportedLanguages]
.sort((a, b) => b.length - a.length)
.join('|');
const URL_LANGUAGE_REGEX = new RegExp(`^/(${LANGUAGE_PATTERN})(?:/|$)`);
const LANGUAGE_PREFIX_REGEX = new RegExp(`^/(${LANGUAGE_PATTERN})(/.*)?$`);
export const getLanguageFromUrl = (): SupportedLanguage => {
const basePath = import.meta.env.BASE_URL.replace(/\/$/, '');
let path = window.location.pathname;
@@ -73,9 +81,7 @@ export const getLanguageFromUrl = (): SupportedLanguage => {
path = '/' + path;
}
const langMatch = path.match(
/^\/(en|ar|fr|es|de|zh|zh-TW|vi|tr|id|it|pt|nl|be|da|ko|sv|ru|ja|uk|sk|ca|fa)(?:\/|$)/
);
const langMatch = path.match(URL_LANGUAGE_REGEX);
if (
langMatch &&
supportedLanguages.includes(langMatch[1] as SupportedLanguage)
@@ -211,9 +217,7 @@ export const changeLanguage = (lang: SupportedLanguage): void => {
}
let pagePathWithoutLang = relativePath;
const langPrefixMatch = relativePath.match(
/^\/(en|ar|fr|es|de|zh|zh-TW|vi|tr|id|it|pt|nl|be|da|ko|sv|ru|ja|uk|sk|ca|fa)(\/.*)?$/
);
const langPrefixMatch = relativePath.match(LANGUAGE_PREFIX_REGEX);
if (langPrefixMatch) {
pagePathWithoutLang = langPrefixMatch[2] || '/';
}
@@ -320,7 +324,7 @@ export const rewriteLinks = (): void => {
}
const langPrefixRegex = new RegExp(
`^(${basePath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})?/?(en|ar|fr|es|de|zh|zh-TW|vi|tr|id|it|pt|nl|be|da|ko|sv|ru|ja|uk|sk|ca|fa)(/|$|[?#])`
`^(${basePath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})?/?(${LANGUAGE_PATTERN})(/|$|[?#])`
);
if (langPrefixRegex.test(href)) {
return;
+19 -13
View File
@@ -367,7 +367,10 @@ function createSignatureCard(
let statusColor = 'text-green-400';
let statusIcon = 'check-circle';
let statusText = trText('Valid Signature');
const isTimestamp = result.isTimestamp === true;
let statusText = isTimestamp
? trText('Valid Timestamp')
: trText('Valid Signature');
if (!result.isValid) {
if (result.cryptoVerificationStatus === 'unsupported') {
@@ -380,19 +383,19 @@ function createSignatureCard(
) {
statusColor = 'text-yellow-400';
statusIcon = 'alert-triangle';
statusText = trText('Modified After Signing (signed part intact)');
statusText = isTimestamp
? trText('Modified After Timestamping (timestamped part intact)')
: trText('Modified After Signing (signed part intact)');
} else {
statusColor = 'text-red-400';
statusIcon = 'x-circle';
statusText =
result.cryptoVerified === false
? trText('Invalid: Cryptographic Verification Failed')
: trText('Invalid Signature');
: result.usesInsecureDigest
? trText('Insecure Digest (MD5 / SHA-1)')
: trText('Invalid Signature');
}
} else if (result.usesInsecureDigest) {
statusColor = 'text-red-400';
statusIcon = 'x-circle';
statusText = trText('Insecure Digest (MD5 / SHA-1)');
} else if (result.isExpired) {
statusColor = 'text-yellow-400';
statusIcon = 'alert-triangle';
@@ -400,9 +403,11 @@ function createSignatureCard(
} else if (!result.isTrusted) {
statusColor = 'text-yellow-400';
statusIcon = 'alert-triangle';
statusText = result.isSelfSigned
? trText('Self-Signed: Signer Identity Not Verified')
: trText('Signature Intact: Signer Identity Not Verified');
statusText = isTimestamp
? trText('Timestamp Intact: Timestamp Authority Not Verified')
: result.isSelfSigned
? trText('Self-Signed: Signer Identity Not Verified')
: trText('Signature Intact: Signer Identity Not Verified');
}
const formatDate = (date: Date) => {
@@ -430,7 +435,7 @@ function createSignatureCard(
<div class="flex items-center gap-3">
<i data-lucide="${statusIcon}" class="w-6 h-6 ${statusColor}"></i>
<div>
<h3 class="font-semibold text-white">${trHtml('Signature {{n}}', { n: index + 1 })}</h3>
<h3 class="font-semibold text-white">${isTimestamp ? trHtml('Document Timestamp {{n}}', { n: index + 1 }) : trHtml('Signature {{n}}', { n: index + 1 })}</h3>
<p class="text-sm ${statusColor}">${statusText}</p>
</div>
</div>
@@ -448,7 +453,7 @@ function createSignatureCard(
<div class="space-y-3 text-sm">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<p class="text-gray-400">${trHtml('Signed By')}</p>
<p class="text-gray-400">${isTimestamp ? trHtml('Timestamped By') : trHtml('Signed By')}</p>
<p class="text-white font-medium">${escapeHtml(result.signerName)}</p>
${result.signerOrg ? `<p class="text-gray-400 text-xs">${escapeHtml(result.signerOrg)}</p>` : ''}
${result.signerEmail ? `<p class="text-gray-400 text-xs">${escapeHtml(result.signerEmail)}</p>` : ''}
@@ -464,7 +469,7 @@ function createSignatureCard(
result.signatureDate
? `
<div>
<p class="text-gray-400">${trHtml('Signed On')}</p>
<p class="text-gray-400">${isTimestamp ? trHtml('Timestamped On') : trHtml('Signed On')}</p>
<p class="text-white">${formatDate(result.signatureDate)}</p>
</div>
`
@@ -512,6 +517,7 @@ function createSignatureCard(
<p><span class="text-gray-400">${trHtml('Serial Number:')}</span> <span class="text-gray-300 font-mono">${escapeHtml(result.serialNumber)}</span></p>
<p><span class="text-gray-400">${trHtml('Digest Algorithm:')}</span> <span class="text-gray-300">${escapeHtml(result.algorithms.digest)}</span></p>
<p><span class="text-gray-400">${trHtml('Signature Algorithm:')}</span> <span class="text-gray-300">${escapeHtml(result.algorithms.signature)}</span></p>
${result.algorithms.imprint ? `<p><span class="text-gray-400">${trHtml('Timestamp Imprint Algorithm:')}</span> <span class="text-gray-300">${escapeHtml(result.algorithms.imprint)}</span></p>` : ''}
${result.errorMessage ? `<p class="text-red-400">${trHtml('Error: {{error}}', { error: result.errorMessage })}</p>` : ''}
${result.unsupportedAlgorithmReason ? `<p class="text-yellow-300">${escapeHtml(result.unsupportedAlgorithmReason)}</p>` : ''}
</div>
+148 -22
View File
@@ -11,6 +11,8 @@ const INSECURE_DIGEST_OIDS = new Set<string>([
const MIN_RSA_EXPONENT = new forge.jsbn.BigInteger('65537');
const TST_INFO_OID = '1.2.840.113549.1.9.16.1.4';
const PDF_WHITESPACE = new Set('\0\t\n\f\r ');
const PDF_TOKEN_TERMINATORS = new Set('\0\t\n\f\r ()<>[]{}/%');
const PDF_STRING_ESCAPES: Record<string, string> = {
@@ -52,13 +54,15 @@ export function extractSignatures(
if (end < 0) continue;
for (const entries of dictionaries) {
if (isSignatureDictionary(pdfString, entries)) {
const kind = getSignatureKind(pdfString, entries);
if (kind) {
signatures.push(
readSignatureDictionary(
pdfString,
pdfBytes,
entries,
signatures.length
signatures.length,
kind === 'timestamp'
)
);
}
@@ -72,33 +76,36 @@ export function extractSignatures(
* /Type is optional in a signature dictionary (PDF 32000-1, 12.8.1, Table
* 252), and some signers leave it out, so a dictionary without /Type that
* carries both /ByteRange and /Contents is a signature too. Document
* timestamps (/Type /DocTimeStamp, /SubFilter /ETSI.RFC3161) hold an RFC 3161
* token rather than a CMS signature over the document and stay out.
* timestamps (/Type /DocTimeStamp, /SubFilter /ETSI.RFC3161, ISO 32000-2,
* 12.8.5) hold an RFC 3161 token instead of a CMS signature over the
* document, so they are reported separately and verified differently.
*/
function isSignatureDictionary(
function getSignatureKind(
pdfString: string,
entries: Map<string, PdfValueSpan>
): boolean {
): 'signature' | 'timestamp' | null {
const name = (key: string) => {
const span = entries.get(key);
return span ? pdfString.slice(span.start, span.end) : undefined;
};
const type = name('Type');
if (type !== undefined) return type === '/Sig';
return (
entries.has('ByteRange') &&
entries.has('Contents') &&
name('SubFilter') !== '/ETSI.RFC3161'
);
if (type === '/DocTimeStamp') return 'timestamp';
if (type !== undefined) return type === '/Sig' ? 'signature' : null;
if (!entries.has('ByteRange') || !entries.has('Contents')) return null;
return name('SubFilter') === '/ETSI.RFC3161' ? 'timestamp' : 'signature';
}
function readSignatureDictionary(
pdfString: string,
pdfBytes: Uint8Array,
entries: Map<string, PdfValueSpan>,
index: number
index: number,
isTimestamp: boolean
): ExtractedSignatureWithError {
const text = (key: string) => decodePdfText(pdfBytes, entries.get(key));
// A PDF processor should ignore these on ETSI.RFC3161 dictionaries (ISO
// 32000-2, Table 255)
const signerText = (key: string) => (isTimestamp ? undefined : text(key));
const timeMatch = text('M')?.match(
/^D:(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})/
);
@@ -106,13 +113,14 @@ function readSignatureDictionary(
index,
contents: new Uint8Array(0),
byteRange: [],
reason: text('Reason'),
location: text('Location'),
contactInfo: text('ContactInfo'),
name: text('Name'),
reason: signerText('Reason'),
location: signerText('Location'),
contactInfo: signerText('ContactInfo'),
name: signerText('Name'),
signingTime: timeMatch
? `${timeMatch[1]}-${timeMatch[2]}-${timeMatch[3]}T${timeMatch[4]}:${timeMatch[5]}:${timeMatch[6]}`
: undefined,
isTimestamp,
};
const rangeSpan = entries.get('ByteRange');
@@ -342,6 +350,7 @@ export async function validateSignature(
reason: signature.reason,
location: signature.location,
contactInfo: signature.contactInfo,
isTimestamp: signature.isTimestamp,
};
if (signature.extractionError) {
@@ -357,6 +366,13 @@ export async function validateSignature(
Array.from(signature.contents)
);
const asn1 = forge.asn1.fromDer(binaryString);
const timestamp = signature.isTimestamp ? unwrapTimestampToken(asn1) : null;
if (signature.isTimestamp && !timestamp) {
result.errorMessage = trText('Could not read the timestamp token');
result.cryptoVerified = false;
result.cryptoVerificationStatus = 'failed';
return result;
}
const p7 = forge.pkcs7.messageFromAsn1(
asn1
) as forge.pkcs7.PkcsSignedData & {
@@ -429,14 +445,22 @@ export async function validateSignature(
(digestOid && getDigestAlgorithmName(digestOid)) ||
getDigestAlgorithmName(signerCert.siginfo?.algorithmOid || ''),
signature: getSignatureAlgorithmName(signerCert.signatureOid || ''),
imprint: timestamp
? getDigestAlgorithmName(timestamp.imprintOid)
: undefined,
};
if (digestOid && INSECURE_DIGEST_OIDS.has(digestOid)) {
result.usesInsecureDigest = true;
}
if (timestamp && INSECURE_DIGEST_OIDS.has(timestamp.imprintOid)) {
result.usesInsecureDigest = true;
}
// Parse signing time if available in signature
if (signature.signingTime) {
if (timestamp?.genTime) {
result.signatureDate = timestamp.genTime;
} else if (signature.signingTime) {
result.signatureDate = new Date(signature.signingTime);
} else {
try {
@@ -490,7 +514,8 @@ export async function validateSignature(
pdfBytes,
signature.byteRange,
signerCert,
signerInfoFields
signerInfoFields,
timestamp
);
result.cryptoVerified = verification.status === 'verified';
@@ -640,6 +665,80 @@ interface SignerInfoFields {
signatureBytes: string;
}
interface TimestampToken {
tstInfo: Uint8Array;
imprintOid: string;
imprint: string;
genTime?: Date;
}
/**
* Reads the TSTInfo out of an RFC 3161 timestamp token and relabels its
* encapsulated content as id-data, because node-forge only parses SignedData
* that wraps plain data.
*/
function unwrapTimestampToken(
contentInfo: forge.asn1.Asn1
): TimestampToken | null {
try {
const signedData = asn1Children(asn1Children(contentInfo)?.[1])?.[0];
const encapContentInfo = asn1Children(signedData)?.[2];
const [typeNode, explicitContent] = asn1Children(encapContentInfo) ?? [];
if (
typeNode?.type !== forge.asn1.Type.OID ||
forge.asn1.derToOid(typeNode.value as string) !== TST_INFO_OID
) {
return null;
}
const octets = asn1Children(explicitContent)?.[0];
if (!octets || octets.type !== forge.asn1.Type.OCTETSTRING) return null;
const tstInfoDer =
typeof octets.value === 'string'
? octets.value
: (octets.value as forge.asn1.Asn1[])
.map((chunk) => chunk.value as string)
.join('');
const tstInfo = asn1Children(forge.asn1.fromDer(tstInfoDer));
const [hashAlgorithm, hashedMessage] = asn1Children(tstInfo?.[2]) ?? [];
const imprintOidNode = asn1Children(hashAlgorithm)?.[0];
if (
imprintOidNode?.type !== forge.asn1.Type.OID ||
hashedMessage?.type !== forge.asn1.Type.OCTETSTRING ||
typeof hashedMessage.value !== 'string'
) {
return null;
}
let genTime: Date | undefined;
const genTimeNode = tstInfo?.[4];
if (
genTimeNode?.type === forge.asn1.Type.GENERALIZEDTIME &&
typeof genTimeNode.value === 'string'
) {
genTime = forge.asn1.generalizedTimeToDate(genTimeNode.value);
}
typeNode.value = forge.asn1.oidToDer(forge.pki.oids.data).getBytes();
return {
tstInfo: latin1ToUint8(tstInfoDer),
imprintOid: forge.asn1.derToOid(imprintOidNode.value as string),
imprint: hashedMessage.value,
genTime,
};
} catch {
return null;
}
}
function asn1Children(
node: forge.asn1.Asn1 | undefined
): forge.asn1.Asn1[] | null {
return node && Array.isArray(node.value) ? node.value : null;
}
function extractSignerInfoFields(
p7: forge.pkcs7.PkcsSignedData & {
rawCapture?: {
@@ -1019,7 +1118,8 @@ async function performCryptoVerification(
pdfBytes: Uint8Array,
byteRange: number[],
signerCert: forge.pki.Certificate,
fields: SignerInfoFields | null
fields: SignerInfoFields | null,
timestamp: TimestampToken | null = null
): Promise<CryptoVerificationResult> {
if (!fields) {
return { status: 'failed', reason: trText('Could not parse signer info') };
@@ -1044,7 +1144,33 @@ async function performCryptoVerification(
signedContent.set(pdfBytes.subarray(start1, start1 + len1), 0);
signedContent.set(pdfBytes.subarray(start2, start2 + len2), len1);
md.update(uint8ToLatin1(signedContent));
// A timestamp token signs its TSTInfo, which in turn holds the hash of the
// document bytes (RFC 3161, 2.4.2), so the document is checked against that
// imprint and the CMS signature against the TSTInfo.
let content: Uint8Array = signedContent;
if (timestamp) {
const imprintMd = createMd(timestamp.imprintOid);
if (!imprintMd) {
return {
status: 'unsupported',
reason: trText('Unsupported digest OID {{digestOid}}', {
digestOid: timestamp.imprintOid,
}),
};
}
imprintMd.update(uint8ToLatin1(signedContent));
if (imprintMd.digest().bytes() !== timestamp.imprint) {
return {
status: 'failed',
reason: trText(
'Document hash does not match the timestamp: PDF was modified after timestamping'
),
};
}
content = timestamp.tstInfo;
}
md.update(uint8ToLatin1(content));
const contentHashBytes = md.digest().bytes();
const authAttrs = fields.authAttrs;
@@ -1107,7 +1233,7 @@ async function performCryptoVerification(
);
signedBytesForVerify = latin1ToUint8(forge.asn1.toDer(asSet).getBytes());
} else {
signedBytesForVerify = signedContent;
signedBytesForVerify = content;
}
if (scheme.kind === 'rsa-pkcs1' && !hasLowRsaExponent(signerCert.publicKey)) {
+3
View File
@@ -17,6 +17,7 @@ export interface SignatureValidationResult {
algorithms: {
digest: string;
signature: string;
imprint?: string;
};
serialNumber: string;
reason?: string;
@@ -29,6 +30,7 @@ export interface SignatureValidationResult {
cryptoVerificationStatus?: 'verified' | 'failed' | 'unsupported';
unsupportedAlgorithmReason?: string;
usesInsecureDigest?: boolean;
isTimestamp?: boolean;
}
export interface ExtractedSignature {
@@ -40,6 +42,7 @@ export interface ExtractedSignature {
contactInfo?: string;
name?: string;
signingTime?: string;
isTimestamp?: boolean;
}
export interface ValidateSignatureState {
+6
View File
@@ -136,6 +136,8 @@
>
<button
id="reset-btn"
title="Reset"
data-i18n-title="multiTool.reset"
style="display: none"
class="flex items-center gap-1.5 bg-gray-700 hover:bg-gray-600 text-white px-2.5 sm:px-3 py-1.5 rounded-lg text-sm transition-colors"
>
@@ -158,6 +160,8 @@
<button
id="faq-open-btn"
title="FAQ"
data-i18n-title="footer.faqLink"
type="button"
class="flex items-center gap-1.5 bg-gray-700 hover:bg-gray-600 text-white px-2.5 sm:px-3 py-1.5 rounded-lg text-sm transition-colors"
aria-label="FAQ"
@@ -171,6 +175,8 @@
<button
id="close-tool-btn"
title="Close"
data-i18n-title="common.close"
class="flex items-center gap-1.5 bg-gray-700 hover:bg-gray-600 text-white px-2.5 sm:px-3 py-1.5 rounded-lg text-sm transition-colors"
aria-label="Close"
>
+8
View File
@@ -315,12 +315,14 @@
>
<button
id="toolbox-toggle"
title="Toolbox"
class="md:hidden bg-gray-700 hover:bg-gray-600 text-white p-1.5 rounded-lg transition-colors"
>
<i class="ph ph-plus-circle text-lg"></i>
</button>
<button
id="run-btn"
title="Run"
class="bg-indigo-600 hover:bg-indigo-700 text-white font-semibold px-3 md:px-4 py-1.5 rounded-lg flex items-center gap-1.5 text-sm transition-colors"
>
<i class="ph ph-play text-base"></i>
@@ -328,6 +330,7 @@
</button>
<button
id="clear-btn"
title="Clear"
class="bg-gray-700 hover:bg-gray-600 text-white px-2.5 md:px-3 py-1.5 rounded-lg text-sm flex items-center gap-1 transition-colors"
>
<i class="ph ph-trash text-base md:hidden"></i>
@@ -336,6 +339,7 @@
<div class="flex-1"></div>
<button
id="save-btn"
title="Save"
class="bg-gray-700 hover:bg-gray-600 text-white px-2.5 md:px-3 py-1.5 rounded-lg text-sm flex items-center gap-1 transition-colors"
>
<i class="ph ph-floppy-disk text-sm"></i>
@@ -343,6 +347,7 @@
</button>
<button
id="faq-open-btn"
title="FAQ"
type="button"
class="bg-gray-700 hover:bg-gray-600 text-white px-2.5 md:px-3 py-1.5 rounded-lg text-sm flex items-center gap-1 transition-colors"
aria-haspopup="dialog"
@@ -354,6 +359,7 @@
</button>
<button
id="load-btn"
title="Load"
class="bg-gray-700 hover:bg-gray-600 text-white px-2.5 md:px-3 py-1.5 rounded-lg text-sm flex items-center gap-1 transition-colors"
>
<i class="ph ph-folder-open text-sm"></i>
@@ -361,6 +367,7 @@
</button>
<button
id="export-btn"
title="Export"
class="bg-gray-700 hover:bg-gray-600 text-white px-2.5 md:px-3 py-1.5 rounded-lg text-sm flex items-center gap-1 transition-colors"
>
<i class="ph ph-export text-sm"></i>
@@ -368,6 +375,7 @@
</button>
<button
id="import-btn"
title="Import"
class="bg-gray-700 hover:bg-gray-600 text-white px-2.5 md:px-3 py-1.5 rounded-lg text-sm flex items-center gap-1 transition-colors"
>
<i class="ph ph-download-simple text-sm"></i>
+69
View File
@@ -3,6 +3,8 @@ import {
changeLanguage,
getLanguageFromUrl,
LANGUAGE_CHOICE_KEY,
rewriteLinks,
supportedLanguages,
} from '@/js/i18n/i18n';
describe('getLanguageFromUrl', () => {
@@ -75,6 +77,22 @@ describe('getLanguageFromUrl', () => {
window.location.pathname = '/zh-TW/merge-pdf';
expect(getLanguageFromUrl()).toBe('zh-TW');
});
it('should match every supported language from the URL', () => {
supportedLanguages.forEach((lang) => {
window.location.pathname = `/${lang}/`;
expect(getLanguageFromUrl()).toBe(lang);
window.location.pathname = `/${lang}/merge-pdf`;
expect(getLanguageFromUrl()).toBe(lang);
});
});
it('should match Polish from the URL', () => {
window.location.pathname = '/pl/';
expect(getLanguageFromUrl()).toBe('pl');
window.location.pathname = '/pl/merge-pdf';
expect(getLanguageFromUrl()).toBe('pl');
});
});
describe('changeLanguage', () => {
@@ -143,4 +161,55 @@ describe('changeLanguage', () => {
changeLanguage('ja');
expect(target).toBe('/pdf/ja/merge-pdf');
});
it('swaps the Polish prefix instead of nesting it', () => {
at('/pl/merge-pdf');
changeLanguage('de');
expect(target).toBe('/de/merge-pdf');
at('/pl/');
changeLanguage('en');
expect(target).toBe('/');
});
});
describe('rewriteLinks', () => {
const originalLocation = window.location;
function at(pathname: string) {
Object.defineProperty(window, 'location', {
value: { ...originalLocation, pathname },
writable: true,
configurable: true,
});
}
beforeEach(() => {
document.body.innerHTML = '';
vi.stubEnv('BASE_URL', '/');
});
afterEach(() => {
Object.defineProperty(window, 'location', {
value: originalLocation,
writable: true,
configurable: true,
});
document.body.innerHTML = '';
vi.unstubAllEnvs();
});
it('prefixes links on Polish pages and leaves language links alone', () => {
at('/pl/merge-pdf');
document.body.innerHTML =
'<a href="/tools">tools</a>' +
'<a href="/de/merge-pdf">de</a>' +
'<a href="/pl/merge-pdf">pl</a>';
rewriteLinks();
const links = document.querySelectorAll('a');
expect(links[0].getAttribute('href')).toBe('/pl/tools');
expect(links[1].getAttribute('href')).toBe('/de/merge-pdf');
expect(links[2].getAttribute('href')).toBe('/pl/merge-pdf');
});
});
+27
View File
@@ -0,0 +1,27 @@
import fs from 'node:fs';
import path from 'node:path';
import { describe, it, expect } from 'vitest';
const PAGES_DIR = path.resolve(__dirname, '../pages');
function loadPage(name: string): Document {
const html = fs.readFileSync(path.join(PAGES_DIR, name), 'utf8');
return new DOMParser().parseFromString(html, 'text/html');
}
describe('top bar hover text', () => {
it.each(['pdf-multi-tool.html', 'pdf-workflow.html'])(
'gives every %s button that hides its label a title',
(page) => {
const labels = loadPage(page).querySelectorAll(
'button span[class*="hidden"][class*=":inline"]'
);
expect(labels.length).toBeGreaterThan(0);
labels.forEach((label) => {
const button = label.closest('button');
expect(button?.title, button?.id).toBe(label.textContent?.trim());
});
}
);
});
@@ -167,15 +167,43 @@ describe('validatePdfSignatures extraction and coverage', () => {
const pdfBytes = new TextEncoder().encode(
'%PDF-1.7\n' +
'1 0 obj\n<< /Subtype /Text /Contents (A note) >>\nendobj\n' +
'2 0 obj\n<< /Filter /Adobe.PPKLite /SubFilter /ETSI.RFC3161 ' +
'/ByteRange [0 10 20 10] /Contents <00> >>\nendobj\n' +
'3 0 obj\n<< /Type /DocTimeStamp /ByteRange [0 10 20 10] ' +
'2 0 obj\n<< /Type /Annot /ByteRange [0 10 20 10] ' +
'/Contents <00> >>\nendobj\n%%EOF\n'
);
expect(countSignatures(pdfBytes)).toBe(0);
});
it('reports document timestamps as timestamps, typed or not', () => {
const pdfBytes = new TextEncoder().encode(
'%PDF-1.7\n' +
'1 0 obj\n<< /Filter /Adobe.PPKLite /SubFilter /ETSI.RFC3161 ' +
'/ByteRange [0 10 20 10] /Contents <00> >>\nendobj\n' +
'2 0 obj\n<< /Type /DocTimeStamp /ByteRange [0 10 20 10] ' +
'/Contents <00> >>\nendobj\n%%EOF\n'
);
const signatures = extractSignatures(pdfBytes);
expect(signatures).toHaveLength(2);
expect(signatures.every((sig) => sig.isTimestamp)).toBe(true);
});
it('ignores Reason, Location, ContactInfo and Name on document timestamps', () => {
const pdfBytes = new TextEncoder().encode(
'%PDF-1.7\n' +
'1 0 obj\n<< /Type /DocTimeStamp /SubFilter /ETSI.RFC3161 ' +
'/Reason (r) /Location (l) /ContactInfo (c) /Name (n) ' +
'/ByteRange [0 10 20 10] /Contents <00> >>\nendobj\n%%EOF\n'
);
const [signature] = extractSignatures(pdfBytes);
expect(signature.isTimestamp).toBe(true);
expect(signature.reason).toBeUndefined();
expect(signature.location).toBeUndefined();
expect(signature.contactInfo).toBeUndefined();
expect(signature.name).toBeUndefined();
});
it('reports an intact signature as partial when an incremental update follows', async () => {
const signed = await signPdf(samplePdf, certificateData, {
signatureInfo: { reason: 'Original' },
@@ -0,0 +1,167 @@
import { describe, it, expect } from 'vitest';
import forge from 'node-forge';
import { validatePdfSignatures } from '@/js/logic/validate-signature-pdf';
const TST_INFO_OID = '1.2.840.113549.1.9.16.1.4';
const RANGE_PLACEHOLDER = '{{RANGE}}';
const RANGE_WIDTH = 64;
const HEX_LENGTH = 8192;
const HEAD =
'%PDF-1.7\n1 0 obj\n<< /Type /DocTimeStamp /Filter /Adobe.PPKLite ' +
`/SubFilter /ETSI.RFC3161 ${RANGE_PLACEHOLDER} /Contents `;
const TAIL = ' >>\nendobj\ntrailer\n<< /Root 1 0 R >>\n%%EOF\n';
const keys = forge.pki.rsa.generateKeyPair(1024);
const tsaCert = forge.pki.createCertificate();
tsaCert.publicKey = keys.publicKey;
tsaCert.serialNumber = '02';
tsaCert.validity.notBefore = new Date(Date.now() - 86400000);
tsaCert.validity.notAfter = new Date(Date.now() + 86400000 * 365);
tsaCert.setSubject([{ name: 'commonName', value: 'Test TSA' }]);
tsaCert.setIssuer([{ name: 'commonName', value: 'Test TSA' }]);
tsaCert.sign(keys.privateKey, forge.md.sha256.create());
const { asn1 } = forge;
const node = (type: number, value: string | forge.asn1.Asn1[]) =>
asn1.create(asn1.Class.UNIVERSAL, type, Array.isArray(value), value);
const oid = (id: string) => node(asn1.Type.OID, asn1.oidToDer(id).getBytes());
function tstInfoDer(imprint: string, imprintOid: string): string {
return asn1
.toDer(
node(asn1.Type.SEQUENCE, [
node(asn1.Type.INTEGER, asn1.integerToDer(1).getBytes()),
oid('1.2.3.4.1'),
node(asn1.Type.SEQUENCE, [
node(asn1.Type.SEQUENCE, [oid(imprintOid), node(asn1.Type.NULL, '')]),
node(asn1.Type.OCTETSTRING, imprint),
]),
node(asn1.Type.INTEGER, asn1.integerToDer(42).getBytes()),
node(asn1.Type.GENERALIZEDTIME, '20260102030405Z'),
])
)
.getBytes();
}
/** An RFC 3161 token whose TSTInfo carries the given document hash. */
function timestampTokenHex(imprint: string, imprintOid: string): string {
const p7 = forge.pkcs7.createSignedData();
p7.content = forge.util.createBuffer(tstInfoDer(imprint, imprintOid));
p7.addCertificate(tsaCert);
p7.addSigner({
key: keys.privateKey,
certificate: tsaCert,
digestAlgorithm: forge.pki.oids.sha256,
authenticatedAttributes: [
{ type: forge.pki.oids.contentType, value: TST_INFO_OID },
{ type: forge.pki.oids.messageDigest },
],
});
p7.sign();
const contentInfo = p7.toAsn1();
// node-forge always labels the content as id-data; a TSA labels it TSTInfo
const signedData = (contentInfo.value as forge.asn1.Asn1[])[1]
.value as forge.asn1.Asn1[];
const encap = (signedData[0].value as forge.asn1.Asn1[])[2]
.value as forge.asn1.Asn1[];
encap[0] = oid(TST_INFO_OID);
return forge.util.bytesToHex(asn1.toDer(contentInfo).getBytes());
}
function sha256(content: string): string {
const md = forge.md.sha256.create();
md.update(content);
return md.digest().bytes();
}
function buildTimestampedPdf(
imprintFor: (signedContent: string) => string = sha256,
imprintOid: string = forge.pki.oids.sha256
): Uint8Array {
const headLength = HEAD.length - RANGE_PLACEHOLDER.length + RANGE_WIDTH;
const gapLength = HEX_LENGTH + 2;
const byteRange = [0, headLength, headLength + gapLength, TAIL.length];
const head = HEAD.replace(
RANGE_PLACEHOLDER,
`/ByteRange [${byteRange.join(' ')}]`.padEnd(RANGE_WIDTH)
);
const hex = timestampTokenHex(imprintFor(head + TAIL), imprintOid).padEnd(
HEX_LENGTH,
'0'
);
return new TextEncoder().encode(`${head}<${hex}>${TAIL}`);
}
describe('document timestamps (RFC 3161)', () => {
it('verifies an intact document timestamp', async () => {
const [result] = await validatePdfSignatures(buildTimestampedPdf());
expect(result.isTimestamp).toBe(true);
expect(result.cryptoVerificationStatus).toBe('verified');
expect(result.coverageStatus).toBe('full');
expect(result.isValid).toBe(true);
expect(result.usesInsecureDigest).toBeFalsy();
expect(result.algorithms.imprint).toBe('SHA-256');
expect(result.signerName).toBe('Test TSA');
expect(result.signatureDate?.toISOString()).toBe(
'2026-01-02T03:04:05.000Z'
);
});
it.each([
['SHA-1', forge.md.sha1, forge.pki.oids.sha1],
['MD5', forge.md.md5, forge.pki.oids.md5],
])('treats a %s messageImprint as insecure', async (name, algorithm, id) => {
const imprintFor = (content: string) => {
const md = algorithm.create();
md.update(content);
return md.digest().bytes();
};
const [result] = await validatePdfSignatures(
buildTimestampedPdf(imprintFor, id)
);
expect(result.cryptoVerificationStatus).toBe('verified');
expect(result.usesInsecureDigest).toBe(true);
expect(result.isValid).toBe(false);
expect(result.algorithms.imprint).toBe(name);
});
it('fails when the document no longer matches the timestamped hash', async () => {
const pdfBytes = buildTimestampedPdf();
// Rename /Adobe.PPKLite to /Adobe.QPKLite: still a valid PDF, new bytes
const at = new TextDecoder('latin1').decode(pdfBytes).indexOf('PPKLite');
pdfBytes[at] = 'Q'.charCodeAt(0);
const [result] = await validatePdfSignatures(pdfBytes);
expect(result.isValid).toBe(false);
expect(result.cryptoVerificationStatus).toBe('failed');
expect(result.errorMessage).toMatch(/modified after timestamping/);
});
it('fails when the token holds the hash of a different document', async () => {
const [result] = await validatePdfSignatures(
buildTimestampedPdf(() => sha256('some other document'))
);
expect(result.isValid).toBe(false);
expect(result.cryptoVerificationStatus).toBe('failed');
});
it('fails when the TSTInfo was altered after the TSA signed it', async () => {
const pdfBytes = buildTimestampedPdf();
const text = new TextDecoder('latin1').decode(pdfBytes);
// GeneralizedTime 20260102030405Z, hex-encoded inside /Contents
const timeHex = forge.util.bytesToHex('20260102030405Z');
const at = text.indexOf(timeHex);
expect(at).toBeGreaterThan(0);
pdfBytes[at + 1] = pdfBytes[at + 1] === 0x31 ? 0x32 : 0x31;
const [result] = await validatePdfSignatures(pdfBytes);
expect(result.isValid).toBe(false);
expect(result.cryptoVerificationStatus).toBe('failed');
});
});
+54
View File
@@ -2155,6 +2155,12 @@
],
"fileCount": 1
},
"Could not read the timestamp token": {
"files": [
"logic/validate-signature-pdf.ts"
],
"fileCount": 1
},
"Could not reduce file size further. Original: {{originalSize}}, New: {{compressedSize}}.": {
"files": [
"logic/compress-pdf-page.ts"
@@ -2580,12 +2586,24 @@
],
"fileCount": 1
},
"Document Timestamp {{n}}": {
"files": [
"logic/validate-signature-pdf-page.ts"
],
"fileCount": 1
},
"Document Type": {
"files": [
"logic/page-dimensions-page.ts"
],
"fileCount": 1
},
"Document hash does not match the timestamp: PDF was modified after timestamping": {
"files": [
"logic/validate-signature-pdf.ts"
],
"fileCount": 1
},
"Document properties": {
"files": [
"logic/edit-pdf-text-dock.ts"
@@ -5222,6 +5240,12 @@
],
"fileCount": 1
},
"Modified After Timestamping (timestamped part intact)": {
"files": [
"logic/validate-signature-pdf-page.ts"
],
"fileCount": 1
},
"More": {
"files": [
"logic/edit-pdf-text-dock.ts"
@@ -8903,6 +8927,30 @@
],
"fileCount": 1
},
"Timestamp Imprint Algorithm:": {
"files": [
"logic/validate-signature-pdf-page.ts"
],
"fileCount": 1
},
"Timestamp Intact: Timestamp Authority Not Verified": {
"files": [
"logic/validate-signature-pdf-page.ts"
],
"fileCount": 1
},
"Timestamped By": {
"files": [
"logic/validate-signature-pdf-page.ts"
],
"fileCount": 1
},
"Timestamped On": {
"files": [
"logic/validate-signature-pdf-page.ts"
],
"fileCount": 1
},
"Tint": {
"files": [
"workflow/nodes/adjust-colors-node.ts"
@@ -9440,6 +9488,12 @@
],
"fileCount": 1
},
"Valid Timestamp": {
"files": [
"logic/validate-signature-pdf-page.ts"
],
"fileCount": 1
},
"Valid Until": {
"files": [
"logic/validate-signature-pdf-page.ts"