This commit is contained in:
a0666660 2026-10-03 11:48:02 +00:00 • committed by GitHub
commit 538b2a2a7c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 915 additions and 191 deletions

View file

@ -6,7 +6,7 @@ on:
- main
jobs:
test:
timeout-minutes: 10
timeout-minutes: 90
runs-on: ubuntu-latest
strategy:
matrix:
@ -23,10 +23,10 @@ jobs:
- name: Get Playwright version
id: playwright-version
run: echo "PLAYWRIGHT_VERSION=$(jq -r .dependencies.playwright package.json)" >> "$GITHUB_OUTPUT"
run: echo "PLAYWRIGHT_VERSION=$(jq -r '.devDependencies["@playwright/test"]' package.json)" >> "$GITHUB_OUTPUT"
- name: Install dependencies
run: pnpm install
run: pnpm install --ignore-scripts
- name: Build app
run: pnpm build
@ -40,8 +40,41 @@ jobs:
${{ runner.os }}-playwright-${{ steps.playwright-version.outputs.PLAYWRIGHT_VERSION }}-
${{ runner.os }}-playwright-
- name: Install Playwright Browsers
run: pnpm exec playwright install --with-deps
# --with-deps bundles an apt-get run for the full browser dependency
# set; the ubuntu archive is intermittently very slow, so it gets its
# own step with a generous timeout, retries, and an apt-cache entry so
# a one-time successful run seeds later ones
- name: Restore apt cache for Playwright dependencies
uses: actions/cache@v3
with:
path: ~/apt-playwright-cache
key: ${{ runner.os }}-apt-${{ steps.playwright-version.outputs.PLAYWRIGHT_VERSION }}
- name: Install Playwright system dependencies
timeout-minutes: 45
run: |
for attempt in 1 2 3 4 5; do
if timeout 1500 ./node_modules/.bin/playwright install-deps; then
exit 0
fi
echo "playwright install-deps attempt $attempt failed (attempt timeout 25 min), retrying in 20s"
sleep 20
done
exit 1
- name: Install Playwright browsers
timeout-minutes: 10
env:
PLAYWRIGHT_DOWNLOAD_CONNECTION_TIMEOUT: 60000
run: |
for attempt in 1 2 3; do
if ./node_modules/.bin/playwright install; then
exit 0
fi
echo "playwright install attempt $attempt failed, retrying in 15s"
sleep 15
done
exit 1
- name: Run Playwright tests
run: pnpm run test:e2e --shard=${{ matrix.shard }}

View file

@ -109,7 +109,7 @@
"@antfu/eslint-config": "^0.41.0",
"@iconify-json/mdi": "^1.1.50",
"@intlify/unplugin-vue-i18n": "^2.0.0",
"@playwright/test": "^1.32.3",
"@playwright/test": "^1.62.1",
"@rushstack/eslint-patch": "^1.2.0",
"@tsconfig/node18": "^18.2.0",
"@types/bcryptjs": "^2.4.2",
@ -149,5 +149,10 @@
"vitest": "^0.34.0",
"workbox-window": "^7.0.0",
"zx": "^7.2.1"
},
"pnpm": {
"overrides": {
"@vueuse/shared": "10.3.0"
}
}
}

260
pnpm-lock.yaml generated
View file

@ -4,6 +4,9 @@ settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
overrides:
'@vueuse/shared': 10.3.0
importers:
.:
@ -49,7 +52,7 @@ importers:
version: 10.3.0(vue@3.3.4)
'@vueuse/head':
specifier: ^1.0.0
version: 1.0.0(typescript@5.2.2)(vue@3.3.4)
version: 1.0.0(vue@3.3.4)
'@vueuse/router':
specifier: ^10.0.0
version: 10.0.0(vue-router@4.1.6(vue@3.3.4))(vue@3.3.4)
@ -220,8 +223,8 @@ importers:
specifier: ^2.0.0
version: 2.0.0(rollup@2.79.2)(vue-i18n@9.9.1(vue@3.3.4))
'@playwright/test':
specifier: ^1.32.3
version: 1.32.3
specifier: ^1.62.1
version: 1.63.0
'@rushstack/eslint-patch':
specifier: ^1.2.0
version: 1.2.0
@ -332,7 +335,7 @@ importers:
version: 4.0.0
vitest:
specifier: ^0.34.0
version: 0.34.0(jsdom@22.0.0)(less@4.1.3)(terser@5.37.0)
version: 0.34.0(jsdom@22.0.0)(less@4.1.3)(playwright@1.63.0)(terser@5.37.0)
workbox-window:
specifier: ^7.0.0
version: 7.0.0
@ -356,17 +359,20 @@ packages:
'@antfu/eslint-config-basic@0.41.0':
resolution: {integrity: sha512-zcwFv+nEV/NroeeVWriNdnIGd9soOLRG8wIiVz4VVJ0BjONrqQR56HLG/gDxH/1GUYBnQCEcVxGUmegce08cnw==}
deprecated: Deprecated, please migrate to @antfu/eslint-config with the flat config
peerDependencies:
eslint: '>=7.4.0'
'@antfu/eslint-config-ts@0.41.0':
resolution: {integrity: sha512-ng3GYpJGZgrxGwBVda/MgUpveH3LbEqdPCFi1+S5e62W4kf8rmEVbhc0I8w7/aKN0uNqir5SInYg8gob2maDAQ==}
deprecated: Deprecated, please migrate to @antfu/eslint-config with the flat config
peerDependencies:
eslint: '>=7.4.0'
typescript: '>=3.9'
'@antfu/eslint-config-vue@0.41.0':
resolution: {integrity: sha512-iJiEGRUgRmT3mQCmGl0hTMwq/ShXRjRPjpgsDcphKJyEF06ZIR/4gxHn+utQRLT2hD39DQN8gk0ZbpV3gWtf/g==}
deprecated: Deprecated, please migrate to @antfu/eslint-config with the flat config
peerDependencies:
eslint: '>=7.4.0'
@ -1464,6 +1470,7 @@ packages:
'@humanwhocodes/config-array@0.11.10':
resolution: {integrity: sha512-KVVjQmNUepDVGXNuoRRdmmEjruj0KfiGSbS8LVc12LMsWDQzRXJ0qdhN8L8uUigKpfEHRhlaQFY0ib1tnUbNeQ==}
engines: {node: '>=10.10.0'}
deprecated: Use @eslint/config-array instead
'@humanwhocodes/module-importer@1.0.1':
resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==}
@ -1471,6 +1478,7 @@ packages:
'@humanwhocodes/object-schema@1.2.1':
resolution: {integrity: sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==}
deprecated: Use @eslint/object-schema instead
'@iconify-json/mdi@1.1.50':
resolution: {integrity: sha512-SgbT5w5eHCdOG74ZWPz7HlTGk6VsifIJhNi6lAsxj/5Nlqt6Cz4LlQmSa9eecU9p075Jub2aAx/o7YI+GCahRQ==}
@ -1626,9 +1634,9 @@ packages:
resolution: {integrity: sha512-wfzX8kc1PMyUILA+1Z/EqoE4UCXGy0iRGMhPwdfae1+f0OXlLqCk+By+aMzgJBzR9AzS4CDizioG6Ss1gvAFJw==}
engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0}
'@playwright/test@1.32.3':
resolution: {integrity: sha512-BvWNvK0RfBriindxhLVabi8BRe3X0J9EVjKlcmhxjg4giWBD/xleLcg2dz7Tx0agu28rczjNIPQWznwzDwVsZQ==}
engines: {node: '>=14'}
'@playwright/test@1.63.0':
resolution: {integrity: sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==}
engines: {node: '>=20'}
hasBin: true
'@polka/url@1.0.0-next.28':
@ -2303,9 +2311,6 @@ packages:
'@vue/compiler-core@3.3.7':
resolution: {integrity: sha512-pACdY6YnTNVLXsB86YD8OF9ihwpolzhhtdLVHhBL6do/ykr6kKXNYABRtNMGrsQXpEXXyAdwvWWkuTbs4MFtPQ==}
'@vue/compiler-core@3.5.13':
resolution: {integrity: sha512-oOdAkwqUfW1WqpwSYJce06wvt6HljgY3fGeM9NcVA1HaYOij3mZG9Rkysn0OHuyUAGMbEbARIpsG+LPVlBJ5/Q==}
'@vue/compiler-dom@3.2.47':
resolution: {integrity: sha512-dBBnEHEPoftUiS03a4ggEig74J2YBZ2UIeyfpcRM2tavgMWo4bsEfgCGsu+uJIL/vax9S+JztH8NmQerUo7shQ==}
@ -2315,18 +2320,12 @@ packages:
'@vue/compiler-dom@3.3.7':
resolution: {integrity: sha512-0LwkyJjnUPssXv/d1vNJ0PKfBlDoQs7n81CbO6Q0zdL7H1EzqYRrTVXDqdBVqro0aJjo/FOa1qBAPVI4PGSHBw==}
'@vue/compiler-dom@3.5.13':
resolution: {integrity: sha512-ZOJ46sMOKUjO3e94wPdCzQ6P1Lx/vhp2RSvfaab88Ajexs0AHeV0uasYhi99WPaogmBlRHNRuly8xV75cNTMDA==}
'@vue/compiler-sfc@3.2.47':
resolution: {integrity: sha512-rog05W+2IFfxjMcFw10tM9+f7i/+FFpZJJ5XHX72NP9eC2uRD+42M3pYcQqDXVYoj74kHMSEdQ/WmCjt8JFksQ==}
'@vue/compiler-sfc@3.3.4':
resolution: {integrity: sha512-6y/d8uw+5TkCuzBkgLS0v3lSM3hJDntFEiUORM11pQ/hKvkhSKZrXW6i69UyXlJQisJxuUEJKAWEqWbWsLeNKQ==}
'@vue/compiler-sfc@3.5.13':
resolution: {integrity: sha512-6VdaljMpD82w6c2749Zhf5T9u5uLBWKnVue6XWxprDobftnletJ8+oel7sexFfM3qIxNmVE7LSFGTpv6obNyaQ==}
'@vue/compiler-ssr@3.2.47':
resolution: {integrity: sha512-wVXC+gszhulcMD8wpxMsqSOpvDZ6xKXSVWkf50Guf/S+28hTAXPDYRTbLQ3EDkOP5Xz/+SY37YiwDquKbJOgZw==}
@ -2336,9 +2335,6 @@ packages:
'@vue/compiler-ssr@3.3.7':
resolution: {integrity: sha512-TxOfNVVeH3zgBc82kcUv+emNHo+vKnlRrkv8YvQU5+Y5LJGJwSNzcmLUoxD/dNzv0bhQ/F0s+InlgV0NrApJZg==}
'@vue/compiler-ssr@3.5.13':
resolution: {integrity: sha512-wMH6vrYHxQl/IybKJagqbquvxpWCuVYpoUJfCqFZwa/JY1GdATAQ+TgVtgrwwMZ0D07QhA99rs/EAAWfvG6KpA==}
'@vue/devtools-api@6.5.0':
resolution: {integrity: sha512-o9KfBeaBmCKl10usN4crU53fYtC1r7jJwdGKjPT24t348rHxgfpZ0xL3Xm/gLUYnc0oTp8LAmrxOeLyu6tbk2Q==}
@ -2359,21 +2355,12 @@ packages:
'@vue/reactivity@3.3.4':
resolution: {integrity: sha512-kLTDLwd0B1jG08NBF3R5rqULtv/f8x3rOFByTDz4J53ttIQEDmALqKqXY0J+XQeN0aV2FBxY8nJDf88yvOPAqQ==}
'@vue/reactivity@3.5.13':
resolution: {integrity: sha512-NaCwtw8o48B9I6L1zl2p41OHo/2Z4wqYGGIK1Khu5T7yxrn+ATOixn/Udn2m+6kZKB/J7cuT9DbWWhRxqixACg==}
'@vue/runtime-core@3.3.4':
resolution: {integrity: sha512-R+bqxMN6pWO7zGI4OMlmvePOdP2c93GsHFM/siJI7O2nxFRzj55pLwkpCedEY+bTMgp5miZ8CxfIZo3S+gFqvA==}
'@vue/runtime-core@3.5.13':
resolution: {integrity: sha512-Fj4YRQ3Az0WTZw1sFe+QDb0aXCerigEpw418pw1HBUKFtnQHWzwojaukAs2X/c9DQz4MQ4bsXTGlcpGxU/RCIw==}
'@vue/runtime-dom@3.3.4':
resolution: {integrity: sha512-Aj5bTJ3u5sFsUckRghsNjVTtxZQ1OyMWCr5dZRAPijF/0Vy4xEoRCwLyHXcj4D0UFbJ4lbx3gPTgg06K/GnPnQ==}
'@vue/runtime-dom@3.5.13':
resolution: {integrity: sha512-dLaj94s93NYLqjLiyFzVs9X6dWhTdAlEAciC3Moq7gzAc13VJUdCnjjRurNM6uTLFATRHexHCTu/Xp3eW6yoog==}
'@vue/server-renderer@3.3.4':
resolution: {integrity: sha512-Q6jDDzR23ViIb67v+vM1Dqntu+HUexQcsWKhhQa4ARVzxOY2HbC7QRW/ggkDBd5BU+uM1sV6XOAP0b216o34JQ==}
peerDependencies:
@ -2384,11 +2371,6 @@ packages:
peerDependencies:
vue: 3.3.7
'@vue/server-renderer@3.5.13':
resolution: {integrity: sha512-wAi4IRJV/2SAW3htkTlB+dHeRmpTiVIK1OGLWV1yeStVSebSQQOwGwIq0D3ZIoBj2C2qpgz5+vX9iEBkTdk5YA==}
peerDependencies:
vue: 3.5.13
'@vue/shared@3.2.47':
resolution: {integrity: sha512-BHGyyGN3Q97EZx0taMQ+OLNuZcW3d37ZEVmEAyeoA9ERdGvm9Irc/0Fua8SNyOtV1w6BS4q25wbMzJujO9HIfQ==}
@ -2398,9 +2380,6 @@ packages:
'@vue/shared@3.3.7':
resolution: {integrity: sha512-N/tbkINRUDExgcPTBvxNkvHGu504k8lzlNQRITVnm6YjOjwa4r0nnbd4Jb01sNpur5hAllyRJzSK5PvB9PPwRg==}
'@vue/shared@3.5.13':
resolution: {integrity: sha512-/hnE/qP5ZoGpol0a5mDi45bOd7t3tjYJBjsgCsivow7D48cJeV5l05RD82lPqi7gRiphZM37rnhW1l6ZoCNNnQ==}
'@vue/test-utils@2.3.2':
resolution: {integrity: sha512-hJnVaYhbrIm0yBS0+e1Y0Sj85cMyAi+PAbK4JHqMRUZ6S622Goa+G7QzkRSyvCteG8wop7tipuEbHoZo26wsSA==}
peerDependencies:
@ -2428,20 +2407,15 @@ packages:
peerDependencies:
vue-router: '>=4.0.0-rc.1'
'@vueuse/shared@10.0.0':
resolution: {integrity: sha512-Zh3LgJqvUBWVY3SiMvXanTcfAneGbt63QPczBRDNgQ6jd/ehodO9a1lCFzaA6SWJJoI+ugVTjHFYJdoR656DVQ==}
'@vueuse/shared@10.3.0':
resolution: {integrity: sha512-kGqCTEuFPMK4+fNWy6dUOiYmxGcUbtznMwBZLC1PubidF4VZY05B+Oht7Jh7/6x4VOWGpvu3R37WHi81cKpiqg==}
'@vueuse/shared@12.0.0':
resolution: {integrity: sha512-3i6qtcq2PIio5i/vVYidkkcgvmTjCqrf26u+Fd4LhnbBmIT6FN8y6q/GJERp8lfcB9zVEfjdV0Br0443qZuJpw==}
'@zhead/schema@1.0.0-beta.13':
resolution: {integrity: sha512-P1A1vRGFBhITco8Iw4/hvnDYoE/SoVrd71dW1pBFdXJb3vP+pBtoOuhbEKy0ROJGOyzQuqvFibcwzyLlWMqNiQ==}
abab@2.0.6:
resolution: {integrity: sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==}
deprecated: Use your platform's native atob() and btoa() methods instead
abbrev@1.1.1:
resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==}
@ -2552,6 +2526,7 @@ packages:
babel-merge@3.0.0:
resolution: {integrity: sha512-eBOBtHnzt9xvnjpYNI5HmaPp/b2vMveE5XggzqHnQeHJ8mFIBrBv6WZEVIj5jJ2uwTItkqKo9gWzEEcBxEq0yw==}
deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.
peerDependencies:
'@babel/core': ^7.0.0
@ -2798,7 +2773,7 @@ packages:
hasBin: true
concat-map@0.0.1:
resolution: {integrity: sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=}
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
confbox@0.1.8:
resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==}
@ -2850,6 +2825,7 @@ packages:
crypto-js@4.1.1:
resolution: {integrity: sha512-o2JlM7ydqd3Qk9CA0L4NL6mTzU2sdx96a+oOfPu8Mkl/PK51vSyoi8/rQ8NknZtk44vq15lmhAj9CIAGwgeWKw==}
deprecated: Active development of CryptoJS has been discontinued. This library is no longer maintained.
crypto-random-string@2.0.0:
resolution: {integrity: sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==}
@ -2896,9 +2872,6 @@ packages:
csstype@3.1.2:
resolution: {integrity: sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==}
csstype@3.1.3:
resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
dash-get@1.0.2:
resolution: {integrity: sha512-4FbVrHDwfOASx7uQVxeiCTo7ggSdYZbqs8lH+WU6ViypPlDbe9y6IP5VVUDQBv9DcnyaiPT5XT0UWHgJ64zLeQ==}
@ -3054,6 +3027,7 @@ packages:
domexception@4.0.0:
resolution: {integrity: sha512-A2is4PLG+eeSfoTMA95/s4pvAoSo2mKtiM5jlHkAVewmiO8ISFTFKZjH7UAM1Atli/OT/7JHOrJRJiMKUZKYBw==}
engines: {node: '>=12'}
deprecated: Use your platform's native DOMException instead
domhandler@5.0.3:
resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==}
@ -3246,6 +3220,7 @@ packages:
eslint-plugin-i@2.28.0-2:
resolution: {integrity: sha512-z48kG4qmE4TmiLcxbmvxMT5ycwvPkXaWW0XpU1L768uZaTbiDbxsHMEdV24JHlOR1xDsPpKW39BfP/pRdYIwFA==}
engines: {node: '>=12'}
deprecated: Please migrate to the brand new `eslint-plugin-import-x` instead
peerDependencies:
eslint: ^7.2.0 || ^8
@ -3271,6 +3246,7 @@ packages:
eslint-plugin-markdown@3.0.1:
resolution: {integrity: sha512-8rqoc148DWdGdmYF6WSQFT3uQ6PO7zXYgeBpHAOAakX/zpq+NvFYbDA/H7PYzHajwtmaOzAwfxyl++x0g1/N9A==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
deprecated: Please use @eslint/markdown instead
peerDependencies:
eslint: ^6.0.0 || ^7.0.0 || ^8.0.0
@ -3337,6 +3313,7 @@ packages:
eslint@8.47.0:
resolution: {integrity: sha512-spUQWrdPt+pRVP1TTJLmfRNJJHHZryFmptzcafwSvHsceV81djHOdnEeDmkdotZyLNjDhrOasNK8nikkoG1O8Q==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options.
hasBin: true
espree@9.6.1:
@ -3499,11 +3476,6 @@ packages:
fs.realpath@1.0.0:
resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==}
fsevents@2.3.2:
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@ -3566,10 +3538,12 @@ packages:
glob@7.2.3:
resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==}
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
glob@8.1.0:
resolution: {integrity: sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==}
engines: {node: '>=12'}
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
globals@11.12.0:
resolution: {integrity: sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==}
@ -3758,6 +3732,7 @@ packages:
inflight@1.0.6:
resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==}
deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.
inherits@2.0.4:
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
@ -4172,6 +4147,7 @@ packages:
loupe@2.3.6:
resolution: {integrity: sha512-RaPMZKiMy8/JruncMU5Bt6na1eftNoo++R4Y+N2FrxkDVTrGvcyzFTsaGif4QTeKESheMGegbhw6iUAq+5A8zA==}
deprecated: Please upgrade to 2.3.7 which fixes GHSA-4q6p-r6v2-jvc5
lower-case-first@1.0.2:
resolution: {integrity: sha512-UuxaYakO7XeONbKrZf5FEgkantPf5DUqDayzP5VXZrtRPdH86s4kN47I8B3TW10S4QKiE3ziHNf3kRN//okHjA==}
@ -4380,6 +4356,7 @@ packages:
node-domexception@1.0.0:
resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==}
engines: {node: '>=10.5.0'}
deprecated: Use your platform's native DOMException instead
node-fetch-native@1.6.4:
resolution: {integrity: sha512-IhOigYzAKHd244OC0JIMIUrjzctirCmPkaIfhDeGcEETWof5zKYUW7e7MYvChGWh/4CJeXEgsRyGzuF334rOOQ==}
@ -4610,10 +4587,16 @@ packages:
plausible-tracker@0.3.8:
resolution: {integrity: sha512-lmOWYQ7s9KOUJ1R+YTOR3HrjdbxIS2Z4de0P/Jx2dQPteznJl2eX3tXxKClpvbfyGP59B5bbhW8ftN59HbbFSg==}
engines: {node: '>=10'}
deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.
playwright-core@1.32.3:
resolution: {integrity: sha512-SB+cdrnu74ZIn5Ogh/8278ngEh9NEEV0vR4sJFmK04h2iZpybfbqBY0bX6+BLYWVdV12JLLI+JEFtSnYgR+mWg==}
engines: {node: '>=14'}
playwright-core@1.63.0:
resolution: {integrity: sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==}
engines: {node: '>=20'}
hasBin: true
playwright@1.63.0:
resolution: {integrity: sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==}
engines: {node: '>=20'}
hasBin: true
pluralize@8.0.0:
@ -4868,10 +4851,6 @@ packages:
resolution: {integrity: sha512-PXNdCiPqDqeUou+w1C2eTQbNfxKSuMxqTCuvlmmMsk1NWHL5fRrhY6Pl0qEYYc6+QqGClco1Qj8XnjPego4wfg==}
hasBin: true
resolve@1.22.8:
resolution: {integrity: sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==}
hasBin: true
resolve@1.22.9:
resolution: {integrity: sha512-QxrmX1DzraFIi9PxdG5VkRfRwIgjwyud+z/iBwfRRrVmHc+P9Q7u2lSSpQ6bjr2gy5lrqIiU9vb6iAeGf2400A==}
hasBin: true
@ -4894,6 +4873,7 @@ packages:
rimraf@3.0.2:
resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==}
deprecated: Rimraf versions prior to v4 are no longer supported
hasBin: true
rollup@2.79.2:
@ -5071,9 +5051,11 @@ packages:
source-map@0.8.0-beta.0:
resolution: {integrity: sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA==}
engines: {node: '>= 8'}
deprecated: The work that was done in this beta branch won't be included in future versions
sourcemap-codec@1.4.8:
resolution: {integrity: sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==}
deprecated: Please use @jridgewell/sourcemap-codec instead
spdx-correct@3.2.0:
resolution: {integrity: sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==}
@ -5544,6 +5526,7 @@ packages:
uuid@9.0.0:
resolution: {integrity: sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg==}
deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).
hasBin: true
validate-npm-package-license@3.0.4:
@ -5569,6 +5552,7 @@ packages:
vite-plugin-vue-markdown@0.23.5:
resolution: {integrity: sha512-NXTZ4y+n691gLPWayMBbh4jldQeaqDp9e9WjWUYbn9obsLqS9qU+hr4RAruDq5kP4siTOp7JDV34Sw5eA7WxLg==}
deprecated: '`vite-plugin-vue-markdown` is renamed to `unplugin-vue-markdown`. For usages in Vite, you also need to change the import path to `unplugin-vue-markdown/vite`.'
peerDependencies:
vite: ^2.0.0 || ^3.0.0-0 || ^4.0.0
@ -5675,6 +5659,7 @@ packages:
vue-i18n@9.9.1:
resolution: {integrity: sha512-xyQ4VspLdNSPTKBFBPWa1tvtj+9HuockZwgFeD2OhxxXuC2CWeNvV4seu2o9+vbQOyQbhAM5Ez56oxUrrnTWdw==}
engines: {node: '>= 16'}
deprecated: v9 and v10 no longer supported. please migrate to v11. about maintenance status, see https://vue-i18n.intlify.dev/guide/maintenance.html
peerDependencies:
vue: ^3.0.0
@ -5701,14 +5686,6 @@ packages:
vue@3.3.4:
resolution: {integrity: sha512-VTyEYn3yvIeY1Py0WaYGZsXnz3y5UnGi62GjVEqvEGPl6nxbOrCXbVOTQWBEJUqAyTUk2uJ5JLVnYJ6ZzGbrSw==}
vue@3.5.13:
resolution: {integrity: sha512-wmeiSMxkZCSc+PM2w2VRsOYAZC8GdipNFRTsLSfodVqI9mbejKeXEGr8SckuLnrQPGe3oJN5c3K0vpoU9q/wCQ==}
peerDependencies:
typescript: '*'
peerDependenciesMeta:
typescript:
optional: true
vuedraggable@4.1.0:
resolution: {integrity: sha512-FU5HCWBmsf20GpP3eudURW3WdWTKIbEIQxh9/8GE806hydR9qZqRRxRE3RjqX7PkuLuMQG/A7n3cfj9rCEchww==}
peerDependencies:
@ -5754,6 +5731,7 @@ packages:
whatwg-encoding@2.0.0:
resolution: {integrity: sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==}
engines: {node: '>=12'}
deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation
whatwg-mimetype@3.0.0:
resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==}
@ -6117,7 +6095,7 @@ snapshots:
'@babel/traverse': 7.23.2
'@babel/types': 7.23.0
convert-source-map: 2.0.0
debug: 4.3.4
debug: 4.4.0
gensync: 1.0.0-beta.2
json5: 2.2.3
semver: 6.3.1
@ -7030,7 +7008,7 @@ snapshots:
'@babel/helper-split-export-declaration': 7.22.6
'@babel/parser': 7.23.0
'@babel/types': 7.23.0
debug: 4.3.4
debug: 4.4.0
globals: 11.12.0
transitivePeerDependencies:
- supports-color
@ -7401,7 +7379,7 @@ snapshots:
'@linaria/logger@4.0.0':
dependencies:
debug: 4.3.4
debug: 4.4.0
picocolors: 1.0.0
transitivePeerDependencies:
- supports-color
@ -7462,12 +7440,9 @@ snapshots:
tiny-glob: 0.2.9
tslib: 2.5.0
'@playwright/test@1.32.3':
'@playwright/test@1.63.0':
dependencies:
'@types/node': 18.15.11
playwright-core: 1.32.3
optionalDependencies:
fsevents: 2.3.2
playwright: 1.63.0
'@polka/url@1.0.0-next.28': {}
@ -7922,7 +7897,7 @@ snapshots:
dependencies:
'@typescript-eslint/typescript-estree': 6.4.1(typescript@5.2.2)
'@typescript-eslint/utils': 6.4.1(eslint@8.47.0)(typescript@5.2.2)
debug: 4.3.4
debug: 4.4.0
eslint: 8.47.0
ts-api-utils: 1.0.1(typescript@5.2.2)
optionalDependencies:
@ -7940,10 +7915,10 @@ snapshots:
dependencies:
'@typescript-eslint/types': 5.60.0
'@typescript-eslint/visitor-keys': 5.60.0
debug: 4.3.4
debug: 4.4.0
globby: 11.1.0
is-glob: 4.0.3
semver: 7.5.4
semver: 7.6.3
tsutils: 3.21.0(typescript@5.2.2)
optionalDependencies:
typescript: 5.2.2
@ -7954,10 +7929,10 @@ snapshots:
dependencies:
'@typescript-eslint/types': 6.4.1
'@typescript-eslint/visitor-keys': 6.4.1
debug: 4.3.4
debug: 4.4.0
globby: 11.1.0
is-glob: 4.0.3
semver: 7.5.4
semver: 7.6.3
ts-api-utils: 1.0.1(typescript@5.2.2)
optionalDependencies:
typescript: 5.2.2
@ -7968,10 +7943,10 @@ snapshots:
dependencies:
'@typescript-eslint/types': 6.9.1
'@typescript-eslint/visitor-keys': 6.9.1
debug: 4.3.4
debug: 4.4.0
globby: 11.1.0
is-glob: 4.0.3
semver: 7.5.4
semver: 7.6.3
ts-api-utils: 1.0.1(typescript@5.2.2)
optionalDependencies:
typescript: 5.2.2
@ -7988,7 +7963,7 @@ snapshots:
'@typescript-eslint/typescript-estree': 5.60.0(typescript@5.2.2)
eslint: 8.47.0
eslint-scope: 5.1.1
semver: 7.5.4
semver: 7.6.3
transitivePeerDependencies:
- supports-color
- typescript
@ -8002,7 +7977,7 @@ snapshots:
'@typescript-eslint/types': 6.4.1
'@typescript-eslint/typescript-estree': 6.4.1(typescript@5.2.2)
eslint: 8.47.0
semver: 7.5.4
semver: 7.6.3
transitivePeerDependencies:
- supports-color
- typescript
@ -8049,15 +8024,15 @@ snapshots:
dependencies:
'@unhead/schema': 0.5.1
'@unhead/vue@0.5.1(typescript@5.2.2)(vue@3.3.4)':
'@unhead/vue@0.5.1(vue@3.3.4)':
dependencies:
'@unhead/dom': 0.5.1
'@unhead/schema': 0.5.1
'@vueuse/shared': 12.0.0(typescript@5.2.2)
'@vueuse/shared': 10.3.0(vue@3.3.4)
unhead: 0.5.1
vue: 3.3.4
transitivePeerDependencies:
- typescript
- '@vue/composition-api'
'@unocss/astro@0.65.1(rollup@2.79.2)(vite@4.4.9(@types/node@18.15.11)(less@4.1.3)(terser@5.37.0))(vue@3.3.4)':
dependencies:
@ -8341,14 +8316,6 @@ snapshots:
source-map-js: 1.0.2
optional: true
'@vue/compiler-core@3.5.13':
dependencies:
'@babel/parser': 7.26.3
'@vue/shared': 3.5.13
entities: 4.5.0
estree-walker: 2.0.2
source-map-js: 1.2.1
'@vue/compiler-dom@3.2.47':
dependencies:
'@vue/compiler-core': 3.2.47
@ -8365,11 +8332,6 @@ snapshots:
'@vue/shared': 3.3.7
optional: true
'@vue/compiler-dom@3.5.13':
dependencies:
'@vue/compiler-core': 3.5.13
'@vue/shared': 3.5.13
'@vue/compiler-sfc@3.2.47':
dependencies:
'@babel/parser': 7.21.4
@ -8396,18 +8358,6 @@ snapshots:
postcss: 8.4.28
source-map-js: 1.0.2
'@vue/compiler-sfc@3.5.13':
dependencies:
'@babel/parser': 7.26.3
'@vue/compiler-core': 3.5.13
'@vue/compiler-dom': 3.5.13
'@vue/compiler-ssr': 3.5.13
'@vue/shared': 3.5.13
estree-walker: 2.0.2
magic-string: 0.30.15
postcss: 8.4.49
source-map-js: 1.2.1
'@vue/compiler-ssr@3.2.47':
dependencies:
'@vue/compiler-dom': 3.2.47
@ -8424,11 +8374,6 @@ snapshots:
'@vue/shared': 3.3.7
optional: true
'@vue/compiler-ssr@3.5.13':
dependencies:
'@vue/compiler-dom': 3.5.13
'@vue/shared': 3.5.13
'@vue/devtools-api@6.5.0': {}
'@vue/language-core@1.8.1(typescript@5.2.2)':
@ -8464,33 +8409,17 @@ snapshots:
dependencies:
'@vue/shared': 3.3.4
'@vue/reactivity@3.5.13':
dependencies:
'@vue/shared': 3.5.13
'@vue/runtime-core@3.3.4':
dependencies:
'@vue/reactivity': 3.3.4
'@vue/shared': 3.3.4
'@vue/runtime-core@3.5.13':
dependencies:
'@vue/reactivity': 3.5.13
'@vue/shared': 3.5.13
'@vue/runtime-dom@3.3.4':
dependencies:
'@vue/runtime-core': 3.3.4
'@vue/shared': 3.3.4
csstype: 3.1.2
'@vue/runtime-dom@3.5.13':
dependencies:
'@vue/reactivity': 3.5.13
'@vue/runtime-core': 3.5.13
'@vue/shared': 3.5.13
csstype: 3.1.3
'@vue/server-renderer@3.3.4(vue@3.3.4)':
dependencies:
'@vue/compiler-ssr': 3.3.4
@ -8504,12 +8433,6 @@ snapshots:
vue: 3.3.4
optional: true
'@vue/server-renderer@3.5.13(vue@3.5.13(typescript@5.2.2))':
dependencies:
'@vue/compiler-ssr': 3.5.13
'@vue/shared': 3.5.13
vue: 3.5.13(typescript@5.2.2)
'@vue/shared@3.2.47': {}
'@vue/shared@3.3.4': {}
@ -8517,8 +8440,6 @@ snapshots:
'@vue/shared@3.3.7':
optional: true
'@vue/shared@3.5.13': {}
'@vue/test-utils@2.3.2(vue@3.3.4)':
dependencies:
js-beautify: 1.14.6
@ -8546,33 +8467,26 @@ snapshots:
- '@vue/composition-api'
- vue
'@vueuse/head@1.0.0(typescript@5.2.2)(vue@3.3.4)':
'@vueuse/head@1.0.0(vue@3.3.4)':
dependencies:
'@unhead/schema': 0.5.1
'@unhead/ssr': 0.5.1
'@unhead/vue': 0.5.1(typescript@5.2.2)(vue@3.3.4)
'@unhead/vue': 0.5.1(vue@3.3.4)
vue: 3.3.4
transitivePeerDependencies:
- typescript
- '@vue/composition-api'
'@vueuse/metadata@10.3.0': {}
'@vueuse/router@10.0.0(vue-router@4.1.6(vue@3.3.4))(vue@3.3.4)':
dependencies:
'@vueuse/shared': 10.0.0(vue@3.3.4)
'@vueuse/shared': 10.3.0(vue@3.3.4)
vue-demi: 0.14.5(vue@3.3.4)
vue-router: 4.1.6(vue@3.3.4)
transitivePeerDependencies:
- '@vue/composition-api'
- vue
'@vueuse/shared@10.0.0(vue@3.3.4)':
dependencies:
vue-demi: 0.14.5(vue@3.3.4)
transitivePeerDependencies:
- '@vue/composition-api'
- vue
'@vueuse/shared@10.3.0(vue@3.3.4)':
dependencies:
vue-demi: 0.14.5(vue@3.3.4)
@ -8580,12 +8494,6 @@ snapshots:
- '@vue/composition-api'
- vue
'@vueuse/shared@12.0.0(typescript@5.2.2)':
dependencies:
vue: 3.5.13(typescript@5.2.2)
transitivePeerDependencies:
- typescript
'@zhead/schema@1.0.0-beta.13': {}
abab@2.0.6: {}
@ -8780,7 +8688,7 @@ snapshots:
builtins@5.0.1:
dependencies:
semver: 7.5.4
semver: 7.6.3
bundle-require@5.0.0(esbuild@0.23.1):
dependencies:
@ -9084,8 +8992,6 @@ snapshots:
csstype@3.1.2: {}
csstype@3.1.3: {}
dash-get@1.0.2: {}
data-uri-to-buffer@4.0.1: {}
@ -9817,9 +9723,6 @@ snapshots:
fs.realpath@1.0.0: {}
fsevents@2.3.2:
optional: true
fsevents@2.3.3:
optional: true
@ -10753,7 +10656,7 @@ snapshots:
normalize-package-data@2.5.0:
dependencies:
hosted-git-info: 2.8.9
resolve: 1.22.8
resolve: 1.22.9
semver: 5.7.2
validate-npm-package-license: 3.0.4
@ -10972,7 +10875,11 @@ snapshots:
plausible-tracker@0.3.8: {}
playwright-core@1.32.3: {}
playwright-core@1.63.0: {}
playwright@1.63.0:
dependencies:
playwright-core: 1.63.0
pluralize@8.0.0: {}
@ -11271,12 +11178,6 @@ snapshots:
path-parse: 1.0.7
supports-preserve-symlinks-flag: 1.0.0
resolve@1.22.8:
dependencies:
is-core-module: 2.13.1
path-parse: 1.0.7
supports-preserve-symlinks-flag: 1.0.0
resolve@1.22.9:
dependencies:
is-core-module: 2.16.0
@ -12073,7 +11974,7 @@ snapshots:
less: 4.1.3
terser: 5.37.0
vitest@0.34.0(jsdom@22.0.0)(less@4.1.3)(terser@5.37.0):
vitest@0.34.0(jsdom@22.0.0)(less@4.1.3)(playwright@1.63.0)(terser@5.37.0):
dependencies:
'@types/chai': 4.3.5
'@types/chai-subset': 1.3.3
@ -12101,6 +12002,7 @@ snapshots:
why-is-node-running: 2.2.2
optionalDependencies:
jsdom: 22.0.0
playwright: 1.63.0
transitivePeerDependencies:
- less
- lightningcss
@ -12125,14 +12027,14 @@ snapshots:
vue-eslint-parser@9.3.1(eslint@8.47.0):
dependencies:
debug: 4.3.4
debug: 4.4.0
eslint: 8.47.0
eslint-scope: 7.2.2
eslint-visitor-keys: 3.4.3
espree: 9.6.1
esquery: 1.5.0
lodash: 4.17.21
semver: 7.5.4
semver: 7.6.3
transitivePeerDependencies:
- supports-color
@ -12180,16 +12082,6 @@ snapshots:
'@vue/server-renderer': 3.3.4(vue@3.3.4)
'@vue/shared': 3.3.4
vue@3.5.13(typescript@5.2.2):
dependencies:
'@vue/compiler-dom': 3.5.13
'@vue/compiler-sfc': 3.5.13
'@vue/runtime-dom': 3.5.13
'@vue/server-renderer': 3.5.13(vue@3.5.13(typescript@5.2.2))
'@vue/shared': 3.5.13
optionalDependencies:
typescript: 5.2.2
vuedraggable@4.1.0(vue@3.3.4):
dependencies:
sortablejs: 1.14.0

View file

@ -59,6 +59,7 @@ import { tool as deviceInformation } from './device-information';
import { tool as cypher } from './encryption';
import { tool as etaCalculator } from './eta-calculator';
import { tool as percentageCalculator } from './percentage-calculator';
import { tool as x509CertificateDecoder } from './x509-certificate-decoder';
import { tool as gitMemo } from './git-memo';
import { tool as hashText } from './hash-text';
import { tool as hmacGenerator } from './hmac-generator';
@ -164,7 +165,7 @@ export const toolsByCategory: ToolCategory[] = [
},
{
name: 'Network',
components: [ipv4SubnetCalculator, ipv4AddressConverter, ipv4RangeExpander, macAddressLookup, macAddressGenerator, ipv6UlaGenerator],
components: [ipv4SubnetCalculator, ipv4AddressConverter, ipv4RangeExpander, macAddressLookup, macAddressGenerator, ipv6UlaGenerator, x509CertificateDecoder],
},
{
name: 'Math',

View file

@ -0,0 +1,12 @@
import { ShieldLock } from '@vicons/tabler';
import { defineTool } from '../tool';
export const tool = defineTool({
name: 'X.509 certificate decoder',
path: '/x509-certificate-decoder',
description: 'Decode a PEM certificate (RSA, EC or Ed25519): subject, issuer, validity, public key, extensions and fingerprints',
keywords: ['x509', 'certificate', 'pem', 'tls', 'ssl', 'decoder', 'asn1', 'expiry'],
component: () => import('./x509-certificate-decoder.vue'),
icon: ShieldLock,
createdAt: new Date('2026-09-30'),
});

View file

@ -0,0 +1,99 @@
import { describe, expect, it } from 'vitest';
import { decodeCertificatePem } from './x509-certificate-decoder.service';
// Regression corpus generated with OpenSSL 3.5 (`openssl req -x509`), then
// every asserted value cross-checked against `openssl x509 -text`. These
// pin the decoder to OpenSSL's canonical rendering of the tricky shapes:
// IPv6 SANs, multi-byte named bits (decipherOnly), bare SKI key ids,
// context-tagged AKI key ids, and an extension OID with a multi-byte first
// sub-identifier (1.2.999.1 → 0x88 0x67).
const RSA_IPV6_CERT_PEM = `-----BEGIN CERTIFICATE-----
MIIDnDCCAoSgAwIBAgIUYQ5gUMphhz1LUtSnI4XJxAnGzTcwDQYJKoZIhvcNAQEL
BQAwSTELMAkGA1UEBhMCQ04xEDAOBgNVBAgMB0JlaWppbmcxDTALBgNVBAoMBFRl
c3QxGTAXBgNVBAMMEHRlc3QuZXhhbXBsZS5jb20wHhcNMjYxMDAzMDgzMjMzWhcN
MjYxMTAyMDgzMjMzWjBJMQswCQYDVQQGEwJDTjEQMA4GA1UECAwHQmVpamluZzEN
MAsGA1UECgwEVGVzdDEZMBcGA1UEAwwQdGVzdC5leGFtcGxlLmNvbTCCASIwDQYJ
KoZIhvcNAQEBBQADggEPADCCAQoCggEBAMnND40VnXTg3ZvBrfJOPh4WGmV718Cb
URSFKnK1yDITpAc4HsJjcTntl92Xsb0ov1Nc20/L7ePz2qN4QbFqmhzZbmNhdmIF
IXlldpFsFHRjSJ3kbwsApWtozDVi3iAYseJ2GfFZ4rUfOIoRbCkHWR6KNwpJrJT4
IWktnE0fK7tIMRc4O+d6ksVg54tLi+r6BSZAurdWLy9Bq3zWwYUrZUvG2TBJ1HIi
CRDC7PSyCV/o/C9eGtJr0EGDq7Exl/kfW+QYwqFMXXrfjhSWBwEZfiegUPr9/7DD
ZMdPTmdOwrV8OmkhSdXSM5tAoRwlSako/Rirf52o02Jm7U2phlHxQP0CAwEAAaN8
MHowDwYDVR0TAQH/BAUwAwEB/zA3BgNVHREEMDAuhxAgAQ24AAAAAAAAAAAAAAAB
hwTAqAEBgg1hLmV4YW1wbGUuY29tgQVhQGIuYzAPBgNVHQ8BAf8EBQMDB6CAMB0G
A1UdDgQWBBSMRZA0vuzo5oiW4uxRGmP62SZcsTANBgkqhkiG9w0BAQsFAAOCAQEA
QerYkO13fxKoqpLitIKKdm5jDCOXfEMJbH2CwKUIhJbxsslCW32p0IXRmvudwPSO
yFwD3fIy7vPrPxiLOvrvs1uEMEYhNKeE0yVhk2DjQxHAJpQRDCHYj4vZSPkrx6ze
JpjEaJ9TniIlxKS4ggubyOkVM51o47rqevsmUccFgT6Umu2AMmgSP4WvUgbncYrK
SKodoq9fZp1e2eVy7CeN7A6rpbYHAgktoYMBvtRSnZVJo+CcqcNl4xc97gtXeKQI
vt8bIDLVFsV0CHuVIi/LzhcZvZ8khTniu4YELpzqYjfLkib8XADtWdeWGTwOPz8a
Y1eZ1r6V6qU4MQpkyc1EdQ==
-----END CERTIFICATE-----`;
const ED25519_CUSTOM_OID_CERT_PEM = `-----BEGIN CERTIFICATE-----
MIIB1jCCAVygAwIBAgIUGd2IUBuv1j/C7zeNuXkPo96XpjQwCgYIKoZIzj0EAwIw
MDELMAkGA1UEBhMCQ04xDzANBgNVBAoMBlRlc3RDQTEQMA4GA1UEAwwHUm9vdCBD
QTAeFw0yNjEwMDMxMDQ0MDhaFw0yNjExMDIxMDQ0MDhaMB4xHDAaBgNVBAMME2Vk
MjU1MTkuZXhhbXBsZS5jb20wKjAFBgMrZXADIQAU+Pxvo65PrtplGQ2m7ro0w+Nl
8LT+55hSlOUnchBPTaOBlDCBkTAJBgNVHRMEAjAAMAsGA1UdDwQEAwIHgDAZBgNV
HREEEjAQgg5lZS5leGFtcGxlLmNvbTAcBgQqh2cBBBQMEm11bHRpYnl0ZS1vaWQt
dGVzdDAdBgNVHQ4EFgQUFP9MR1+AykhnKRueeH/chyvm8DswHwYDVR0jBBgwFoAU
5mzqfASzZNXN4M6pjkcrzP8dfRgwCgYIKoZIzj0EAwIDaAAwZQIxALE9DbikX0km
UVBx2tc3QM7kM2TdIDhOCQ5a0cARDcfXmpQKoPFw31fVoHvbPaDFGAIwBH8Ixj3N
hG5ERArF3f+YnESSA8SOR0QDJxrAx/dP702H7Imct3LFSJiYB5DYkzWS
-----END CERTIFICATE-----`;
function rowsOf(pem: string, title: string): Map<string, string> {
const decoded = decodeCertificatePem(pem);
const section = decoded.sections.find(s => s.title === title);
expect(section, `section ${title}`).toBeDefined();
return new Map(section!.rows.map(row => [row.label, row.value]));
}
describe('x509 decoder vs OpenSSL corpus (regression)', () => {
it('renders IPv6 SANs in RFC 5952 text form and keeps IPv4 dotted', () => {
const san = rowsOf(RSA_IPV6_CERT_PEM, 'Extensions').get('subjectAltName') ?? '';
expect(san).toContain('IP: 2001:db8::1');
expect(san).toContain('IP: 192.168.1.1');
expect(san).toContain('DNS: a.example.com');
expect(san).toContain('Email: a@b.c');
expect(san).not.toContain('32.1.13.184');
});
it('decodes named bits beyond the first byte (decipherOnly)', () => {
const ku = rowsOf(RSA_IPV6_CERT_PEM, 'Extensions').get('keyUsage (critical)') ?? '';
expect(ku).toContain('digitalSignature');
expect(ku).toContain('keyEncipherment');
expect(ku).toContain('decipherOnly');
});
it('prints the bare SKI key id without the DER wrapper', () => {
const ski = rowsOf(RSA_IPV6_CERT_PEM, 'Extensions').get('subjectKeyIdentifier') ?? '';
expect(ski).toBe('8C:45:90:34:BE:EC:E8:E6:88:96:E2:EC:51:1A:63:FA:D9:26:5C:B1');
});
it('prints the AKI key id from the context-tagged child', () => {
const aki = rowsOf(ED25519_CUSTOM_OID_CERT_PEM, 'Extensions').get('authorityKeyIdentifier') ?? '';
expect(aki).toBe('E6:6C:EA:7C:04:B3:64:D5:CD:E0:CE:A9:8E:47:2B:CC:FF:1D:7D:18');
});
it('decodes extension OIDs with multi-byte first sub-identifiers', () => {
const extensions = rowsOf(ED25519_CUSTOM_OID_CERT_PEM, 'Extensions');
// 1.2.999.1 encodes its first sub-identifier as two base-128 bytes;
// the old first-byte shortcut decoded it as 3.39.77.1.
expect(extensions.has('1.2.999.1')).toBe(true);
expect(extensions.has('3.39.77.1')).toBe(false);
});
it('identifies the Ed25519 public key without a curve row', () => {
const key = rowsOf(ED25519_CUSTOM_OID_CERT_PEM, 'Public key');
expect(key.get('Algorithm')).toBe('Ed25519');
expect(key.get('Key size')).toBe('256 bits');
});
it('keeps the signature algorithm names for ECDSA issuer chains', () => {
const cert = rowsOf(ED25519_CUSTOM_OID_CERT_PEM, 'Certificate');
expect(cert.get('Signature algorithm')).toBe('ecdsa-with-SHA256');
});
});

View file

@ -0,0 +1,156 @@
import { md, pki } from 'node-forge';
import { describe, expect, it } from 'vitest';
import { decodeCertificatePem } from './x509-certificate-decoder.service';
const EC_CERT_PEM = `-----BEGIN CERTIFICATE-----
MIIBqjCCAVGgAwIBAgIUVgYjpd++yLMwyD15DLcDVQXwsscwCgYIKoZIzj0EAwIw
KzEXMBUGA1UEAwwOZWMuZXhhbXBsZS5jb20xEDAOBgNVBAoMB0VDIFRlc3QwHhcN
MjYwOTMwMTMzODI1WhcNMjcwOTMwMTMzODI1WjArMRcwFQYDVQQDDA5lYy5leGFt
cGxlLmNvbTEQMA4GA1UECgwHRUMgVGVzdDBZMBMGByqGSM49AgEGCCqGSM49AwEH
A0IABDLul3OTKmdoRTgVpAHhNCYl/cf0Kv0OtysMoyoXxazZ6l3RhMOFXuSSv7/Y
M4ryQ0NxdIPcfp3tdvz9gofPJZWjUzBRMB0GA1UdDgQWBBTqaYh03Xxut48jrjWu
SAwe84nD4TAfBgNVHSMEGDAWgBTqaYh03Xxut48jrjWuSAwe84nD4TAPBgNVHRMB
Af8EBTADAQH/MAoGCCqGSM49BAMCA0cAMEQCICWnMVnE7j/t2azIpgKwYc20+1Vo
gpauAYH3o8+v52jhAiAW7o36o7iEd5r8yuh6CR/qJYazHp3eo274Q537m4us7Q==
-----END CERTIFICATE-----`;
function rsaCertPem(options: { keyUsage?: boolean } = {}): string {
const keys = pki.rsa.generateKeyPair(1024);
const cert = pki.createCertificate();
cert.publicKey = keys.publicKey;
cert.serialNumber = '01AB';
cert.validity.notBefore = new Date('2026-01-01T00:00:00Z');
cert.validity.notAfter = new Date('2027-01-01T00:00:00Z');
const subject = [
{ shortName: 'CN', value: 'test.example.com' },
{ shortName: 'O', value: 'Test Org' },
{ shortName: 'C', value: 'DE' },
];
cert.setSubject(subject);
cert.setIssuer(subject);
const extensions: object[] = [
{ name: 'basicConstraints', cA: true, pathLen: 1 },
{ name: 'subjectAltName', altNames: [{ type: 2, value: 'test.example.com' }, { type: 7, ip: '10.0.0.1' }] },
];
if (options.keyUsage !== false) {
extensions.push({ name: 'keyUsage', digitalSignature: true, keyEncipherment: true });
}
cert.setExtensions(extensions);
cert.sign(keys.privateKey, md.sha256.create());
return pki.certificateToPem(cert);
}
function findRow(decoded: ReturnType<typeof decodeCertificatePem>, section: string, label: string) {
return decoded.sections
.find(s => s.title === section)
?.rows.find(row => row.label.startsWith(label));
}
describe('x509-certificate-decoder: RSA certificates', () => {
it('decodes the certificate overview section', () => {
const decoded = decodeCertificatePem(rsaCertPem());
expect(findRow(decoded, 'Certificate', 'Version')?.value).toBe('v3');
expect(findRow(decoded, 'Certificate', 'Serial number')?.value).toBe('01:AB');
expect(findRow(decoded, 'Certificate', 'Signature algorithm')?.value).toBe('sha256WithRSAEncryption');
expect(findRow(decoded, 'Certificate', 'Not before')?.value).toContain('2026-01-01');
expect(findRow(decoded, 'Certificate', 'Not after')?.value).toContain('2027-01-01');
});
it('decodes subject and issuer distinguished names', () => {
const decoded = decodeCertificatePem(rsaCertPem());
const subject = decoded.sections.find(s => s.title === 'Subject')?.rows;
expect(subject?.[0]).toMatchObject({ label: expect.stringContaining('(CN)'), value: 'test.example.com' });
expect(subject?.some(row => row.label.includes('(O)') && row.value === 'Test Org')).toBe(true);
expect(subject?.some(row => row.label.includes('(C)') && row.value === 'DE')).toBe(true);
const issuer = decoded.sections.find(s => s.title === 'Issuer')?.rows;
expect(issuer?.[0]?.value).toBe('test.example.com');
});
it('decodes the RSA public key details', () => {
const decoded = decodeCertificatePem(rsaCertPem());
expect(findRow(decoded, 'Public key', 'Algorithm')?.value).toBe('RSA');
expect(findRow(decoded, 'Public key', 'Key size')?.value).toBe('1024 bits');
expect(findRow(decoded, 'Public key', 'Exponent')?.value).toBe('65537');
});
it('decodes basicConstraints, SAN and keyUsage extensions', () => {
const decoded = decodeCertificatePem(rsaCertPem());
// forge's setExtensions drops pathLen from the DER; openssl fixture covers it
expect(findRow(decoded, 'Extensions', 'basicConstraints')?.value).toBe('CA: true');
const san = findRow(decoded, 'Extensions', 'subjectAltName')?.value ?? '';
expect(san).toContain('DNS: test.example.com');
expect(san).toContain('IP: 10.0.0.1');
expect(findRow(decoded, 'Extensions', 'keyUsage')?.value).toContain('digitalSignature');
expect(findRow(decoded, 'Extensions', 'keyUsage')?.value).toContain('keyEncipherment');
});
it('computes both fingerprints as hex pairs', () => {
const decoded = decodeCertificatePem(rsaCertPem());
const sha256 = findRow(decoded, 'Fingerprints', 'SHA-256')?.value ?? '';
expect(sha256).toMatch(/^([0-9A-F]{2}:){31}[0-9A-F]{2}$/);
const sha1 = findRow(decoded, 'Fingerprints', 'SHA-1')?.value ?? '';
expect(sha1).toMatch(/^([0-9A-F]{2}:){19}[0-9A-F]{2}$/);
});
});
const CA_PATHLEN_PEM = `-----BEGIN CERTIFICATE-----
MIIBiTCCATCgAwIBAgIUHBPZrRX8pGjpag/D48ZCCL2n+rowCgYIKoZIzj0EAwIw
GTEXMBUGA1UEAwwOY2EuZXhhbXBsZS5jb20wHhcNMjYwOTMwMTQwMjQzWhcNMjgw
OTI5MTQwMjQzWjAZMRcwFQYDVQQDDA5jYS5leGFtcGxlLmNvbTBZMBMGByqGSM49
AgEGCCqGSM49AwEHA0IABDLul3OTKmdoRTgVpAHhNCYl/cf0Kv0OtysMoyoXxazZ
6l3RhMOFXuSSv7/YM4ryQ0NxdIPcfp3tdvz9gofPJZWjVjBUMB0GA1UdDgQWBBTq
aYh03Xxut48jrjWuSAwe84nD4TAfBgNVHSMEGDAWgBTqaYh03Xxut48jrjWuSAwe
84nD4TASBgNVHRMBAf8ECDAGAQH/AgEBMAoGCCqGSM49BAMCA0cAMEQCIAD1pF4R
oy3pyDv0IbID+fn3FmkbmWyb2pii6uEYyVtaAiB0lln06DKzc0bmww47Hf/ZfD/H
hiErXsIe25bNKf1Iwg==
-----END CERTIFICATE-----`;
describe('x509-certificate-decoder: EC certificates', () => {
it('decodes a prime256v1 certificate that node-forge certificateFromPem rejects', () => {
const decoded = decodeCertificatePem(EC_CERT_PEM);
expect(findRow(decoded, 'Certificate', 'Signature algorithm')?.value).toBe('ecdsa-with-SHA256');
expect(findRow(decoded, 'Public key', 'Algorithm')?.value).toBe('EC (id-ecPublicKey)');
expect(findRow(decoded, 'Public key', 'Curve')?.value).toBe('prime256v1 (P-256)');
expect(findRow(decoded, 'Public key', 'Key size')?.value).toBe('256 bits');
expect(findRow(decoded, 'Extensions', 'basicConstraints')?.value).toBe('CA: true');
});
});
describe('x509-certificate-decoder: critical extension with pathLen', () => {
it('decodes critical marker and pathLen from an openssl-generated CA cert', () => {
const decoded = decodeCertificatePem(CA_PATHLEN_PEM);
const bc = decoded.sections
.find(s2 => s2.title === 'Extensions')
?.rows.find(row => row.label.startsWith('basicConstraints'));
expect(bc?.label).toBe('basicConstraints (critical)');
expect(bc?.value).toBe('CA: true, pathLen: 1');
});
});
describe('x509-certificate-decoder: error handling', () => {
it('throws a helpful error for empty input', () => {
expect(() => decodeCertificatePem('')).toThrow('Paste a PEM certificate');
});
it('throws when no CERTIFICATE block is present', () => {
const keyPem = '-----BEGIN PRIVATE KEY-----\nAAAA\n-----END PRIVATE KEY-----';
expect(() => decodeCertificatePem(keyPem)).toThrow('No CERTIFICATE block');
});
it('throws on garbage input', () => {
expect(() => decodeCertificatePem('not a pem at all')).toThrow();
});
it('decodes the first certificate of a chain and ignores trailing blocks', () => {
const chain = `${rsaCertPem()}\n${EC_CERT_PEM}`;
const decoded = decodeCertificatePem(chain);
expect(findRow(decoded, 'Public key', 'Algorithm')?.value).toBe('RSA');
});
});

View file

@ -0,0 +1,432 @@
import { asn1, md as forgeMd, pem as forgePem } from 'node-forge';
export interface CertificateRow {
label: string
value: string
hint?: string
}
export interface CertificateSection {
title: string
rows: CertificateRow[]
}
export interface DecodedCertificate {
sections: CertificateSection[]
}
/** Distinguished-name attribute OIDs (RFC 5280 / legacy PKCS#9). */
const DN_OIDS: Record<string, string> = {
'2.5.4.3': 'CommonName (CN)',
'2.5.4.6': 'Country (C)',
'2.5.4.7': 'Locality (L)',
'2.5.4.8': 'StateOrProvince (ST)',
'2.5.4.9': 'Street',
'2.5.4.10': 'Organization (O)',
'2.5.4.11': 'OrganizationalUnit (OU)',
'2.5.4.5': 'SerialNumber',
'2.5.4.4': 'Surname (SN)',
'2.5.4.42': 'GivenName (GN)',
'2.5.4.12': 'Title',
'2.5.4.13': 'Description',
'2.5.4.17': 'PostalCode',
'1.2.840.113549.1.9.1': 'Email',
'0.9.2342.19200300.100.1.25': 'DomainComponent (DC)',
'0.9.2342.19200300.100.1.1': 'UserID (UID)',
};
// Dotted strings below are ASN.1 object identifiers in dotted-arc notation,
// not network addresses (S1313 pattern-matches some of them as IPv4).
const SIGNATURE_ALGORITHM_OIDS: Record<string, string> = {
'1.2.840.113549.1.1.4': 'md5WithRSAEncryption',
'1.2.840.113549.1.1.5': 'sha1WithRSAEncryption',
'1.2.840.113549.1.1.10': 'RSASSA-PSS',
'1.2.840.113549.1.1.11': 'sha256WithRSAEncryption',
'1.2.840.113549.1.1.12': 'sha384WithRSAEncryption',
'1.2.840.113549.1.1.13': 'sha512WithRSAEncryption',
'1.2.840.10045.4.1': 'ecdsa-with-SHA1',
'1.2.840.10045.4.3.2': 'ecdsa-with-SHA256',
'1.2.840.10045.4.3.3': 'ecdsa-with-SHA384',
'1.2.840.10045.4.3.4': 'ecdsa-with-SHA512',
'1.3.101.112': 'Ed25519',
'1.3.101.113': 'Ed448',
};
const PUBLIC_KEY_ALGORITHM_OIDS: Record<string, string> = {
'1.2.840.113549.1.1.1': 'RSA',
'1.2.840.10045.2.1': 'EC (id-ecPublicKey)',
'1.3.101.110': 'X25519',
'1.3.101.111': 'X448',
'1.3.101.112': 'Ed25519',
'1.3.101.113': 'Ed448',
};
const CURVE_OIDS: Record<string, string> = {
'1.2.840.10045.3.1.1': 'secp192r1 (P-192)',
'1.2.840.10045.3.1.7': 'prime256v1 (P-256)',
'1.3.132.0.10': 'secp256k1',
'1.3.132.0.33': 'secp224r1 (P-224)',
'1.3.132.0.34': 'secp384r1 (P-384)',
'1.3.132.0.35': 'secp521r1 (P-521)',
};
const EXTENSION_OIDS: Record<string, string> = {
'2.5.29.19': 'basicConstraints',
'2.5.29.17': 'subjectAltName',
'2.5.29.18': 'issuerAltName',
'2.5.29.15': 'keyUsage',
'2.5.29.14': 'subjectKeyIdentifier',
'2.5.29.35': 'authorityKeyIdentifier',
'2.5.29.31': 'cRLDistributionPoints',
'2.5.29.37': 'extKeyUsage',
'1.3.6.1.5.5.7.1.1': 'authorityInfoAccess',
'1.3.6.1.4.1.11129.2.4.2': 'signedCertificateTimestampList',
};
const EXTENDED_KEY_USAGE_OIDS: Record<string, string> = {
'1.3.6.1.5.5.7.3.1': 'serverAuth',
'1.3.6.1.5.5.7.3.2': 'clientAuth',
'1.3.6.1.5.5.7.3.3': 'codeSigning',
'1.3.6.1.5.5.7.3.4': 'emailProtection',
'1.3.6.1.5.5.7.3.8': 'timeStamping',
'1.3.6.1.5.5.7.3.9': 'OCSPSigning',
};
/** IPv4 = dotted decimal; IPv6 = RFC 5952 text (lowercase, longest zero run
* compressed). Anything else falls back to dotted decimal of the bytes. */
function decodeIpAddress(bytes: string): string {
const octets = Array.from(bytes, ch => ch.charCodeAt(0));
if (octets.length === 16) {
const groups: string[] = [];
for (let i = 0; i < 16; i += 2) {
groups.push(((octets[i]! << 8) | octets[i + 1]!).toString(16));
}
// Compress the longest run of zero groups (length ≥ 2) into '::'.
let bestStart = -1;
let bestLength = 0;
let currentStart = -1;
let currentLength = 0;
for (let i = 0; i < groups.length; i++) {
if (groups[i] !== '0') {
currentStart = -1;
currentLength = 0;
continue;
}
if (currentStart < 0) {
currentStart = i;
}
currentLength++;
if (currentLength > bestLength) {
bestStart = currentStart;
bestLength = currentLength;
}
}
if (bestLength >= 2) {
const head = groups.slice(0, bestStart).join(':');
const tail = groups.slice(bestStart + bestLength).join(':');
return `${head}::${tail}`;
}
return groups.join(':');
}
return octets.join('.');
}
const GENERAL_NAME_TYPES: Record<number, { label: string; decode: (value: string) => string }> = {
1: { label: 'Email', decode: value => value },
2: { label: 'DNS', decode: value => value },
6: { label: 'URI', decode: value => value },
7: { label: 'IP', decode: decodeIpAddress },
4: { label: 'DirName', decode: () => '(directory name)' },
8: { label: 'RID', decode: () => '(registered id)' },
};
/** forge types .value as string | Asn1[]; narrow to the constructed shape. */
function asChildren(value: unknown): asn1.Asn1[] {
return (value ?? []) as unknown as asn1.Asn1[];
}
function isUniversal(node: asn1.Asn1, tag: number): boolean {
return node.tagClass === asn1.Class.UNIVERSAL && node.type === tag;
}
function primitiveValue(node: asn1.Asn1 | undefined): string {
return typeof node?.value === 'string' ? node.value : '';
}
/** forge may swap a BIT STRING's .value for a composed [asn1] array when its
* content parses as ASN.1; the raw bytes always live in bitStringContents. */
function bitStringValue(node: asn1.Asn1 | undefined): string {
// bitStringContents exists at runtime but is missing from forge's type defs
const raw = (node as unknown as { bitStringContents?: string } | undefined)?.bitStringContents
?? primitiveValue(node);
return raw.slice(1); // drop the leading unused-bits octet
}
/** forge's fromDer keeps OBJECT IDENTIFIER values as raw DER bytes; expand
* them to dotted notation. The first sub-identifier packs 40·arc1+arc2 and
* may itself span multiple base-128 bytes (arc2 ≥ 40 under arc 2, e.g.
* 2.999 → 0x88 0x67); the rest decode as plain base-128. */
function decodeOid(node: asn1.Asn1 | undefined): string {
const bytes = primitiveValue(node);
if (!bytes) {
return '';
}
let pending = 0;
let first = 0;
let index = 0;
// Accumulate the packed first sub-identifier (continuation while high bit set).
for (; index < bytes.length; index++) {
const byte = bytes.charCodeAt(index);
pending = (pending << 7) | (byte & 0x7F);
if ((byte & 0x80) === 0) {
first = pending;
index++;
break;
}
}
const parts: number[] = first < 80
? [Math.floor(first / 40), first % 40]
: [2, first - 80];
pending = 0;
for (; index < bytes.length; index++) {
const byte = bytes.charCodeAt(index);
pending = (pending << 7) | (byte & 0x7F);
if ((byte & 0x80) === 0) {
parts.push(pending);
pending = 0;
}
}
return parts.join('.');
}
function hex(bytes: string, separator = ':'): string {
return Array.from(bytes, ch => ch.charCodeAt(0).toString(16).padStart(2, '0')).join(separator).toUpperCase();
}
function toIso(date: Date): string {
return `${date.toISOString().replace('T', ' ').replace(/\..+/, '')} UTC`;
}
function parseTime(node: asn1.Asn1): Date {
const raw = primitiveValue(node);
// UTCTime YYMMDDHHMMSSZ or GeneralizedTime YYYYMMDDHHMMSSZ
const utcTime = raw.length === 13;
const year = utcTime
? (Number(raw.slice(0, 2)) < 50 ? `20${raw.slice(0, 2)}` : `19${raw.slice(0, 2)}`)
: raw.slice(0, 4);
const rest = utcTime ? raw.slice(2) : raw.slice(4);
return new Date(`${year}-${rest.slice(0, 2)}-${rest.slice(2, 4)}T${rest.slice(4, 6)}:${rest.slice(6, 8)}:${rest.slice(8, 10) || '00'}Z`);
}
function walkDn(nameNode: asn1.Asn1 | undefined): CertificateRow[] {
const rows: CertificateRow[] = [];
const rdns = (nameNode?.value ?? []) as unknown as asn1.Asn1[];
for (const rdnSet of rdns) {
for (const attr of (rdnSet.value ?? []) as unknown as asn1.Asn1[]) {
const [oidNode, valueNode] = (attr.value ?? []) as asn1.Asn1[];
const oid = decodeOid(oidNode);
rows.push({ label: DN_OIDS[oid] ?? oid, value: primitiveValue(valueNode) });
}
}
return rows;
}
function describeAlgorithmIdentifier(node: asn1.Asn1 | undefined, table: Record<string, string>): { oid: string; name: string } {
const oid = decodeOid(node?.value?.[0] as asn1.Asn1 | undefined);
return { oid, name: table[oid] ?? oid };
}
function describePublicKey(spkiNode: asn1.Asn1 | undefined): CertificateRow[] {
const rows: CertificateRow[] = [];
const [algorithmNode, keyNode] = (spkiNode?.value ?? []) as asn1.Asn1[];
const { oid: algOid, name: algName } = describeAlgorithmIdentifier(algorithmNode, PUBLIC_KEY_ALGORITHM_OIDS);
rows.push({ label: 'Algorithm', value: algName, hint: algOid });
const keyBytes = bitStringValue(keyNode);
const algParams = (algorithmNode?.value ?? [])[1] as asn1.Asn1 | undefined;
if (algOid === '1.2.840.113549.1.1.1') {
try {
const rsa = asn1.fromDer(keyBytes);
const [modulus, exponent] = asChildren(rsa.value);
const modBytes = primitiveValue(modulus);
const leadingZero = modBytes.charCodeAt(0) === 0 ? 1 : 0;
rows.push({ label: 'Key size', value: `${(modBytes.length - leadingZero) * 8} bits` });
const expBytes = primitiveValue(exponent);
const exponentValue = Array.from(expBytes, ch => ch.charCodeAt(0)).reduce((acc, byte) => acc * 256 + byte, 0);
rows.push({ label: 'Exponent', value: exponentValue.toString() });
}
catch {
rows.push({ label: 'Key size', value: 'unparseable modulus' });
}
return rows;
}
if (algOid === '1.2.840.10045.2.1') {
const curveOid = decodeOid(algParams);
rows.push({ label: 'Curve', value: CURVE_OIDS[curveOid] ?? curveOid, hint: curveOid });
rows.push({ label: 'Key size', value: `${keyBytes.charCodeAt(0) === 4 ? ((keyBytes.length - 1) / 2) * 8 : keyBytes.length * 8} bits` });
rows.push({ label: 'Public key', value: hex(keyBytes) });
return rows;
}
rows.push({ label: 'Key size', value: `${keyBytes.length * 8} bits` });
rows.push({ label: 'Public key', value: hex(keyBytes) });
return rows;
}
function describeExtension(extension: asn1.Asn1): CertificateRow {
const children = (extension.value ?? []) as asn1.Asn1[];
const [oidNode] = children;
// critical BOOLEAN is optional and only present between OID and value
const criticalNode = isUniversal(children[1], asn1.Type.BOOLEAN) ? children[1] : undefined;
const valueNode = criticalNode ? children[2] : children[1];
const oid = decodeOid(oidNode);
const name = EXTENSION_OIDS[oid] ?? oid;
const critical = criticalNode !== undefined && primitiveValue(criticalNode).charCodeAt(0) !== 0;
const raw = primitiveValue(valueNode);
const label = critical ? `${name} (critical)` : name;
try {
if (oid === '2.5.29.19') {
const bc = asn1.fromDer(raw);
const bcChildren = asChildren(bc.value);
const isCa = bcChildren[0] ? primitiveValue(bcChildren[0]).charCodeAt(0) !== 0 : false;
const pathLenNode = bcChildren[1];
const pathLen = pathLenNode ? Array.from(primitiveValue(pathLenNode), ch => ch.charCodeAt(0)).reduce((acc, b) => acc * 256 + b, 0) : undefined;
return { label, value: `CA: ${isCa}${pathLen !== undefined ? `, pathLen: ${pathLen}` : ''}` };
}
if (oid === '2.5.29.17' || oid === '2.5.29.18') {
const names = asn1.fromDer(raw);
const parts = asChildren(names.value).map((generalName) => {
const meta = GENERAL_NAME_TYPES[generalName.type];
return meta ? `${meta.label}: ${meta.decode(primitiveValue(generalName))}` : `type${generalName.type}`;
});
return { label, value: parts.join(', ') || '(empty)' };
}
if (oid === '2.5.29.15') {
const bytes = bitStringValue(asn1.fromDer(raw));
// Named bits span up to 2 bytes (bit 9 = decipherOnly); unused-bits
// count was already dropped with the BIT STRING prefix.
const flags = ['digitalSignature', 'nonRepudiation', 'keyEncipherment', 'dataEncipherment', 'keyAgreement', 'keyCertSign', 'cRLSign', 'encipherOnly', 'decipherOnly'];
const active = flags.filter((_, index) => {
const byte = bytes.charCodeAt(index >> 3) ?? 0;
return byte & (0b1000_0000 >> (index & 7));
});
return { label, value: active.join(', ') || '(none)' };
}
if (oid === '2.5.29.37') {
const usages = asn1.fromDer(raw);
const parts = asChildren(usages.value).map(node => EXTENDED_KEY_USAGE_OIDS[decodeOid(node)] ?? decodeOid(node));
return { label, value: parts.join(', ') };
}
if (oid === '2.5.29.14') {
// SKI: the extension value is a bare OCTET STRING — hex its content,
// not the DER wrapper (openssl prints the bare key id).
const node = asn1.fromDer(raw);
const value = isUniversal(node, asn1.Type.OCTETSTRING) ? primitiveValue(node) : raw;
return { label, value: hex(value) };
}
if (oid === '2.5.29.35') {
// AKI: SEQUENCE; the key identifier lives in the context-tagged [0]
// child. Fall back to the whole-der hex when the shape is unexpected.
const first = asChildren(asn1.fromDer(raw).value)[0];
const isKeyId = first
&& first.tagClass === asn1.Class.CONTEXT_SPECIFIC
&& first.type === 0;
const value = isKeyId ? primitiveValue(first) : raw;
return { label, value: hex(value) };
}
}
catch {
// fall through to hex display
}
return { label, value: hex(raw) };
}
function fingerprintHex(der: string, algorithm: 'sha1' | 'sha256'): string {
const digest = forgeMd.algorithms[algorithm].create();
digest.update(der);
const hexDigest = digest.digest().toHex();
return hexDigest.match(/.{2}/g)!.join(':').toUpperCase();
}
export function decodeCertificatePem(pem: string): DecodedCertificate {
const trimmed = pem.trim();
if (!trimmed) {
throw new Error('Paste a PEM certificate to decode');
}
const messages = forgePem.decode(trimmed);
const certificateMessage = messages.find(message => message.type === 'CERTIFICATE');
if (!certificateMessage) {
throw new Error('No CERTIFICATE block found (this tool decodes certificates, not keys)');
}
const root = asn1.fromDer(certificateMessage.body);
if (root.type !== asn1.Type.SEQUENCE || !(Array.isArray(root.value)) || root.value.length < 3) {
throw new Error('Input is not a valid X.509 certificate');
}
const [tbsNode, outerSignatureNode] = asChildren(root.value);
if (tbsNode.type !== asn1.Type.SEQUENCE) {
throw new Error('Input is not a valid X.509 certificate');
}
let cursor = 0;
let versionRow: CertificateRow = { label: 'Version', value: '1 (implicit)' };
const versionNode = asChildren(tbsNode.value)[0];
if (versionNode && versionNode.tagClass === asn1.Class.CONTEXT_SPECIFIC && versionNode.type === 0) {
const versionValue = versionNode.value?.[0] ? primitiveValue(asChildren(versionNode.value)[0]).charCodeAt(0) : 0;
versionRow = { label: 'Version', value: `v${(versionValue ?? 0) + 1}` };
cursor = 1;
}
const tbsChildren = asChildren(tbsNode.value);
const serialNode = tbsChildren[cursor];
const tbsSignatureNode = tbsChildren[cursor + 1];
const issuerNode = tbsChildren[cursor + 2];
const validityNode = tbsChildren[cursor + 3];
const subjectNode = tbsChildren[cursor + 4];
const spkiNode = tbsChildren[cursor + 5];
const extensionsWrapper = asChildren(tbsNode.value).find(
node => node.tagClass === asn1.Class.CONTEXT_SPECIFIC && node.type === 3,
);
const tbsSignature = describeAlgorithmIdentifier(tbsSignatureNode, SIGNATURE_ALGORITHM_OIDS);
const outerSignature = describeAlgorithmIdentifier(outerSignatureNode, SIGNATURE_ALGORITHM_OIDS);
const serialBytes = primitiveValue(serialNode as asn1.Asn1 | undefined);
const [notBeforeNode, notAfterNode] = asChildren(validityNode?.value);
const notBefore = parseTime(notBeforeNode);
const notAfter = parseTime(notAfterNode);
const extensionRows: CertificateRow[] = asChildren(asChildren(extensionsWrapper?.value)[0]?.value)
.map(extension => describeExtension(extension));
const sections: CertificateSection[] = [
{
title: 'Certificate',
rows: [
versionRow,
{ label: 'Serial number', value: hex(serialBytes) },
{ label: 'Signature algorithm', value: tbsSignature.name, hint: tbsSignature.oid },
{ label: 'Not before', value: toIso(notBefore) },
{ label: 'Not after', value: toIso(notAfter) },
{ label: 'Signature consistency', value: outerSignature.name === tbsSignature.name ? 'outer matches TBS' : `outer: ${outerSignature.name}` },
],
},
{ title: 'Subject', rows: walkDn(subjectNode as asn1.Asn1 | undefined) },
{ title: 'Issuer', rows: walkDn(issuerNode as asn1.Asn1 | undefined) },
{ title: 'Public key', rows: describePublicKey(spkiNode as asn1.Asn1 | undefined) },
{ title: 'Extensions', rows: extensionRows.length > 0 ? extensionRows : [{ label: '(none)', value: '' }] },
{
title: 'Fingerprints',
rows: [
{ label: 'SHA-256', value: fingerprintHex(certificateMessage.body, 'sha256') },
{ label: 'SHA-1', value: fingerprintHex(certificateMessage.body, 'sha1') },
],
},
];
return { sections };
}

View file

@ -0,0 +1,94 @@
<script setup lang="ts">
import { formatDistanceToNow } from 'date-fns';
import { decodeCertificatePem } from './x509-certificate-decoder.service';
import { useValidation } from '@/composable/validation';
import { isNotThrowing } from '@/utils/boolean';
import { withDefaultOnError } from '@/utils/defaults';
const rawCertificate = ref('');
const decoded = computed(() =>
withDefaultOnError(() => decodeCertificatePem(rawCertificate.value), { sections: [] }),
);
const validation = useValidation({
source: rawCertificate,
rules: [
{
validator: value => value.length === 0 || isNotThrowing(() => decodeCertificatePem(value)),
message: 'Invalid certificate (paste a PEM CERTIFICATE block)',
},
],
});
const notAfterRow = computed(() =>
decoded.value.sections
.find(section => section.title === 'Certificate')
?.rows.find(row => row.label === 'Not after'),
);
const expiryRelative = computed(() => {
const raw = notAfterRow.value?.value;
if (!raw || !validation.isValid) {
return undefined;
}
const date = new Date(raw.replace(' UTC', 'Z'));
if (Number.isNaN(date.getTime())) {
return undefined;
}
return formatDistanceToNow(date, { addSuffix: true });
});
</script>
<template>
<c-card>
<c-input-text
v-model:value="rawCertificate"
label="Certificate (PEM)"
:validation="validation"
placeholder="-----BEGIN CERTIFICATE----- ... -----END CERTIFICATE-----"
rows="8"
raw-text autofocus multiline mb-3
/>
<n-alert v-if="rawCertificate.length > 0 && !validation.isValid" type="error" mb-3>
Paste a PEM certificate (-----BEGIN CERTIFICATE-----), not a private key or CSR.
</n-alert>
<template v-if="validation.isValid && rawCertificate.length > 0">
<n-alert v-if="expiryRelative" type="info" mb-3>
Expires {{ expiryRelative }}
</n-alert>
<n-table>
<tbody>
<template v-for="section of decoded.sections" :key="section.title">
<th colspan="2" class="table-header">
{{ section.title }}
</th>
<tr v-for="row of section.rows" :key="section.title + row.label + row.value">
<td class="claims" style="vertical-align: top;">
<span font-bold>{{ row.label }}</span>
<span v-if="row.hint" ml-2 text-xs op-60>{{ row.hint }}</span>
</td>
<td style="word-wrap: break-word; word-break: break-all;">
{{ row.value }}
</td>
</tr>
</template>
</tbody>
</n-table>
</template>
</c-card>
</template>
<style scoped lang="less">
.table-header {
font-weight: bold;
text-align: left;
}
.claims {
width: 260px;
}
</style>