diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 13b787ef..427a27f7 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -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 }} diff --git a/package.json b/package.json index 5738b632..cda06f3f 100644 --- a/package.json +++ b/package.json @@ -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" + } } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5d113893..b74d3b61 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/src/tools/index.ts b/src/tools/index.ts index 388cfaf4..f0da1b3a 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -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', diff --git a/src/tools/x509-certificate-decoder/index.ts b/src/tools/x509-certificate-decoder/index.ts new file mode 100644 index 00000000..cb57728d --- /dev/null +++ b/src/tools/x509-certificate-decoder/index.ts @@ -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'), +}); diff --git a/src/tools/x509-certificate-decoder/x509-certificate-decoder.openssl-regression.test.ts b/src/tools/x509-certificate-decoder/x509-certificate-decoder.openssl-regression.test.ts new file mode 100644 index 00000000..0e303ec9 --- /dev/null +++ b/src/tools/x509-certificate-decoder/x509-certificate-decoder.openssl-regression.test.ts @@ -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 { + 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'); + }); +}); diff --git a/src/tools/x509-certificate-decoder/x509-certificate-decoder.service.test.ts b/src/tools/x509-certificate-decoder/x509-certificate-decoder.service.test.ts new file mode 100644 index 00000000..8119f888 --- /dev/null +++ b/src/tools/x509-certificate-decoder/x509-certificate-decoder.service.test.ts @@ -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, 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'); + }); +}); diff --git a/src/tools/x509-certificate-decoder/x509-certificate-decoder.service.ts b/src/tools/x509-certificate-decoder/x509-certificate-decoder.service.ts new file mode 100644 index 00000000..3f4d4168 --- /dev/null +++ b/src/tools/x509-certificate-decoder/x509-certificate-decoder.service.ts @@ -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 = { + '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 = { + '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 = { + '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 = { + '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 = { + '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 = { + '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 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): { 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 }; +} diff --git a/src/tools/x509-certificate-decoder/x509-certificate-decoder.vue b/src/tools/x509-certificate-decoder/x509-certificate-decoder.vue new file mode 100644 index 00000000..7e33486c --- /dev/null +++ b/src/tools/x509-certificate-decoder/x509-certificate-decoder.vue @@ -0,0 +1,94 @@ + + + + +