diff --git a/.github/workflows/build-extensions.yml b/.github/workflows/build-extensions.yml index 3baa747..5e7833d 100644 --- a/.github/workflows/build-extensions.yml +++ b/.github/workflows/build-extensions.yml @@ -7,8 +7,8 @@ on: description: Use production build (minified, no sourcemaps) type: boolean default: false - upload-vsix: - description: Package and upload VSIX artifacts + upload-artifacts: + description: Package and upload release artifacts (VSIXes) for downstream publish jobs. type: boolean default: false @@ -70,7 +70,7 @@ jobs: run: npm run package -w src/vscode-workspace-extension - name: Upload VSIX artifacts - if: inputs.upload-vsix + if: inputs.upload-artifacts uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: vsix @@ -107,6 +107,56 @@ jobs: - name: Validate PFX with .NET run: npm test -w src/vscode-ui-extension -- tests/dotnetPfx.integration.test.ts + macos-dotnet-dev-certs-cache: + name: macOS dotnet dev-certs disk-cache compatibility + runs-on: macos-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: "22" + + - name: Install dependencies + run: npm ci + + - name: Build UI Extension + run: npm run ${{ inputs.production && 'build:prod' || 'build' }} -w src/vscode-ui-extension + + - name: Set up .NET 10 SDK + # Test invokes `dotnet dev-certs https` to produce the actual + # on-disk cache file aspnetcore's MacOSCertificateManager + # writes. We need a real SDK on PATH; the exact major version + # is logged so we can correlate the result with SDK behavior + # if aspnetcore changes its export defaults. + uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0 + with: + dotnet-version: "10.0.x" + + - name: Unlock login keychain for dotnet dev-certs + # `dotnet dev-certs https` saves the generated cert into + # CurrentUser\My, which on macOS is the login keychain. GitHub + # Actions macos runners ship with the login keychain locked + # by default — without an unlock, the save call fails with + # "There was an error saving the HTTPS developer certificate + # to the current user personal certificate store." and the + # disk cache we're trying to test never gets written. + # + # The empty password is the documented GH Actions convention + # for the runner user's login keychain. + run: | + security unlock-keychain -p '' "$HOME/Library/Keychains/login.keychain-db" + security set-keychain-settings "$HOME/Library/Keychains/login.keychain-db" + + - name: Validate dotnet dev-certs disk cache is loadable + # If this fails, the dotnet backend on macOS cannot discover + # the cert dotnet just created — platform-store discovery + # depends on `parsePfx` accepting whatever + # `certificate.Export(X509ContentType.Pfx)` writes on macOS. + # The same test pins the on-disk PBE algorithm OID so we get + # a loud signal the day aspnetcore switches to PBES2 and the + # legacy 3DES handler in `pkcs12LegacyPbe.ts` can be removed. + run: npm test -w src/vscode-ui-extension -- tests/dotnetMacosCache.integration.test.ts + feature: name: Validate Devcontainer Feature runs-on: ubuntu-latest diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8ae234e..507f6d4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,4 +12,4 @@ jobs: uses: ./.github/workflows/build-extensions.yml with: production: true - upload-vsix: true + upload-artifacts: true diff --git a/.github/workflows/release-feature.yml b/.github/workflows/release-feature.yml index 6fde874..19aa1c7 100644 --- a/.github/workflows/release-feature.yml +++ b/.github/workflows/release-feature.yml @@ -55,7 +55,7 @@ jobs: uses: ./.github/workflows/build-extensions.yml with: production: true - upload-vsix: true + upload-artifacts: true publish-feature: name: Publish feature to GHCR diff --git a/README.md b/README.md index 40b398b..a887f39 100644 --- a/README.md +++ b/README.md @@ -162,6 +162,7 @@ Set under the feature entry in `devcontainer.json`: | `syncUserCertificates` | `true` | Per-container opt-out for syncing certs configured in the host `devcontainerDevCerts.userCertificates` VS Code setting. | | `syncContainerCert` | `false` | **Reverse sync (opt-in).** When the container itself already has a valid ASP.NET dev certificate (e.g. baked into the image with `dotnet dev-certs https`), push it to the host so the host trusts it instead of generating its own. Enabling this also implicitly overrides `generateDotNetCert` for this container — you don't need to set both. See "[Syncing a certificate from the container to the host](#syncing-a-certificate-from-the-container-to-the-host)". | | `extraCertDestinations` | `""` | Comma-separated list of additional directories to write cert artifacts to. Each entry is `[=]` where `format` is `pem`, `key`, `pem-bundle`, `pfx`, or `all` (default). Every synced cert is written under the directory as `{name}.{pem,key,pfx}` (and/or `{name}-bundle.pem`). Example: `/etc/nginx/certs=pem,/var/myapp`. | +| `installFallbackTools` | `false` | Install the runtime prerequisites (`openssl`, `jq`) the fallback `devcontainer-dev-certs-install` script needs. The script itself is always delivered to `/usr/local/bin/` regardless of this option — set this to `true` only when you intend to invoke it manually (JetBrains / Vim / CLI users) and your base image does not already provide `openssl` and `jq`. See "[Manual / non-VS Code use](#manual--non-vs-code-use)". | ### Host VS Code settings @@ -322,6 +323,123 @@ When this is on, non-local SAN entries are shown in the consent modal so you can Pushes from a Dev Container without the matching feature option are ignored — the host setting on its own doesn't do anything until a container actively pushes. Host trust prompts fire per unique thumbprint, so opening multiple containers with different container-generated certs will accumulate trust prompts; this is intentional and is why the option isn't on by default. +## Manual / non-VS Code use + +> [!NOTE] +> This is a **minimally supported** path. The first-class experience this project ships is the VS Code host + remote extension pair; everything below exists so a user without VS Code isn't left with no escape hatch, but it's hand-wired and there's no host-side automation for cert generation or OS trust. Treat it as a "you're on your own for the host half" workaround, not a primary supported scenario. + +The companion-extension pattern is VS Code-specific, but the underlying container-side machinery isn't. JetBrains, Vim, raw CLI, and CI users can drive the in-container half of the trust state through a small shell tool the feature installs into the container. Host-side cert generation and OS trust remain the user's problem (typically `dotnet dev-certs https --trust`). + +For an end-to-end walkthrough — generating the cert on the host, mounting it in, wiring `postStartCommand` — see [`examples/manual-setup/`](examples/manual-setup/). The summary here is the reference. + +### The fallback installer + +The feature delivers `devcontainer-dev-certs-install` to `/usr/local/bin/` during install. It writes to the same canonical paths the VS Code workspace extension uses — `~/.dotnet/corefx/cryptography/x509stores/{my,root}` and `~/.aspnet/dev-certs/trust/` (with c_rehash symlinks) — so Kestrel's `X509Store` fallback and OpenSSL clients discover certs installed this way exactly as they would extension-installed ones. + +Three invocation forms: + +```bash +# Single cert (legacy positional form) +devcontainer-dev-certs-install /path/to/cert.pfx /path/to/cert.pem + +# Multi-cert + extra destinations (preferred) +devcontainer-dev-certs-install --bundle-json /path/to/bundle.json + +# Read-only diagnostics +devcontainer-dev-certs-install --doctor +``` + +The bundle form requires `openssl` and `jq`; the positional form needs only `openssl`. Set the feature option `installFallbackTools: true` to have the feature install them when they're missing from the base image. + +### Bundle JSON + +A bundle describes one or more certs and (optionally) extra destinations. The full schema lives at [`schema/bundle.schema.json`](schema/bundle.schema.json); reference it from your bundle file to get autocomplete and validation in any editor that honors JSON Schema: + +```jsonc +{ + "$schema": "https://raw.githubusercontent.com/dnegstad/devcontainer-dev-certs/main/schema/bundle.schema.json", + "certs": [ + { + "name": "aspnetcore-dev", + "kind": "dotnet-dev", + "thumbprint": "ABCDEF...", + "pfxPath": "/host-dev-certs/aspnetcore-dev.pfx", + "pemPath": "/host-dev-certs/aspnetcore-dev.pem", + "pemKeyPath": "/host-dev-certs/aspnetcore-dev.key", + "trustInContainer": true + } + ], + "extraDestinations": [ + { "path": "/etc/nginx/certs", "format": "pem" } + ] +} +``` + +Key fields: + +| Field | Notes | +|-------|-------| +| `certs[].name` | Filename stem in extra destinations and (for user certs) in the OpenSSL trust dir. `[A-Za-z0-9._-]`, 1-64 chars. | +| `certs[].thumbprint` | SHA-1 fingerprint, hex, no separators. Used as the `.pfx` filename in the .NET store where Kestrel discovers it. | +| `certs[].pemPath` | Required. PEM-encoded cert. | +| `certs[].pfxPath` | PFX with private key. Required if you want Kestrel to serve TLS with this cert. | +| `certs[].pemKeyPath` | Private key in PEM form. Optional; needed only for `key` / `pem-bundle` extra destination formats. | +| `certs[].rootPfxPath` | Public-cert-only PFX for the .NET Root store. Synthesized via `openssl pkcs12 -nokeys` when omitted. | +| `certs[].trustInContainer` | Default `true`. Install into the Root store + OpenSSL trust dir, not just My. | +| `certs[].kind` | `dotnet-dev` uses the historic `aspnetcore-localhost-{thumbprint}.pem` filename; `user` (default) uses `{name}.pem`. | +| `extraDestinations[].path` | Absolute directory to write artifacts under. | +| `extraDestinations[].format` | `pem`, `key`, `pem-bundle`, `pfx`, or `all` (default). | + +### Lifecycle: invoking the installer from `devcontainer.json` + +Use `postStartCommand` (not `postCreateCommand`) so the install re-runs on every container start. That way regenerating the cert on the host takes effect on the next container start without a rebuild: + +```jsonc +{ + "features": { + "ghcr.io/dnegstad/devcontainer-dev-certs/devcontainer-dev-certs:1": { + "installFallbackTools": true + } + }, + "mounts": [ + "source=${localEnv:HOME}/.dev-certs,target=/host-dev-certs,type=bind,readonly" + ], + "postStartCommand": "devcontainer-dev-certs-install --bundle-json /host-dev-certs/bundle.json || true" +} +``` + +The `|| true` keeps a missing or malformed bundle from blocking container startup. + +### Path hints for integrations + +The feature exports a handful of `DEVCONTAINER_DEV_CERTS_*` env vars so integration scripts don't have to hardcode the canonical paths. They're available in login shells (via `/etc/profile.d/`) and PAM-based sessions (via `/etc/environment`): + +| Variable | Value | +|----------|-------| +| `DEVCONTAINER_DEV_CERTS_INSTALL_BIN` | `/usr/local/bin/devcontainer-dev-certs-install` | +| `DEVCONTAINER_DEV_CERTS_DOTNET_STORE_DIR` | `~/.dotnet/corefx/cryptography/x509stores/my` | +| `DEVCONTAINER_DEV_CERTS_DOTNET_ROOT_STORE_DIR` | `~/.dotnet/corefx/cryptography/x509stores/root` | +| `DEVCONTAINER_DEV_CERTS_TRUST_DIR` | `~/.aspnet/dev-certs/trust` | +| `DEVCONTAINER_DEV_CERTS_GENERATE_DOTNET` | Mirror of the `generateDotNetCert` feature option. | +| `DEVCONTAINER_DEV_CERTS_SYNC_USER` | Mirror of the `syncUserCertificates` feature option. | +| `DEVCONTAINER_DEV_CERTS_SYNC_FROM_CONTAINER` | Mirror of the `syncContainerCert` feature option. | +| `DEVCONTAINER_DEV_CERTS_EXTRA_DESTINATIONS` | Mirror of the `extraCertDestinations` feature option. | + +### Verifying with `--doctor` + +`devcontainer-dev-certs-install --doctor` runs read-only checks across the trust infrastructure: prerequisite presence, trust-directory existence and writability, `SSL_CERT_DIR` state, the gating of `DOTNET_GENERATE_ASPNET_CERTIFICATE`, per-store cert listings (subject / `notAfter` / SHA-1), c_rehash symlink integrity, and expired-cert / multiple-dev-cert detection. Exits non-zero only when something is demonstrably broken, so it's safe to chain into CI: + +```bash +devcontainer-dev-certs-install --bundle-json /host-dev-certs/bundle.json \ + && devcontainer-dev-certs-install --doctor +``` + +### What's still VS Code-only + +- **Host-side cert generation and trust.** Use `dotnet dev-certs https --trust` (or your platform's equivalent) and export the PFX / PEM into your mounted host directory. +- **`defaultKestrelCertificate`.** Lives in a VS Code setting and is delivered via `EnvironmentVariableCollection`. Set `ASPNETCORE_Kestrel__Certificates__Default__Path`/`__Password` yourself in `devcontainer.json` `containerEnv` if you need this outside VS Code. +- **Reverse sync (`syncContainerCert`).** Needs a privileged host-side process with a consent UI; the script can't perform host trust. + ## Development ### Prerequisites @@ -351,7 +469,7 @@ This feature applies the workaround at the end of its own install script, so com ## Limitations - **Auto-generated dev cert matches .NET's format only.** The `generateDotNetCert` flow produces a cert identical to `dotnet dev-certs https` (specific OID marker, subject, SAN entries). To sync differently-shaped certs (corporate CAs, custom wildcard certs, etc.), add them via the `devcontainerDevCerts.userCertificates` VS Code setting — they're copied as-is. -- **VS Code only.** The companion extension pattern relies on VS Code's cross-host command routing. Other editors (JetBrains, Vim, etc.) are not supported, though the devcontainer feature includes a `setup-cert.sh` fallback script (with a `--bundle-json` form for multi-cert bundles) for manual use. +- **Full automation is VS Code-only.** The host extension's certificate generation, OS trust, and cross-host routing rely on VS Code APIs. JetBrains, Vim, and CLI users can drive the same container-side trust state through the `devcontainer-dev-certs-install` fallback installer the feature ships at `/usr/local/bin/` — see "[Manual / non-VS Code use](#manual--non-vs-code-use)" — but cert generation and host trust are still on the user. - **Host trust requires user interaction.** On Windows, trusting the auto-generated dev cert triggers a system dialog. On macOS, the keychain may prompt for a password. This only happens once and only for the .NET dev cert — user-managed certs are never added to the host OS trust store. ## Supported Platforms diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000..f991ac2 --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,24 @@ +# Releasing + +This repository ships three components together on a single release trigger: + +- The devcontainer feature (`ghcr.io/dnegstad/devcontainer-dev-certs/devcontainer-dev-certs`) +- The host VS Code extension (`dnegstad.devcontainer-dev-certs-host`) +- The remote VS Code extension (`dnegstad.devcontainer-dev-certs-remote`) + +All three share one repo-wide version. A release is one GitHub Release; everything downstream runs from `.github/workflows/release-feature.yml`. + +## Normal release procedure + +1. Verify every component is at the version you intend to release. `bump-version.yml` keeps them in lockstep automatically after the previous release, so this usually means "merge the open `chore/bump-X.Y.Z-pre` PR and then promote `X.Y.Z-pre` to `X.Y.Z`" via a separate bump PR. +2. Create a GitHub Release with tag `vX.Y.Z` (must match the component versions exactly — `validate-release` will fail the workflow otherwise). +3. Publish the GitHub Release. The `release: [released]` trigger starts the pipeline. + +The workflow: + +- Validates that the tag matches every component's `package.json` / `devcontainer-feature.json` version. +- Builds and tests everything via `build-extensions.yml` in production mode, producing VSIX files as workflow artifacts. +- Publishes the devcontainer feature to GHCR with build provenance. +- Attests VSIX provenance via `actions/attest-build-provenance` and attaches both VSIXes to the GitHub Release. + +No manual publish commands. The GitHub Release is the trigger; CI does the rest. diff --git a/examples/manual-setup/README.md b/examples/manual-setup/README.md new file mode 100644 index 0000000..e53324e --- /dev/null +++ b/examples/manual-setup/README.md @@ -0,0 +1,108 @@ +# manual-setup + +End-to-end example for using `devcontainer-dev-certs` outside of VS Code. + +> [!IMPORTANT] +> Usage outside of VS Code is **minimally supported** and the host-side half of the workflow is hand-wired. The host extension that ships with this project does cert generation, OS trust, and routing automatically; without it you're driving `dotnet dev-certs` (or a comparable tool) yourself and only the in-container installer is automated. If you're choosing between this and the VS Code extensions, pick the extensions. + +## What this gives you + +The same canonical trust state the VS Code workspace extension produces — `~/.dotnet/corefx/cryptography/x509stores/{my,root}` populated with your dev cert and `~/.aspnet/dev-certs/trust/` populated with hash-symlinked PEMs — without VS Code being involved. Kestrel discovers the cert via its `X509Store` fallback; `curl`, `wget`, and other OpenSSL clients trust it via `SSL_CERT_DIR`. + +What this does **not** give you: automated host-side cert generation, automated OS trust on the host, the reverse-sync flow (`syncContainerCert`), or `defaultKestrelCertificate`. Those all require the VS Code host extension. + +## Prerequisites + +- The `devcontainer-dev-certs` feature in your `devcontainer.json` with `installFallbackTools: true` (or `openssl` and `jq` already present in your base image). +- A directory on the host containing the cert files you want installed, plus a `bundle.json` describing them. + +## One-time host setup + +Pick a host directory to hold your certs and bundle file: + +```bash +mkdir -p ~/.dev-certs +``` + +Generate the ASP.NET dev cert and export both forms: + +```bash +dotnet dev-certs https --trust +dotnet dev-certs https --format Pfx --no-password \ + --export-path ~/.dev-certs/aspnetcore-dev.pfx +dotnet dev-certs https --format PEM --no-password \ + --export-path ~/.dev-certs/aspnetcore-dev.pem +``` + +Compute the SHA-1 fingerprint (this is what `bundle.json` calls `thumbprint`): + +```bash +openssl x509 -in ~/.dev-certs/aspnetcore-dev.pem -noout -fingerprint -sha1 \ + | sed 's/^[^=]*=//' | tr -d ':' +``` + +Drop a copy of [`bundle.json`](./bundle.json) into `~/.dev-certs/` and replace `REPLACE_WITH_SHA1_FINGERPRINT_NO_COLONS` with the fingerprint you just computed. + +> **Note on `dotnet dev-certs`-generated certs vs the host extension's certs.** This path uses `dotnet dev-certs https` for cert generation. The VS Code host extension produces functionally equivalent certs with the same OID marker and SAN entries — either source works against the same fallback installer in the container. If you need to share a *specific* cert with the host extension (e.g. a Windows developer also runs the extension), generate it once and have both flows consume the same PFX. + +## Project setup + +Copy the bits of [`devcontainer.json`](./devcontainer.json) you want into your own `.devcontainer/devcontainer.json`: + +- the `devcontainer-dev-certs` feature reference with `installFallbackTools: true` +- the `mounts` entry that binds your host cert directory to `/host-dev-certs` read-only +- the `postStartCommand` that invokes the fallback installer + +The fallback installer is delivered to `/usr/local/bin/devcontainer-dev-certs-install` by the feature. + +Use `postStartCommand` (not `postCreateCommand`) so the install re-runs on every container start. That way regenerating the cert on the host (`dotnet dev-certs https --clean && …re-export…`) takes effect the next time you start the container — no rebuild required. The `|| true` keeps container startup from blocking if the bundle is missing or malformed. + +## Verifying + +After the container starts, run: + +```bash +devcontainer-dev-certs-install --doctor +``` + +You should see `[ok]` for every check. If you see `[fail]` or `[warn]`, the message tells you what to fix. + +You can also sanity-check from inside the container: + +```bash +# Kestrel-style discovery +ls ~/.dotnet/corefx/cryptography/x509stores/my/ + +# OpenSSL trust (curl, wget, etc.) +echo "$SSL_CERT_DIR" +ls ~/.aspnet/dev-certs/trust/ +``` + +## Adding more certs + +The bundle is a list — add corporate CAs, wildcard certs, etc. as additional entries: + +```jsonc +{ + "$schema": "https://raw.githubusercontent.com/dnegstad/devcontainer-dev-certs/main/schema/bundle.schema.json", + "certs": [ + { "name": "aspnetcore-dev", "kind": "dotnet-dev", ... }, + { + "name": "corp-ca", + "thumbprint": "...", + "pemPath": "/host-dev-certs/corp-ca.pem", + "trustInContainer": true + } + ] +} +``` + +CA-only entries (no `pfxPath`, no `pemKeyPath`) are valid — they get planted in the trust store but no private key is synced. + +See the [bundle schema](../../schema/bundle.schema.json) for the full field reference. + +## Limitations + +- **Host trust is on you.** This script only handles the *container side*. Trusting the cert on your host so browsers accept forwarded ports requires `dotnet dev-certs https --trust`, an OS-specific dance (`security` on macOS, PowerShell on Windows, NSS / OpenSSL on Linux), or running the VS Code host extension once even if you don't use VS Code day-to-day. +- **No `defaultKestrelCertificate` equivalent.** The VS Code-only `defaultKestrelCertificate` setting writes `ASPNETCORE_Kestrel__Certificates__Default__Path/__Password` via VS Code's `EnvironmentVariableCollection`. To pin a custom Kestrel default outside VS Code, set those env vars yourself in `devcontainer.json` `containerEnv`. +- **No reverse sync (container → host).** The `syncContainerCert` flow needs a privileged host-side process to add the cert to the host OS trust store; without the host extension's UI there's nowhere to surface the consent prompt. diff --git a/examples/manual-setup/bundle.json b/examples/manual-setup/bundle.json new file mode 100644 index 0000000..6d430b7 --- /dev/null +++ b/examples/manual-setup/bundle.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://raw.githubusercontent.com/dnegstad/devcontainer-dev-certs/main/schema/bundle.schema.json", + "certs": [ + { + "name": "aspnetcore-dev", + "kind": "dotnet-dev", + "thumbprint": "REPLACE_WITH_SHA1_FINGERPRINT_NO_COLONS", + "pfxPath": "/host-dev-certs/aspnetcore-dev.pfx", + "pemPath": "/host-dev-certs/aspnetcore-dev.pem", + "pemKeyPath": "/host-dev-certs/aspnetcore-dev.key", + "trustInContainer": true + } + ], + "extraDestinations": [ + { "path": "/etc/nginx/certs", "format": "pem" } + ] +} diff --git a/examples/manual-setup/devcontainer.json b/examples/manual-setup/devcontainer.json new file mode 100644 index 0000000..6c9d13b --- /dev/null +++ b/examples/manual-setup/devcontainer.json @@ -0,0 +1,13 @@ +{ + "name": "manual-setup-example", + "image": "mcr.microsoft.com/devcontainers/dotnet:9.0", + "features": { + "ghcr.io/dnegstad/devcontainer-dev-certs/devcontainer-dev-certs:1": { + "installFallbackTools": true + } + }, + "mounts": [ + "source=${localEnv:HOME}/.dev-certs,target=/host-dev-certs,type=bind,readonly" + ], + "postStartCommand": "devcontainer-dev-certs-install --bundle-json /host-dev-certs/bundle.json || true" +} diff --git a/package-lock.json b/package-lock.json index a0d89f9..4e783da 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7064,7 +7064,7 @@ }, "src/shared": { "name": "@devcontainer-dev-certs/shared", - "version": "1.3.2-pre", + "version": "1.4.0-pre.1", "dependencies": { "@peculiar/x509": "^2.0.0", "asn1js": "^3.0.10", @@ -7077,7 +7077,7 @@ }, "src/vscode-ui-extension": { "name": "devcontainer-dev-certs-host", - "version": "1.3.2-pre", + "version": "1.4.0-pre.1", "license": "MIT", "dependencies": { "@devcontainer-dev-certs/shared": "*", @@ -7100,7 +7100,7 @@ }, "src/vscode-workspace-extension": { "name": "devcontainer-dev-certs-remote", - "version": "1.3.2-pre", + "version": "1.4.0-pre.1", "license": "MIT", "dependencies": { "@devcontainer-dev-certs/shared": "*", diff --git a/schema/bundle.schema.json b/schema/bundle.schema.json new file mode 100644 index 0000000..18fa56a --- /dev/null +++ b/schema/bundle.schema.json @@ -0,0 +1,88 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/dnegstad/devcontainer-dev-certs/main/schema/bundle.schema.json", + "title": "devcontainer-dev-certs bundle", + "description": "Input schema for the `devcontainer-dev-certs-install --bundle-json` fallback installer. Use this when invoking the installer outside of VS Code (JetBrains, Vim, raw CLI, CI). Reference from your bundle file via `\"$schema\": \"https://raw.githubusercontent.com/dnegstad/devcontainer-dev-certs/main/schema/bundle.schema.json\"` to get autocomplete and validation in any editor that honors JSON Schema.", + "type": "object", + "additionalProperties": false, + "required": ["certs"], + "properties": { + "$schema": { + "type": "string", + "description": "Schema URL. Optional; present so editors can pick up validation without out-of-band configuration." + }, + "certs": { + "type": "array", + "description": "Certificates to install into the container's .NET X509Store and (when trustInContainer is true) the OpenSSL trust directory.", + "items": { "$ref": "#/$defs/cert" } + }, + "extraDestinations": { + "type": "array", + "description": "Additional directories to write cert artifacts to. Every cert in `certs` is written to every destination according to the destination's `format`. Useful for non-.NET workloads (nginx, Java keystores, Python requests bundles, etc.).", + "items": { "$ref": "#/$defs/extraDestination" } + } + }, + "$defs": { + "cert": { + "type": "object", + "additionalProperties": false, + "required": ["name", "thumbprint", "pemPath"], + "properties": { + "name": { + "type": "string", + "pattern": "^[A-Za-z0-9._-]{1,64}$", + "description": "Filename stem used for this cert in extra destinations and (for user certs) in the OpenSSL trust directory. Constrained to [A-Za-z0-9._-], 1-64 chars." + }, + "thumbprint": { + "type": "string", + "pattern": "^[A-Fa-f0-9]+$", + "description": "SHA-1 fingerprint of the certificate, hex-encoded with no separators. Used as the on-disk filename in the .NET X509Store (`{thumbprint}.pfx`) where Kestrel discovers it." + }, + "pemPath": { + "type": "string", + "description": "Absolute path to the certificate in PEM form. Required; used to compute the OpenSSL trust-dir filename and to synthesize the root-store PFX when `rootPfxPath` is omitted." + }, + "pfxPath": { + "type": "string", + "description": "Absolute path to a PFX (PKCS#12) containing the certificate and its private key. When present, copied verbatim to the .NET X509Store as `{thumbprint}.pfx`. Optional only for CA-only certs (no private key)." + }, + "pemKeyPath": { + "type": "string", + "description": "Absolute path to the private key in PEM form. Optional; used when writing `{name}-bundle.pem` or `{name}.key` to extra destinations." + }, + "rootPfxPath": { + "type": "string", + "description": "Absolute path to a public-cert-only PFX for installation into the .NET CurrentUser/Root store. Optional; synthesized via `openssl pkcs12 -nokeys` when omitted." + }, + "trustInContainer": { + "type": "boolean", + "default": true, + "description": "When true (default), install into the .NET CurrentUser/Root store and the OpenSSL trust directory in addition to CurrentUser/My. When false, only the My store is populated — useful when the cert is for serving only, not for trust." + }, + "kind": { + "type": "string", + "enum": ["dotnet-dev", "user"], + "default": "user", + "description": "`dotnet-dev` uses the historic `aspnetcore-localhost-{thumbprint}.pem` filename in the OpenSSL trust directory; `user` (default) uses `{name}.pem`." + } + } + }, + "extraDestination": { + "type": "object", + "additionalProperties": false, + "required": ["path"], + "properties": { + "path": { + "type": "string", + "description": "Absolute path of a directory to write cert artifacts into. Created if it does not exist." + }, + "format": { + "type": "string", + "enum": ["pem", "key", "pem-bundle", "pfx", "all"], + "default": "all", + "description": "Which artifact(s) to write per cert. `pem` = `{name}.pem` (cert only); `key` = `{name}.key`; `pem-bundle` = `{name}-bundle.pem` (cert+key); `pfx` = `{name}.pfx`; `all` = every applicable artifact." + } + } + } + } +} diff --git a/src/devcontainer-feature/src/devcontainer-dev-certs/devcontainer-feature.json b/src/devcontainer-feature/src/devcontainer-dev-certs/devcontainer-feature.json index 10b0bf6..64cd410 100644 --- a/src/devcontainer-feature/src/devcontainer-dev-certs/devcontainer-feature.json +++ b/src/devcontainer-feature/src/devcontainer-dev-certs/devcontainer-feature.json @@ -1,6 +1,6 @@ { "id": "devcontainer-dev-certs", - "version": "1.3.2-pre", + "version": "1.4.0-pre.1", "name": "Dev Container Development Certificates", "description": "Enables trusted HTTPS in Dev Containers by preparing certificate trust infrastructure and installing companion VS Code extensions that automatically generate, trust, and transfer ASP.NET and Aspire compatible development certificates from the host machine. Add to your devcontainer.json: \"features\": { \"ghcr.io/dnegstad/devcontainer-dev-certs/devcontainer-dev-certs:1\": {} }", "documentationURL": "https://github.com/dnegstad/devcontainer-dev-certs", @@ -48,6 +48,11 @@ "type": "string", "default": "", "description": "Comma-separated additional directories to write cert artifacts to. Each entry: [=] where format is pem|key|pem-bundle|pfx|all (defaults to all). Every synced cert is written under the directory as {name}.{pem,key,pfx} (and/or {name}-bundle.pem). Example: /etc/nginx/certs=pem,/var/myapp." + }, + "installFallbackTools": { + "type": "boolean", + "default": false, + "description": "Install the runtime prerequisites (openssl, jq) that the fallback `devcontainer-dev-certs-install` script needs. The script is always delivered to /usr/local/bin/ regardless of this option — set this to true only when you intend to invoke it manually (e.g. for JetBrains / Vim / CLI users) and your base image does not already provide openssl and jq." } }, "customizations": { diff --git a/src/devcontainer-feature/src/devcontainer-dev-certs/install.sh b/src/devcontainer-feature/src/devcontainer-dev-certs/install.sh index 1919231..5d479d4 100755 --- a/src/devcontainer-feature/src/devcontainer-dev-certs/install.sh +++ b/src/devcontainer-feature/src/devcontainer-dev-certs/install.sh @@ -18,6 +18,12 @@ GENERATE_DOTNET_CERT="${GENERATEDOTNETCERT:-true}" SYNC_USER_CERTIFICATES="${SYNCUSERCERTIFICATES:-true}" SYNC_CONTAINER_CERT="${SYNCCONTAINERCERT:-false}" EXTRA_CERT_DESTINATIONS="${EXTRACERTDESTINATIONS:-}" +INSTALL_FALLBACK_TOOLS="${INSTALLFALLBACKTOOLS:-false}" + +# Resolve our own source directory so the fallback script copy works +# regardless of where the devcontainer CLI mounts us. +FEATURE_SRC_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +FALLBACK_BIN_PATH="/usr/local/bin/devcontainer-dev-certs-install" REMOTE_USER="${_REMOTE_USER:-vscode}" REMOTE_USER_HOME="${_REMOTE_USER_HOME:-/home/${REMOTE_USER}}" @@ -55,7 +61,7 @@ fi # Validate that no feature option contains a newline. We append these to # /etc/environment, and an embedded newline would inject an extra env line # (potentially with a name the operator didn't intend). -for varname in TRUST_NSS SSL_CERT_DIRS PRUNE_MISSING_CERT_DIRS GENERATE_DOTNET_CERT SYNC_USER_CERTIFICATES SYNC_CONTAINER_CERT EXTRA_CERT_DESTINATIONS; do +for varname in TRUST_NSS SSL_CERT_DIRS PRUNE_MISSING_CERT_DIRS GENERATE_DOTNET_CERT SYNC_USER_CERTIFICATES SYNC_CONTAINER_CERT EXTRA_CERT_DESTINATIONS INSTALL_FALLBACK_TOOLS; do case "${!varname}" in *$'\n'*) echo "Error: feature option ${varname} must not contain newlines." >&2 @@ -107,6 +113,39 @@ if [ "${TRUST_NSS}" = "true" ]; then fi fi +# Install fallback-script prerequisites if requested. The script requires +# openssl unconditionally and jq for the --bundle-json form; install only +# what's missing so this is a no-op on images that already provide them. +if [ "${INSTALL_FALLBACK_TOOLS}" = "true" ]; then + declare -a FALLBACK_PKGS=() + command -v openssl &>/dev/null || FALLBACK_PKGS+=("openssl") + command -v jq &>/dev/null || FALLBACK_PKGS+=("jq") + if [ "${#FALLBACK_PKGS[@]}" -gt 0 ]; then + if command -v apt-get &>/dev/null; then + apt-get update -y + apt-get install -y --no-install-recommends "${FALLBACK_PKGS[@]}" + rm -rf /var/lib/apt/lists/* + elif command -v apk &>/dev/null; then + apk add --no-cache "${FALLBACK_PKGS[@]}" + elif command -v dnf &>/dev/null; then + dnf install -y "${FALLBACK_PKGS[@]}" + dnf clean all + else + echo "Warning: installFallbackTools=true but no supported package manager found; skipping." >&2 + fi + fi +fi + +# Deliver the fallback installer to a stable PATH location so non-VS Code +# consumers (JetBrains, Vim, raw CLI) have something to invoke. The script +# is small and inert at rest, so we always copy regardless of options — +# only its runtime prerequisites are gated by installFallbackTools above. +if [ -f "${FEATURE_SRC_DIR}/scripts/setup-cert.sh" ]; then + install -m 0755 "${FEATURE_SRC_DIR}/scripts/setup-cert.sh" "${FALLBACK_BIN_PATH}" +else + echo "Warning: scripts/setup-cert.sh not found under ${FEATURE_SRC_DIR}; fallback installer will not be available." >&2 +fi + # Create .NET X509Store CurrentUser\My directory # This is where Kestrel discovers dev certs via X509Store fallback DOTNET_STORE_DIR="${REMOTE_USER_HOME}/.dotnet/corefx/cryptography/x509stores/my" @@ -294,6 +333,19 @@ append_profile "DEVCONTAINER_DEV_CERTS_SYNC_USER" "${SYNC_USER_CERTIFICATES}" append_profile "DEVCONTAINER_DEV_CERTS_SYNC_FROM_CONTAINER" "${SYNC_CONTAINER_CERT}" append_profile "DEVCONTAINER_DEV_CERTS_EXTRA_DESTINATIONS" "${EXTRA_CERT_DESTINATIONS}" +# Path hints for non-VS Code consumers. These let a postStartCommand or an +# editor "external tool" config invoke the installer and locate the trust +# stores without hardcoding the canonical paths. We export the resolved +# per-user paths into /etc/environment (pam_env can't expand $HOME) and the +# $HOME-expanded form into profile.d so each user gets their own at login. +append_profile "DEVCONTAINER_DEV_CERTS_INSTALL_BIN" "${FALLBACK_BIN_PATH}" +# Profile.d entries that need per-user $HOME expansion at login time. +{ + echo "export DEVCONTAINER_DEV_CERTS_DOTNET_STORE_DIR=\"\$HOME/.dotnet/corefx/cryptography/x509stores/my\"" + echo "export DEVCONTAINER_DEV_CERTS_DOTNET_ROOT_STORE_DIR=\"\$HOME/.dotnet/corefx/cryptography/x509stores/root\"" + echo "export DEVCONTAINER_DEV_CERTS_TRUST_DIR=\"\$HOME/.aspnet/dev-certs/trust\"" +} >> "${PROFILE_SCRIPT}" + # Suppress dotnet's first-run HTTPS dev cert provisioning ONLY when the # host is the source. # @@ -346,6 +398,10 @@ append_env "DEVCONTAINER_DEV_CERTS_GENERATE_DOTNET" "${GENERATE_DOTNET_CERT}" append_env "DEVCONTAINER_DEV_CERTS_SYNC_USER" "${SYNC_USER_CERTIFICATES}" append_env "DEVCONTAINER_DEV_CERTS_SYNC_FROM_CONTAINER" "${SYNC_CONTAINER_CERT}" append_env "DEVCONTAINER_DEV_CERTS_EXTRA_DESTINATIONS" "${EXTRA_CERT_DESTINATIONS}" +append_env "DEVCONTAINER_DEV_CERTS_INSTALL_BIN" "${FALLBACK_BIN_PATH}" +append_env "DEVCONTAINER_DEV_CERTS_DOTNET_STORE_DIR" "${DOTNET_STORE_DIR}" +append_env "DEVCONTAINER_DEV_CERTS_DOTNET_ROOT_STORE_DIR" "${DOTNET_ROOT_STORE_DIR}" +append_env "DEVCONTAINER_DEV_CERTS_TRUST_DIR" "${TRUST_DIR}" # Mirror the dotnet-autogen suppression into /etc/environment so PAM-based # sessions (sshd, console) see the same gating logic as login shells. Same # conditional as the profile.d write above — see the comment there. @@ -436,9 +492,11 @@ echo " .NET cert store: ${DOTNET_STORE_DIR}" echo " .NET root store: ${DOTNET_ROOT_STORE_DIR}" echo " OpenSSL trust: ${TRUST_DIR}" echo " SSL_CERT_DIR: ${SSL_CERT_DIR_RESOLVED}" +echo " fallback installer: ${FALLBACK_BIN_PATH}" echo " generateDotNetCert: ${GENERATE_DOTNET_CERT}" echo " syncUserCertificates: ${SYNC_USER_CERTIFICATES}" echo " syncContainerCert: ${SYNC_CONTAINER_CERT}" +echo " installFallbackTools: ${INSTALL_FALLBACK_TOOLS}" if [ "${SUPPRESS_DOTNET_AUTOGEN}" = "true" ]; then echo " DOTNET_GENERATE_ASPNET_CERTIFICATE: false (host generates the dev cert — suppressing dotnet's racing first-run auto-gen)" else diff --git a/src/devcontainer-feature/src/devcontainer-dev-certs/scripts/setup-cert.sh b/src/devcontainer-feature/src/devcontainer-dev-certs/scripts/setup-cert.sh index 15b7196..bd7425c 100755 --- a/src/devcontainer-feature/src/devcontainer-dev-certs/scripts/setup-cert.sh +++ b/src/devcontainer-feature/src/devcontainer-dev-certs/scripts/setup-cert.sh @@ -5,6 +5,7 @@ # Usage: # setup-cert.sh # setup-cert.sh --bundle-json +# setup-cert.sh --doctor # # Bundle JSON form (accepts one or more certs plus optional extra destinations): # { @@ -25,17 +26,29 @@ # ] # } # +# Doctor mode runs read-only checks over the trust infrastructure (directories, +# env vars, cert presence, hash symlinks) and reports findings. Exits non-zero +# only when something is actually broken (missing prereqs, missing or +# unwritable trust dirs, expired managed certs). +# # This script requires openssl to be installed for hash computation and (in # the bundle form) PFX/root-PFX conversion. The bundle form additionally # requires `jq` for JSON parsing. set -e REMOTE_USER="${_REMOTE_USER:-vscode}" -REMOTE_USER_HOME="${_REMOTE_USER_HOME:-/home/${REMOTE_USER}}" - -DOTNET_STORE_DIR="${REMOTE_USER_HOME}/.dotnet/corefx/cryptography/x509stores/my" -DOTNET_ROOT_STORE_DIR="${REMOTE_USER_HOME}/.dotnet/corefx/cryptography/x509stores/root" -TRUST_DIR="${REMOTE_USER_HOME}/.aspnet/dev-certs/trust" +REMOTE_USER_HOME="${_REMOTE_USER_HOME:-${HOME:-/home/${REMOTE_USER}}}" + +# Honor the DEVCONTAINER_DEV_CERTS_*_DIR vars exported by install.sh +# (via /etc/profile.d and /etc/environment) so non-VS-Code users — +# whose shell session doesn't set _REMOTE_USER — get the same paths +# install.sh chowned at feature-build time. Falling back to the +# REMOTE_USER_HOME computation keeps the legacy path working when +# the env vars aren't present (e.g. running this script directly +# without a login shell or PAM session). +DOTNET_STORE_DIR="${DEVCONTAINER_DEV_CERTS_DOTNET_STORE_DIR:-${REMOTE_USER_HOME}/.dotnet/corefx/cryptography/x509stores/my}" +DOTNET_ROOT_STORE_DIR="${DEVCONTAINER_DEV_CERTS_DOTNET_ROOT_STORE_DIR:-${REMOTE_USER_HOME}/.dotnet/corefx/cryptography/x509stores/root}" +TRUST_DIR="${DEVCONTAINER_DEV_CERTS_TRUST_DIR:-${REMOTE_USER_HOME}/.aspnet/dev-certs/trust}" ensure_openssl() { if ! command -v openssl &>/dev/null; then @@ -156,6 +169,181 @@ write_extra_destination() { esac } +# --- Doctor mode: read-only diagnostics --- +if [ "${1:-}" = "--doctor" ]; then + doctor_errors=0 + doctor_warnings=0 + + # Output helpers. The tags double as a grep-able stable interface for + # tooling that wants to parse this — keep them short and stable. + ok() { printf ' [ok] %s\n' "$*"; } + warn() { printf ' [warn] %s\n' "$*"; doctor_warnings=$((doctor_warnings + 1)); } + fail() { printf ' [fail] %s\n' "$*"; doctor_errors=$((doctor_errors + 1)); } + info() { printf ' [info] %s\n' "$*"; } + section() { printf '\n%s\n' "$*"; } + + section "Prerequisites:" + if command -v openssl &>/dev/null; then + ok "openssl present ($(openssl version 2>/dev/null | head -1))" + else + fail "openssl missing — required for hash symlinks and root-PFX synthesis" + fi + if command -v jq &>/dev/null; then + ok "jq present ($(jq --version 2>/dev/null))" + else + warn "jq missing — needed only for --bundle-json mode; install if you use bundle invocations" + fi + + # Helper: check a directory exists and is writable by the current user. + # Doctor runs as whoever invoked it (typically the remote user, not root), + # which is the same identity that will later need to write certs here. + check_dir() { + local label="$1" + local dir="$2" + if [ ! -d "${dir}" ]; then + fail "${label}: ${dir} does not exist" + return + fi + if [ ! -w "${dir}" ]; then + fail "${label}: ${dir} exists but is not writable by $(id -un)" + return + fi + ok "${label}: ${dir}" + } + + section "Trust directories:" + check_dir ".NET CurrentUser/My " "${DOTNET_STORE_DIR}" + check_dir ".NET CurrentUser/Root" "${DOTNET_ROOT_STORE_DIR}" + check_dir "OpenSSL trust " "${TRUST_DIR}" + + section "Environment:" + if [ -n "${SSL_CERT_DIR:-}" ]; then + case ":${SSL_CERT_DIR}:" in + *:"${TRUST_DIR}":*) ok "SSL_CERT_DIR includes ${TRUST_DIR}" ;; + *) warn "SSL_CERT_DIR is set but does NOT include ${TRUST_DIR} — OpenSSL clients (curl, wget) won't trust the dev cert" ;; + esac + info "SSL_CERT_DIR=${SSL_CERT_DIR}" + else + warn "SSL_CERT_DIR is unset — log out / log back in to source /etc/profile.d, or export it manually" + fi + + case "${DOTNET_GENERATE_ASPNET_CERTIFICATE:-}" in + false) ok "DOTNET_GENERATE_ASPNET_CERTIFICATE=false (suppresses dotnet's first-run cert race)" ;; + "") info "DOTNET_GENERATE_ASPNET_CERTIFICATE unset — expected when syncContainerCert=true or generateDotNetCert=false; otherwise dotnet's first-run auto-gen may race the managed install" ;; + *) info "DOTNET_GENERATE_ASPNET_CERTIFICATE=${DOTNET_GENERATE_ASPNET_CERTIFICATE}" ;; + esac + + # Helper: describe a .pfx — subject + notAfter + fingerprint. Empty + # passphrase is assumed (matches the on-disk posture for the .NET + # X509Store on Linux). Returns 1 if the file can't be parsed. + describe_pfx() { + local path="$1" + local pem + pem=$(openssl pkcs12 -in "${path}" -nokeys -passin pass: 2>/dev/null) || return 1 + local subject not_after fp + subject=$(printf '%s' "${pem}" | openssl x509 -noout -subject 2>/dev/null | sed 's/^subject= *//') + not_after=$(printf '%s' "${pem}" | openssl x509 -noout -enddate 2>/dev/null | sed 's/^notAfter=//') + # OpenSSL 1.x prints `SHA1 Fingerprint=...`, OpenSSL 3.x prints + # `sha1 Fingerprint=...`. Strip up to and including the `=` to + # cover both spellings. + fp=$(printf '%s' "${pem}" | openssl x509 -noout -fingerprint -sha1 2>/dev/null | sed 's/^[^=]*=//' | tr -d ':') + printf '%s | notAfter=%s | sha1=%s' "${subject}" "${not_after}" "${fp}" + } + + # Helper: 0 if the cert PEM's notAfter is in the past, 1 otherwise. + pfx_is_expired() { + local path="$1" + local pem + pem=$(openssl pkcs12 -in "${path}" -nokeys -passin pass: 2>/dev/null) || return 1 + # checkend exits 0 when cert is still valid for at least N seconds, + # 1 when expired. Invert so this function returns 0 (true) when + # the cert IS expired. + ! printf '%s' "${pem}" | openssl x509 -noout -checkend 0 &>/dev/null + } + + section ".NET CurrentUser/My contents:" + if command -v openssl &>/dev/null && [ -d "${DOTNET_STORE_DIR}" ]; then + my_count=0 + # Glob pattern may yield the literal pattern when no matches; guard. + for pfx in "${DOTNET_STORE_DIR}"/*.pfx; do + [ -f "${pfx}" ] || continue + my_count=$((my_count + 1)) + desc=$(describe_pfx "${pfx}" 2>/dev/null) || desc="(unparseable PFX)" + info "$(basename "${pfx}"): ${desc}" + if pfx_is_expired "${pfx}"; then + fail "$(basename "${pfx}") has expired — Kestrel will not serve HTTPS with this cert" + fi + done + if [ "${my_count}" -eq 0 ]; then + warn "no .pfx files present — Kestrel's X509Store fallback will find nothing" + elif [ "${my_count}" -gt 1 ]; then + warn "${my_count} .pfx files present — multiple dev certs in this store cause nondeterministic Kestrel selection (run the host extension's 'clean up other dev certificates' command, or remove manually)" + fi + fi + + section ".NET CurrentUser/Root contents:" + if command -v openssl &>/dev/null && [ -d "${DOTNET_ROOT_STORE_DIR}" ]; then + root_count=0 + for pfx in "${DOTNET_ROOT_STORE_DIR}"/*.pfx; do + [ -f "${pfx}" ] || continue + root_count=$((root_count + 1)) + desc=$(describe_pfx "${pfx}" 2>/dev/null) || desc="(unparseable PFX)" + info "$(basename "${pfx}"): ${desc}" + done + if [ "${root_count}" -eq 0 ]; then + info "no .pfx files present — .NET will not consider any of My's certs as locally trusted" + fi + fi + + section "OpenSSL trust directory contents:" + if [ -d "${TRUST_DIR}" ]; then + pem_count=0 + for pem in "${TRUST_DIR}"/*.pem; do + [ -f "${pem}" ] || continue + pem_count=$((pem_count + 1)) + pem_name=$(basename "${pem}") + if command -v openssl &>/dev/null; then + hash=$(openssl x509 -hash -noout -in "${pem}" 2>/dev/null) + if [ -n "${hash}" ]; then + # Look for at least one symlink "${hash}.N" that resolves to this PEM. + found_link="" + for i in 0 1 2 3 4 5 6 7 8 9; do + link="${TRUST_DIR}/${hash}.${i}" + if [ -L "${link}" ] && [ "$(readlink "${link}")" = "${pem_name}" ]; then + found_link="${link}" + break + fi + done + if [ -n "${found_link}" ]; then + info "${pem_name} → $(basename "${found_link}") (hash ${hash})" + else + fail "${pem_name}: no c_rehash symlink (${hash}.N) pointing back — OpenSSL won't find this cert; re-run the installer or 'openssl rehash ${TRUST_DIR}'" + fi + else + fail "${pem_name}: openssl could not compute a subject hash" + fi + else + info "${pem_name}" + fi + done + if [ "${pem_count}" -eq 0 ]; then + warn "no .pem files in ${TRUST_DIR} — OpenSSL-based tools (curl, wget) won't trust any dev cert" + fi + fi + + section "Summary:" + if [ "${doctor_errors}" -gt 0 ]; then + printf ' %d error(s), %d warning(s).\n' "${doctor_errors}" "${doctor_warnings}" + exit 1 + fi + if [ "${doctor_warnings}" -gt 0 ]; then + printf ' 0 errors, %d warning(s) — review above before relying on this setup.\n' "${doctor_warnings}" + else + printf ' All checks passed.\n' + fi + exit 0 +fi + # --- Bundle JSON form --- if [ "${1:-}" = "--bundle-json" ]; then BUNDLE="${2:?Usage: setup-cert.sh --bundle-json }" diff --git a/src/shared/package.json b/src/shared/package.json index c7a3c93..5a08352 100644 --- a/src/shared/package.json +++ b/src/shared/package.json @@ -1,6 +1,6 @@ { "name": "@devcontainer-dev-certs/shared", - "version": "1.3.2-pre", + "version": "1.4.0-pre.1", "private": true, "main": "./src/index.ts", "types": "./src/index.ts", diff --git a/src/shared/src/backends/dotnet.ts b/src/shared/src/backends/dotnet.ts new file mode 100644 index 0000000..282a337 --- /dev/null +++ b/src/shared/src/backends/dotnet.ts @@ -0,0 +1,102 @@ +import * as fs from "fs"; +import { exportPem, exportPfx } from "../cert/exporter"; +import { trustInNss } from "../platform/nssTrust"; +import { runProcess } from "../platform/processUtil"; +import { + createPlatformStore, + type LinuxNssTrustReporter, +} from "../platform/types"; +import type { Backend, GenerateOptions, GenerateResult } from "./types"; + +/** + * Dotnet backend: shells out to `dotnet dev-certs https` for the + * generate-and-trust step on macOS — where the dotnet binary is + * Apple-notarized and produces a cleaner keychain prompt than our + * `security add-trusted-cert` invocation does — and then re-exports + * the resulting cert from the platform store using our own primitives. + * + * Why we don't use dotnet's `--export-path`: + * + * - `--no-password` is PEM-only in every currently-shipping .NET SDK + * (6/7/8/9). PFX export requires a password, which we'd then have + * to strip locally to match our passwordless convention — at which + * point we're already loading and re-exporting, so we may as well + * skip the broken flag combination. + * - `--format PEM --export-path foo.pem` writes `foo.pem` + `foo.pem.key`, + * not `foo.pem` + `foo.key`. The latter is what our `bundle.ts` / + * `inspect.ts` sibling-discovery probes for, what the in-container + * installer expects, and what the native backend produces. Going + * through our own exporters keeps naming uniform across backends. + * + * So the flow is: + * 1. `dotnet dev-certs https [--trust]` — generates (if absent) and + * trusts. No file export. + * 2. `findExistingDevCert()` against the platform store — same path + * the rest of the codebase uses to discover dotnet-installed certs. + * 3. `exportPfx` + `exportPem` — write the files to outDir under our + * conventional names with our conventional permissions. + * + * On Linux, `dotnet dev-certs --trust` only populates the OpenSSL trust + * dir and the .NET Root store; it doesn't touch the NSS DBs that + * Firefox / Chromium read. We supplement that with our own `trustInNss` + * step so the dotnet backend's trust outcome matches the native + * backend's on Linux. + */ +export class DotnetBackend implements Backend { + readonly kind = "dotnet" as const; + + async isAvailable(): Promise { + const result = await runProcess("dotnet", ["--version"], 5000); + return result.exitCode === 0; + } + + async generate(options: GenerateOptions): Promise { + fs.mkdirSync(options.outDir, { recursive: true }); + + const args = ["dev-certs", "https"]; + if (!options.noTrust) args.push("--trust"); + + const result = await runProcess("dotnet", args, 60_000); + if (result.exitCode !== 0) { + throw new Error( + `dotnet dev-certs failed (exit ${result.exitCode}): ${result.stderr || result.stdout}` + ); + } + + const store = await createPlatformStore(); + const found = await store.findExistingDevCert(); + if (!found) { + throw new Error( + "dotnet dev-certs completed but no dev cert was found in the platform store afterwards." + ); + } + + const pfxPath = await exportPfx(found.cert, found.key, options.outDir); + const { certPath: pemPath, keyPath: pemKeyPath } = exportPem( + found.cert, + found.key, + options.outDir + ); + + if (!options.noTrust && process.platform === "linux") { + await runNssTrust(pemPath, options.linuxNssTrustReporter); + } + + return { + pfxPath, + pemPath, + pemKeyPath, + thumbprint: found.thumbprint, + trusted: !options.noTrust, + backendUsed: "dotnet", + }; + } +} + +async function runNssTrust( + pemPath: string, + reporter: LinuxNssTrustReporter | undefined +): Promise { + const result = await trustInNss(pemPath); + reporter?.(result, pemPath); +} diff --git a/src/shared/src/backends/native.ts b/src/shared/src/backends/native.ts new file mode 100644 index 0000000..7bad5ca --- /dev/null +++ b/src/shared/src/backends/native.ts @@ -0,0 +1,104 @@ +import * as fs from "fs"; +import * as path from "path"; +import { exportPem, exportPfx } from "../cert/exporter"; +import { generateCertificate } from "../cert/generator"; +import { CertManager } from "../cert/manager"; +import { VALIDITY_DAYS } from "../cert/properties"; +import type { LinuxNssTrustReporter } from "../platform/types"; +import type { Backend, GenerateOptions, GenerateResult } from "./types"; + +/** + * Native backend: uses the in-tree cert primitives directly — no + * shelling out to other tools, no `dotnet` runtime required. + * + * Two code paths, picked by `noTrust`: + * + * - `noTrust: false` (default): drive `CertManager` end-to-end. The + * generated cert lands in the host's OS platform store + * (`~/.dotnet/corefx/cryptography/x509stores/my/` on Linux/macOS, + * `CurrentUser\My` on Windows) and is added to the OS trust store. + * Living in the .NET store is part of the host-trust contract — it's + * where `dotnet dev-certs --check`, host-running Kestrel, and the + * VS Code host extension all look — so writing there is the point of + * the operation, not a side effect. + * + * - `noTrust: true`: generate purely in memory and write only to + * `outDir`. The platform store is NOT touched. Reserved for callers + * that have explicitly opted out of host-side cert installation — we + * honor that by keeping our side effects bounded to `outDir`. + */ +export class NativeBackend implements Backend { + readonly kind = "native" as const; + + isAvailable(): Promise { + // Always available — the shared layer ships implementations for all + // three supported platforms and the cert primitives themselves have + // no external runtime dependencies. + return Promise.resolve(true); + } + + async generate(options: GenerateOptions): Promise { + fs.mkdirSync(options.outDir, { recursive: true }); + + if (options.noTrust) { + return generateFilesOnly(options.outDir); + } + return generateAndTrust(options.outDir, options.linuxNssTrustReporter); + } +} + +async function generateFilesOnly(outDir: string): Promise { + const now = new Date(); + const expiry = new Date(now.getTime() + VALIDITY_DAYS * 86400_000); + const { cert, key, thumbprint } = await generateCertificate(now, expiry); + + const pfxPath = await exportPfx(cert, key, outDir); + const { certPath: pemPath, keyPath: pemKeyPath } = exportPem( + cert, + key, + outDir + ); + + return { + pfxPath, + pemPath, + pemKeyPath, + thumbprint, + trusted: false, + backendUsed: "native", + }; +} + +async function generateAndTrust( + outDir: string, + linuxNssTrustReporter: LinuxNssTrustReporter | undefined +): Promise { + // Without a reporter the Linux NSS trust step would still run but its + // outcome would be discarded. CertProvider in the host extension wires + // a toast reporter so the user sees NSS trust failures. + const manager = new CertManager({ linuxNssTrustReporter }); + await manager.trust(); + await manager.exportCert("pfx", outDir); + await manager.exportCert("pem", outDir); + + // `manager.trust()` guarantees the cert is present in the platform + // store, so a follow-up `check()` reads the thumbprint without + // touching disk again. (Previously this re-parsed the exported PFX — + // a holdover from when the dotnet backend did the same dance; the + // dotnet rework no longer reparses, so this can read its own state.) + const status = await manager.check(); + if (!status.thumbprint) { + throw new Error( + "Native backend trust() succeeded but check() reports no thumbprint." + ); + } + + return { + pfxPath: path.join(outDir, "aspnetcore-dev.pfx"), + pemPath: path.join(outDir, "aspnetcore-dev.pem"), + pemKeyPath: path.join(outDir, "aspnetcore-dev.key"), + thumbprint: status.thumbprint, + trusted: true, + backendUsed: "native", + }; +} diff --git a/src/shared/src/backends/select.ts b/src/shared/src/backends/select.ts new file mode 100644 index 0000000..3c90f13 --- /dev/null +++ b/src/shared/src/backends/select.ts @@ -0,0 +1,48 @@ +import { DotnetBackend } from "./dotnet"; +import { NativeBackend } from "./native"; +import type { Backend, BackendKind, BackendMode } from "./types"; + +/** + * Resolve a `hostCertGenerator` choice (possibly `auto`) into a concrete + * backend instance. `auto` prefers `dotnet` on macOS when the `dotnet` CLI + * is on PATH (better keychain-trust UX via a signed binary), `native` + * everywhere else. + */ +export async function selectBackend(mode: BackendMode): Promise { + if (mode === "native") return new NativeBackend(); + if (mode === "dotnet") { + const backend = new DotnetBackend(); + if (!(await backend.isAvailable())) { + throw new Error( + "Requested hostCertGenerator=dotnet but the `dotnet` CLI was not found on PATH." + ); + } + return backend; + } + if (mode === "auto") return autoSelect(); + throw new Error(`Unknown backend mode: ${String(mode)}`); +} + +async function autoSelect(): Promise { + if (process.platform === "darwin") { + const dotnet = new DotnetBackend(); + if (await dotnet.isAvailable()) return dotnet; + } + return new NativeBackend(); +} + +/** + * Report which backend `auto` would pick on this host without actually + * constructing it. Useful for status surfaces in the VS Code host extension. + * + * Callers that have already probed dotnet can pass the result via + * `dotnetAvailable` to avoid a second `dotnet --version` spawn. + */ +export async function describeAutoBackend( + dotnetAvailable?: boolean +): Promise { + if (process.platform !== "darwin") return "native"; + const available = + dotnetAvailable ?? (await new DotnetBackend().isAvailable()); + return available ? "dotnet" : "native"; +} diff --git a/src/shared/src/backends/types.ts b/src/shared/src/backends/types.ts new file mode 100644 index 0000000..73fd0c5 --- /dev/null +++ b/src/shared/src/backends/types.ts @@ -0,0 +1,65 @@ +/** + * Backend abstraction for the VS Code host extension's `hostCertGenerator` + * setting. Lets the extension pick between equivalent generators — the + * bundled-in native cert primitives or the `dotnet dev-certs https` CLI + * pass-through — without the call site reimplementing the selection / + * availability-detection logic. + * + * The interface is deliberately narrow: each backend exposes + * `isAvailable()` and `generate()`. Trust is bundled into `generate()` so + * backends like `dotnet` (which combines generate + trust into a single + * shell invocation) don't need a separate trust hook. + */ + +export type BackendKind = "native" | "dotnet"; + +export type BackendMode = BackendKind | "auto"; + +import type { LinuxNssTrustReporter } from "../platform/types"; + +export interface GenerateOptions { + /** Directory that receives PFX / PEM / key artifacts. */ + outDir: string; + /** Skip the host trust step. PFX / PEM are still emitted. */ + noTrust: boolean; + /** + * Optional callback invoked after the Linux NSS browser-trust step + * with success/failure status and the PEM path. No-op on other + * platforms. The native backend forwards it to `CertManager`; the + * dotnet backend invokes `trustInNss` directly with it (because + * `dotnet dev-certs --trust` itself doesn't touch the NSS DBs that + * Firefox / Chromium read). Without a reporter, NSS failures are + * silent — surfacing them in a VS Code toast is the consumer's job. + */ + linuxNssTrustReporter?: LinuxNssTrustReporter; +} + +export interface GenerateResult { + /** Absolute host path of the PFX. */ + pfxPath: string; + /** Absolute host path of the PEM cert. */ + pemPath: string; + /** Absolute host path of the PEM key (null when backend didn't emit one). */ + pemKeyPath: string | null; + /** SHA-1 thumbprint, uppercase hex. */ + thumbprint: string; + /** Whether the host trust step ran and succeeded. */ + trusted: boolean; + /** + * Which backend actually produced the cert — meaningful when the caller + * selected `auto` and wants to know which concrete kind was picked. + */ + backendUsed: BackendKind; +} + +/** + * Backends implement generation (+ optional trust) and a platform-availability + * probe. `auto` selection asks each candidate whether it's available and + * picks per-platform preference. + */ +export interface Backend { + readonly kind: BackendKind; + /** Is this backend usable on the current host? */ + isAvailable(): Promise; + generate(options: GenerateOptions): Promise; +} diff --git a/src/shared/src/cert/exporter.ts b/src/shared/src/cert/exporter.ts new file mode 100644 index 0000000..0a4a432 --- /dev/null +++ b/src/shared/src/cert/exporter.ts @@ -0,0 +1,134 @@ +import * as fs from "fs"; +import * as path from "path"; +import type { LoadedCert } from "./loader"; +import { type DevCert, type DevKey } from "./types"; +import { ASPNET_HTTPS_OID_FRIENDLY_NAME } from "./properties"; +import { buildPfx } from "./pfx"; + +// PFX and unencrypted key PEM contain private key material. Use mode 0o600 on +// every write — the temp dir created upstream is already mkdtemp'd 0o700, but +// explicit file modes survive being copied or extracted elsewhere. +const PRIVATE_FILE_MODE = 0o600; +const PUBLIC_FILE_MODE = 0o644; + +/** + * Export a certificate with its private key as a PFX/PKCS12 file. + */ +export async function exportPfx( + cert: DevCert, + key: DevKey, + outputDir: string, + password?: string +): Promise { + fs.mkdirSync(outputDir, { recursive: true }); + const der = await buildPfx({ + cert, + key, + password, + friendlyName: ASPNET_HTTPS_OID_FRIENDLY_NAME, + }); + const outPath = path.join(outputDir, "aspnetcore-dev.pfx"); + fs.writeFileSync(outPath, der, { mode: PRIVATE_FILE_MODE }); + return outPath; +} + +/** + * Export a certificate and private key as PEM files. + * Returns { certPath, keyPath }. + */ +export function exportPem( + cert: DevCert, + key: DevKey, + outputDir: string +): { certPath: string; keyPath: string } { + fs.mkdirSync(outputDir, { recursive: true }); + + const certPath = path.join(outputDir, "aspnetcore-dev.pem"); + const keyPath = path.join(outputDir, "aspnetcore-dev.key"); + + fs.writeFileSync(certPath, cert.pem, { mode: PUBLIC_FILE_MODE }); + fs.writeFileSync(keyPath, key.pem, { mode: PRIVATE_FILE_MODE }); + + return { certPath, keyPath }; +} + +/** + * Convert a certificate to PEM format string. + */ +export function certToPem(cert: DevCert): string { + return cert.pem; +} + +/** + * Convert a private key to PEM format string (PKCS#8 unencrypted). + */ +export function keyToPem(key: DevKey): string { + return key.pem; +} + +/** + * Export certificate as DER-encoded bytes (public cert only, no private key). + */ +export function certToDer(cert: DevCert): Buffer { + return cert.der; +} + +/** + * Export a public-cert-only PFX for the .NET Root store. + * This matches what `dotnet dev-certs https --trust` writes to + * ~/.dotnet/corefx/cryptography/x509stores/root/ on Linux. + */ +export async function exportRootPfx( + cert: DevCert, + outputDir: string +): Promise { + fs.mkdirSync(outputDir, { recursive: true }); + const der = await buildPfx({ cert }); + const outPath = path.join(outputDir, "aspnetcore-dev-root.pfx"); + fs.writeFileSync(outPath, der, { mode: PUBLIC_FILE_MODE }); + return outPath; +} + +export interface ExportedLoadedCert { + pemCertPath: string; + pemKeyPath: string | null; + rootPfxPath: string | null; +} + +/** + * Export a user-managed (or generically loaded) certificate's PEM artifacts + * to a directory under a stable `{name}.*` filename scheme. The cert is + * always written; the key is only written when a key is attached; the + * public-cert-only root PFX is only produced when `includeRootPfx` is true. + * + * Notably this does NOT synthesize a key-bearing `{name}.pfx`. That decision + * lives with the host orchestrator, where the user's pfxPassword is in + * scope — silently encoding a passwordless PFX here would strip the user's + * password without their consent. + */ +export async function exportLoadedCert( + loaded: LoadedCert, + name: string, + outputDir: string, + options: { includeRootPfx?: boolean } = {} +): Promise { + fs.mkdirSync(outputDir, { recursive: true }); + + const pemCertPath = path.join(outputDir, `${name}.pem`); + fs.writeFileSync(pemCertPath, loaded.cert.pem, { mode: PUBLIC_FILE_MODE }); + + let pemKeyPath: string | null = null; + if (loaded.key) { + pemKeyPath = path.join(outputDir, `${name}.key`); + fs.writeFileSync(pemKeyPath, loaded.key.pem, { mode: PRIVATE_FILE_MODE }); + } + + let rootPfxPath: string | null = null; + if (options.includeRootPfx) { + const rootBytes = await buildPfx({ cert: loaded.cert }); + rootPfxPath = path.join(outputDir, `${name}-root.pfx`); + fs.writeFileSync(rootPfxPath, rootBytes, { mode: PUBLIC_FILE_MODE }); + } + + return { pemCertPath, pemKeyPath, rootPfxPath }; +} diff --git a/src/shared/src/cert/generator.ts b/src/shared/src/cert/generator.ts new file mode 100644 index 0000000..a10bc17 --- /dev/null +++ b/src/shared/src/cert/generator.ts @@ -0,0 +1,223 @@ +import { + AuthorityKeyIdentifierExtension, + BasicConstraintsExtension, + ExtendedKeyUsage, + ExtendedKeyUsageExtension, + Extension, + KeyUsageFlags, + KeyUsagesExtension, + SubjectAlternativeNameExtension, + SubjectKeyIdentifierExtension, + X509CertificateGenerator, + cryptoProvider, +} from "@peculiar/x509"; +import { randomBytes, webcrypto } from "node:crypto"; +import { DevCert, DevKey } from "./types"; +import { + ASPNET_HTTPS_OID, + CURRENT_CERTIFICATE_VERSION, + RSA_KEY_SIZE, + SAN_DNS_NAMES, + SAN_IP_ADDRESSES, +} from "./properties"; + +let cryptoProviderConfigured = false; +function ensureCryptoProvider(): void { + if (cryptoProviderConfigured) return; + cryptoProvider.set(webcrypto as unknown as Crypto); + cryptoProviderConfigured = true; +} + +export interface GeneratedCert { + cert: DevCert; + key: DevKey; + /** + * SHA-1 thumbprint, uppercase hex. This is the .NET-compatible + * `X509Certificate2.Thumbprint` value used as the X509Store filename + * (`{thumbprint}.pfx`). For a stronger cert identifier, use + * `cert.thumbprint` (SHA-256). + */ + thumbprint: string; +} + +/** + * Algorithm choices for `generateCertificate`. Defaults to RSA-2048 to match + * the historical ASP.NET dev cert format, but the same code path supports + * ECDSA P-256/P-384/P-521 and Ed25519 for user-managed flows. + */ +export type GenerateAlgorithm = + | { kind: "rsa"; modulusLength?: number } + | { kind: "ec"; namedCurve: "P-256" | "P-384" | "P-521" } + | { kind: "ed25519" } + | { kind: "ed448" }; + +/** + * Generate a self-signed certificate matching the ASP.NET Core HTTPS dev + * cert format (subject, validity, SANs, custom OID, SKI/AKI). + * + * The default algorithm is RSA-2048 with SHA-256 signing — a byte-for-byte + * compatible replacement for the previous `node-forge` path. Pass an + * `algorithm` to opt into ECDSA / Ed25519 (used by user-managed cert flows + * that need to round-trip non-RSA keys). + */ +export async function generateCertificate( + notBefore: Date, + notAfter: Date, + algorithm: GenerateAlgorithm = { kind: "rsa" } +): Promise { + ensureCryptoProvider(); + + const { keyPair, signingAlgorithm } = await generateKeyPair(algorithm); + + const serialNumber = generateSerialNumber(); + const subject = "CN=localhost"; + const issuer = subject; + + const sanEntries = [ + ...SAN_DNS_NAMES.map( + (dns) => ({ type: "dns" as const, value: dns }) + ), + ...SAN_IP_ADDRESSES.map( + (ip) => ({ type: "ip" as const, value: ip }) + ), + ]; + + const extensions: Extension[] = [ + new BasicConstraintsExtension(false, undefined, true), + new KeyUsagesExtension( + KeyUsageFlags.digitalSignature | KeyUsageFlags.keyEncipherment, + true + ), + new ExtendedKeyUsageExtension([ExtendedKeyUsage.serverAuth], true), + new SubjectAlternativeNameExtension(sanEntries, true), + new Extension( + ASPNET_HTTPS_OID, + false, + new Uint8Array([CURRENT_CERTIFICATE_VERSION]).buffer + ), + await SubjectKeyIdentifierExtension.create(keyPair.publicKey), + await AuthorityKeyIdentifierExtension.create(keyPair.publicKey), + ]; + + const cert = await X509CertificateGenerator.create({ + serialNumber, + subject, + issuer, + notBefore, + notAfter, + signingAlgorithm, + publicKey: keyPair.publicKey, + signingKey: keyPair.privateKey, + extensions, + }); + + const devCert = new DevCert(cert); + const devKey = await DevKey.fromCryptoKey(keyPair.privateKey); + + return { + cert: devCert, + key: devKey, + thumbprint: devCert.thumbprintSha1, + }; +} + +async function generateKeyPair( + algorithm: GenerateAlgorithm +): Promise<{ + keyPair: CryptoKeyPair; + signingAlgorithm: Algorithm | RsaHashedKeyGenParams | EcdsaParams; +}> { + const subtle = webcrypto.subtle; + + if (algorithm.kind === "rsa") { + const modulusLength = algorithm.modulusLength ?? RSA_KEY_SIZE; + const params: RsaHashedKeyGenParams = { + name: "RSASSA-PKCS1-v1_5", + modulusLength, + publicExponent: new Uint8Array([1, 0, 1]), + hash: "SHA-256", + }; + const keyPair = (await subtle.generateKey(params, true, [ + "sign", + "verify", + ])); + return { + keyPair, + signingAlgorithm: { + name: "RSASSA-PKCS1-v1_5", + hash: "SHA-256", + } as RsaHashedKeyGenParams, + }; + } + + if (algorithm.kind === "ec") { + const params: EcKeyGenParams = { + name: "ECDSA", + namedCurve: algorithm.namedCurve, + }; + const keyPair = (await subtle.generateKey(params, true, [ + "sign", + "verify", + ])); + return { + keyPair, + signingAlgorithm: { + name: "ECDSA", + hash: defaultEcHash(algorithm.namedCurve), + } as EcdsaParams, + }; + } + + if (algorithm.kind === "ed25519") { + const keyPair = (await subtle.generateKey( + "Ed25519", + true, + ["sign", "verify"] + )) as CryptoKeyPair; + return { + keyPair, + signingAlgorithm: { name: "Ed25519" }, + }; + } + + if (algorithm.kind === "ed448") { + const keyPair = (await subtle.generateKey( + { name: "Ed448" }, + true, + ["sign", "verify"] + )) as CryptoKeyPair; + return { + keyPair, + signingAlgorithm: { name: "Ed448" }, + }; + } + + throw new Error( + `Unsupported algorithm: ${(algorithm as { kind: string }).kind}` + ); +} + +function defaultEcHash(curve: string): string { + switch (curve) { + case "P-256": + return "SHA-256"; + case "P-384": + return "SHA-384"; + case "P-521": + return "SHA-512"; + default: + return "SHA-256"; + } +} + +function generateSerialNumber(): string { + const maxAttempts = 5; + for (let attempt = 0; attempt < maxAttempts; attempt++) { + const bytes = randomBytes(16); + bytes[0] &= 0x7f; // ensure non-negative + if (bytes.some((value) => value !== 0)) { + return bytes.toString("hex"); + } + } + throw new Error("Failed to generate a non-zero certificate serial number."); +} diff --git a/src/shared/src/cert/loader.ts b/src/shared/src/cert/loader.ts index d72d644..8ccaa03 100644 --- a/src/shared/src/cert/loader.ts +++ b/src/shared/src/cert/loader.ts @@ -55,3 +55,4 @@ export function loadPemPair( return buildLoadedCert(cert, key); } + diff --git a/src/shared/src/cert/manager.ts b/src/shared/src/cert/manager.ts new file mode 100644 index 0000000..1a428b9 --- /dev/null +++ b/src/shared/src/cert/manager.ts @@ -0,0 +1,217 @@ +import { generateCertificate, type GeneratedCert } from "./generator"; +import { exportPfx, exportPem, exportRootPfx } from "./exporter"; +import { VALIDITY_DAYS } from "./properties"; +import { + type PlatformCertificateStore, + type CertificateStatus, + type LinuxNssTrustReporter, + createPlatformStore, +} from "../platform/types"; +import { log } from "../logger"; +import { type Localizer } from "../localizer"; + +export interface CertManagerOptions { + /** + * Optional reporter invoked by the Linux platform store after attempting + * browser-NSS trust as part of `trustCertificate`. No-op on other + * platforms. + */ + linuxNssTrustReporter?: LinuxNssTrustReporter; + + /** + * Optional Localizer threaded into the platform store so log lines emitted + * inside `findExistingDevCert` (multi-candidate selection, forced skips) + * pass through the host's `vscode.l10n.t`. Non-VS-Code consumers (CLI, + * scripts) can omit it and fall back to the identity localizer. + */ + localize?: Localizer; +} + +/** + * Certificate manager that orchestrates generation, trust, export, and status + * checking using platform-specific stores. + */ +export class CertManager { + private store: PlatformCertificateStore | null = null; + private currentCert: GeneratedCert | null = null; + + constructor(private readonly options: CertManagerOptions = {}) {} + + private async getStore(): Promise { + this.store ??= await createPlatformStore({ + linuxNssTrustReporter: this.options.linuxNssTrustReporter, + localize: this.options.localize, + }); + return this.store; + } + + /** + * Generate a new dev cert and save it to the platform store. + * If force is true, removes existing certs first. + */ + async generate(force: boolean = false): Promise { + const store = await this.getStore(); + + if (force) { + log("Removing existing certificates..."); + await store.removeCertificates(); + } + + log("Generating new dev certificate..."); + const now = new Date(); + const expiry = new Date( + now.getTime() + VALIDITY_DAYS * 24 * 60 * 60 * 1000 + ); + const generated = await generateCertificate(now, expiry); + this.currentCert = generated; + + log(`Certificate generated. Thumbprint: ${generated.thumbprint}`); + await store.saveCertificate( + generated.cert, + generated.key, + generated.thumbprint + ); + log("Certificate saved to platform store."); + } + + /** + * Trust an externally-supplied certificate (e.g. one pushed from a Dev + * Container via the syncContainerCert reverse-sync flow) in the host + * OS trust store. + * + * Delegates directly to `store.trustCertificate(cert)` — the SAME hook + * the host-generation flow (`trust()`) uses on its final step — so + * "trusted on the host" means the same thing regardless of whether + * the cert was generated here or accepted from a container. On Linux + * that's `.NET Root store + OpenSSL trust dir + NSS browser DBs` (the + * NSS step uses the same `linuxNssTrustReporter` callback the host + * generation flow wires up). On macOS, login keychain trust policy. + * On Windows, CurrentUser/Root via certutil. + * + * Public-cert-only: the cert lands in every trust surface listed + * above but NEVER in CurrentUser/My, the keychain's identity slot, or + * the .NET store's `my/` directory. Skipping `saveCertificate` is + * deliberate — the host doesn't need (and shouldn't store) the + * private key, because Kestrel runs in the container with its own + * copy of the key. Future changes to this method MUST preserve both + * properties: (a) trust goes through `store.trustCertificate`; (b) + * no `saveCertificate` call. `tests/manager.test.ts` pins both. + * + * Does NOT update `currentCert`. The host's auto-generation flow + * (`generate()` / `trust()`) is a separate state machine that the + * container-push path doesn't feed into; if the user also has + * `generateDotNetCert: true` and a subsequent `getAllCertMaterial` + * pull arrives, the host will generate its own (separate) cert as + * normal. + */ + async trustExternalCertificate( + cert: GeneratedCert["cert"] + ): Promise { + const store = await this.getStore(); + + // Verify on-disk state before invoking the platform trust step. + // Skipping a redundant call matters on macOS where + // `security add-trusted-cert` is not a no-op for an already-trusted + // cert (re-touches the trust-settings record, may re-prompt for + // the keychain password). The same cache-as-goal-state / + // verify-on-disk pattern is used by the host-generation flow's + // `trust()` method, just expressed differently because it goes + // through `checkStatus()` instead of a direct `isCertTrusted`. + if (await store.isCertTrusted(cert)) { + log( + `Externally-supplied dev certificate ${cert.thumbprintSha1} is already trusted on host; skipping platform trust call.` + ); + return; + } + + log( + `Trusting externally-supplied dev certificate ${cert.thumbprintSha1} (public cert only, via the same platform trust path as host-generated)...` + ); + await store.trustCertificate(cert); + log("Externally-supplied certificate trusted."); + } + + /** + * Ensure a cert exists and is trusted. Generates one if needed. + */ + async trust(): Promise { + const store = await this.getStore(); + const status = await store.checkStatus(); + + if (!status.exists) { + await this.generate(); + } + + // Re-check: load from store if we didn't just generate + if (!this.currentCert) { + const found = await store.findExistingDevCert(); + if (!found) { + throw new Error("Failed to find certificate after generation."); + } + this.currentCert = found; + } + + const recheck = await store.checkStatus(); + if (!recheck.isTrusted) { + log("Trusting certificate in OS store..."); + await store.trustCertificate(this.currentCert.cert); + log("Certificate trusted."); + } + } + + /** + * Export the current cert in the specified format. + */ + async exportCert( + format: "pfx" | "pem" | "root-pfx", + outputDir: string, + password?: string + ): Promise { + await this.ensureLoaded(); + + if (format === "pfx") { + await exportPfx( + this.currentCert!.cert, + this.currentCert!.key, + outputDir, + password + ); + } else if (format === "root-pfx") { + await exportRootPfx(this.currentCert!.cert, outputDir); + } else { + exportPem(this.currentCert!.cert, this.currentCert!.key, outputDir); + } + } + + /** + * Check the status of the dev certificate. + */ + async check(): Promise { + const store = await this.getStore(); + return store.checkStatus(); + } + + /** + * Remove all dev certificates from the platform store. + */ + async clean(): Promise { + const store = await this.getStore(); + await store.removeCertificates(); + this.currentCert = null; + log("All dev certificates removed."); + } + + /** + * Ensure we have a loaded cert (from store or freshly generated). + */ + private async ensureLoaded(): Promise { + if (this.currentCert) return; + + const store = await this.getStore(); + const found = await store.findExistingDevCert(); + if (!found) { + throw new Error("No dev certificate found. Generate one first."); + } + this.currentCert = found; + } +} diff --git a/src/shared/src/cert/pfx.ts b/src/shared/src/cert/pfx.ts index db94ed2..4fd4b22 100644 --- a/src/shared/src/cert/pfx.ts +++ b/src/shared/src/cert/pfx.ts @@ -24,6 +24,11 @@ import { randomBytes, webcrypto, } from "node:crypto"; +import { + decryptLegacyPbe, + isSupportedLegacyPbe, + parseLegacyPbeParams, +} from "./pkcs12LegacyPbe"; import { DevCert } from "./types"; import { DevKey } from "./types"; @@ -69,14 +74,20 @@ const AES_KEY_LENGTH = 32; // AES-256 const AES_IV_LENGTH = 16; /** - * Well-known PKCS#12 PBE-with-SHA OIDs that we deliberately don't accept on - * the read path. Used purely to render an actionable error message — the - * actual rejection is "anything that isn't PBES2". + * Well-known PKCS#12 PBE-with-SHA OIDs that we deliberately don't accept + * on the read path. Used purely to render an actionable error message — + * the actual rejection is "anything that isn't PBES2, except the one + * legacy OID `pkcs12LegacyPbe.ts` is currently scoped to handle". See + * that module's docstring for context and removal criteria. */ const REJECTED_LEGACY_PBE_NAMES: Record = { "1.2.840.113549.1.12.1.1": "pbeWithSHAAnd128BitRC4", "1.2.840.113549.1.12.1.2": "pbeWithSHAAnd40BitRC4", - "1.2.840.113549.1.12.1.3": "pbeWithSHAAnd3-KeyTripleDES-CBC", + // 1.2.840.113549.1.12.1.3 = pbeWithSHAAnd3-KeyTripleDES-CBC is the + // one legacy algorithm we DO accept (via pkcs12LegacyPbe.ts) because + // aspnetcore writes it on macOS. Keeping it out of this rejection + // list rather than papering over with a special-case in the error + // path; the read flow branches before this table is consulted. "1.2.840.113549.1.12.1.4": "pbeWithSHAAnd2-KeyTripleDES-CBC", "1.2.840.113549.1.12.1.5": "pbeWithSHAAnd128BitRC2-CBC", "1.2.840.113549.1.12.1.6": "pbeWithSHAAnd40BitRC2-CBC", @@ -821,14 +832,30 @@ async function decryptSafeContents( const encryptedData = new pkijs.EncryptedData({ schema: contentInfo.content, }); - const algoOid = - encryptedData.encryptedContentInfo.contentEncryptionAlgorithm - .algorithmId; - if (algoOid !== OID_PBES2) { - throw unsupportedAlgorithmError("cert bag", algoOid); + const algoId = encryptedData.encryptedContentInfo.contentEncryptionAlgorithm; + const algoOid = algoId.algorithmId; + if (algoOid === OID_PBES2) { + const decrypted = await encryptedData.decrypt({ password: passwordBuf }); + return pkijs.SafeContents.fromBER(decrypted); + } + if (isSupportedLegacyPbe(algoOid)) { + // Legacy 3DES path — see pkcs12LegacyPbe.ts for context and the + // removal checklist. Restricted to the one OID aspnetcore writes + // on macOS; every other legacy PBE algorithm still falls through + // to the rejection below. + const params = parseLegacyPbeParams(algoId.algorithmParams); + const ciphertext = bufferFromAsn1OctetString( + encryptedData.encryptedContentInfo.encryptedContent + ); + const decrypted = decryptLegacyPbe( + algoOid, + params, + ciphertext, + Buffer.from(passwordBuf).toString("utf-8") + ); + return pkijs.SafeContents.fromBER(bufferToArrayBuffer(decrypted)); } - const decrypted = await encryptedData.decrypt({ password: passwordBuf }); - return pkijs.SafeContents.fromBER(decrypted); + throw unsupportedAlgorithmError("cert bag", algoOid); } throw new Error( @@ -841,18 +868,33 @@ async function decryptShroudedKeyBag( passwordBuf: ArrayBuffer ): Promise { const algoOid = bag.encryptionAlgorithm.algorithmId; - if (algoOid !== OID_PBES2) { - throw unsupportedAlgorithmError("private key", algoOid); + if (algoOid === OID_PBES2) { + await ( + bag as unknown as { + parseInternalValues: (p: { password: ArrayBuffer }) => Promise; + } + ).parseInternalValues({ password: passwordBuf }); + const pki = bag.parsedValue; + if (!pki) throw new Error("PKCS#8 shrouded key bag has no key."); + const der = pki.toSchema().toBER(false); + return DevKey.fromPkcs8Der(Buffer.from(der)); } - await ( - bag as unknown as { - parseInternalValues: (p: { password: ArrayBuffer }) => Promise; - } - ).parseInternalValues({ password: passwordBuf }); - const pki = bag.parsedValue; - if (!pki) throw new Error("PKCS#8 shrouded key bag has no key."); - const der = pki.toSchema().toBER(false); - return DevKey.fromPkcs8Der(Buffer.from(der)); + if (isSupportedLegacyPbe(algoOid)) { + // Legacy 3DES path — see pkcs12LegacyPbe.ts for context and the + // removal checklist. + const params = parseLegacyPbeParams(bag.encryptionAlgorithm.algorithmParams); + const ciphertext = bufferFromAsn1OctetString(bag.encryptedData); + const decrypted = decryptLegacyPbe( + algoOid, + params, + ciphertext, + Buffer.from(passwordBuf).toString("utf-8") + ); + const pki = pkijs.PrivateKeyInfo.fromBER(bufferToArrayBuffer(decrypted)); + const der = pki.toSchema().toBER(false); + return DevKey.fromPkcs8Der(Buffer.from(der)); + } + throw unsupportedAlgorithmError("private key", algoOid); } function passwordToArrayBuffer(password: string): ArrayBuffer { @@ -870,3 +912,23 @@ function bufferToArrayBuffer(buf: Buffer | Uint8Array): ArrayBuffer { new Uint8Array(ab).set(buf); return ab; } + +/** + * Pull the raw bytes out of an asn1js OctetString — handles both + * primitive (`valueBlock.valueHex`) and constructed (`getValue()`) + * encodings. Used by the legacy-PBE decrypt path to feed ciphertext + * into Node crypto without going through pkijs's internal decryptor. + */ +function bufferFromAsn1OctetString(node: unknown): Buffer { + const os = node as asn1js.OctetString; + // Constructed octet string: concatenated child segments. + const inner = os?.valueBlock?.value; + if (Array.isArray(inner) && inner.length > 0) { + const pieces = inner.map((child) => + Buffer.from((child as asn1js.OctetString).valueBlock.valueHex) + ); + return Buffer.concat(pieces); + } + // Primitive octet string: raw bytes in valueHex. + return Buffer.from(os.valueBlock.valueHex); +} diff --git a/src/shared/src/cert/pkcs12LegacyPbe.ts b/src/shared/src/cert/pkcs12LegacyPbe.ts new file mode 100644 index 0000000..3afe2e0 --- /dev/null +++ b/src/shared/src/cert/pkcs12LegacyPbe.ts @@ -0,0 +1,319 @@ +/** + * Legacy PKCS#12 PBE decryptor — narrowly scoped to one algorithm. + * + * # Why this exists + * + * `parsePfx` strictly accepts only modern PBES2/AES encryption for cert + * and key bags. We added that policy because every cert WE produce uses + * PBES2 and every legacy PBE algorithm in the PKCS#12 family is + * cryptographically weak. The lone exception is the cert that + * `aspnetcore`'s `MacOSCertificateManager.SaveCertificateCore` writes + * into `~/.aspnet/dev-certs/https/aspnetcore-localhost-*.pfx`: it calls + * `certificate.Export(X509ContentType.Pfx)` with no password, and + * dotnet/runtime's managed Pkcs12 writer's no-password default on Unix + * is `pbeWithSHA1And3-KeyTripleDES-CBC` (OID 1.2.840.113549.1.12.1.3). + * Confirmed empirically on .NET 10.0.301 / runtime 10.0.9, both in CI + * (macos-latest) and on a maintainer's local Mac. + * + * Without this module, `DotnetBackend.generate` on macOS — the platform + * `hostCertGenerator=auto` PREFERS — fails because `findExistingDevCert` + * can't read aspnetcore's disk cache. Same blocker hits any user-supplied + * dotnet-on-macOS-produced PFX that flows through `parsePfx`. Scoping is + * per-OID: only the algorithm we observed in practice is accepted; the + * other five RC2/RC4/2-key-3DES OIDs in the PKCS#12 family remain + * rejected with the original error. + * + * # Removal criteria + * + * Delete this module (and its companion test file) when ALL of the + * following hold: + * + * 1. Our floor-supported .NET SDK no longer produces 3DES-encrypted + * disk-cache PFXes on macOS — i.e. aspnetcore has switched + * `MacOSCertificateManager.SaveCertificateCore` from + * `Export(X509ContentType.Pfx)` to `ExportPkcs12(PbeParameters(...))` + * with explicit PBES2 parameters, matching the + * `OpenSslDirectoryBasedStoreProvider` Linux path. Verify via + * `tests/dotnetMacosCache.integration.test.ts` against the new + * minimum SDK version. + * 2. No user-facing report has surfaced legacy-PBE PFXes from any + * other source that we care about loading. + * + * Removal steps: + * + * 1. Delete this file. + * 2. Delete `tests/pkcs12LegacyPbe.test.ts` (in the UI extension test + * tree). + * 3. Delete `test/fixtures/pkcs12-legacy-3des.pfx`. + * 4. In `src/shared/src/cert/pfx.ts`: + * - Add `"1.2.840.113549.1.12.1.3": "pbeWithSHAAnd3-KeyTripleDES-CBC"` + * back to `REJECTED_LEGACY_PBE_NAMES`. + * - Revert `decryptSafeContents` and `decryptShroudedKeyBag` to + * their original "reject anything not PBES2" form (drop the + * `isSupportedLegacyPbe` branches and the imports from this + * module). + * + * # References + * + * - RFC 7292 §4.2: PKCS#12 PBE-with-SHA OIDs. + * - RFC 7292 Appendix B: PKCS#12 v1.0 key derivation function. + * - RFC 7292 Appendix C: `pkcs-12PbeParams` ASN.1 definition. + * - aspnetcore source: src/Shared/CertificateGeneration/ + * MacOSCertificateManager.cs `SaveCertificateCore` + * (`certificate.Export(X509ContentType.Pfx)` with no password). + * - dotnet/runtime behavior: managed Pkcs12 writer's no-password + * default on Unix is 3DES-with-SHA1, 2000 iterations. + */ + +import { createDecipheriv, createHash } from "crypto"; +import type * as asn1js from "asn1js"; + +/** + * The one legacy PBE OID this module decodes. Every other legacy + * PKCS#12 PBE OID stays in `REJECTED_LEGACY_PBE_NAMES` in pfx.ts. + */ +export const SUPPORTED_LEGACY_PBE_OID = "1.2.840.113549.1.12.1.3"; + +/** Type-guard: is `oid` the one legacy OID we support? */ +export function isSupportedLegacyPbe(oid: string): boolean { + return oid === SUPPORTED_LEGACY_PBE_OID; +} + +export interface LegacyPbeParams { + /** Salt bytes from the pkcs-12PbeParams ASN.1 sequence. */ + salt: Buffer; + /** Iteration count from the pkcs-12PbeParams ASN.1 sequence. */ + iterations: number; +} + +/** + * Parse the AlgorithmIdentifier.parameters field as `pkcs-12PbeParams` + * (RFC 7292 Appendix C): + * + * pkcs-12PbeParams ::= SEQUENCE { + * salt OCTET STRING, + * iterations INTEGER + * } + * + * The same format is shared by all six legacy OIDs in the + * `1.2.840.113549.1.12.1.*` family. + */ +export function parseLegacyPbeParams( + algorithmParameters: unknown +): LegacyPbeParams { + const seq = algorithmParameters as asn1js.Sequence | undefined; + const items = seq?.valueBlock?.value; + if (!items || items.length < 2) { + throw new Error( + "Malformed pkcs-12PbeParams: expected SEQUENCE { salt, iterations }." + ); + } + const saltAsn1 = items[0] as asn1js.OctetString; + const iterAsn1 = items[1] as asn1js.Integer; + return { + salt: Buffer.from(saltAsn1.valueBlock.valueHex), + iterations: iterAsn1.valueBlock.valueDec, + }; +} + +/** + * Decrypt a body protected by `pbeWithSHA1And3-KeyTripleDES-CBC`. + * + * 1. Derive the 24-byte 3DES key via PKCS#12 v1.0 KDF (diversifier 1). + * 2. Derive the 8-byte IV via PKCS#12 v1.0 KDF (diversifier 2). + * 3. Decrypt with `des-ede3-cbc` (Node's built-in crypto). + * + * For an EMPTY password we try two derivation conventions and return + * whichever decrypts cleanly: + * + * - "with null terminator" — encode `""` as the 2-byte UTF-16BE null + * terminator and derive from that. OpenSSL, Bouncy Castle, and our + * openssl-generated test fixture all use this convention. + * - "literal empty bytes" — RFC 7292's literal wording ("if password + * is the empty string, then so is P"). dotnet/runtime's managed + * PKCS#12 writer takes this branch; its on-disk macOS dev-cert + * cache decrypts ONLY under this convention. Confirmed empirically + * on .NET 10.0.301 — CI hit "bad decrypt" with the OpenSSL + * convention applied uniformly. + * + * For non-empty passwords there's no ambiguity: implementations agree + * the password is UTF-16BE bytes plus a trailing null terminator, and + * only that path is tried. + * + * PKCS#7 padding the cipher leaves on the plaintext is stripped by + * Node's `decipher.final()`. A `final()` throw under both conventions + * propagates outward — the password supplied is wrong, the file is + * corrupt, or aspnetcore changed its export convention again. + */ +export function decryptLegacyPbe( + oid: string, + params: LegacyPbeParams, + ciphertext: Buffer, + password: string +): Buffer { + if (oid !== SUPPORTED_LEGACY_PBE_OID) { + throw new Error( + `decryptLegacyPbe called with unsupported OID ${oid}. ` + + `Call sites should check isSupportedLegacyPbe(oid) first.` + ); + } + // Non-empty password: single canonical encoding. + if (password.length > 0) { + return tryDecrypt3DesPbe(params, ciphertext, password, true); + } + // Empty password: try OpenSSL convention first (matches our test + // fixture + most real-world legacy PFXes), fall back to RFC-literal + // (matches dotnet's macOS disk cache). + try { + return tryDecrypt3DesPbe(params, ciphertext, "", true); + } catch { + return tryDecrypt3DesPbe(params, ciphertext, "", false); + } +} + +function tryDecrypt3DesPbe( + params: LegacyPbeParams, + ciphertext: Buffer, + password: string, + includeNullTerminator: boolean +): Buffer { + const key = pkcs12Kdf( + password, + params.salt, + params.iterations, + 1, + 24, + includeNullTerminator + ); + const iv = pkcs12Kdf( + password, + params.salt, + params.iterations, + 2, + 8, + includeNullTerminator + ); + const decipher = createDecipheriv("des-ede3-cbc", key, iv); + return Buffer.concat([decipher.update(ciphertext), decipher.final()]); +} + +/** + * PKCS#12 v1.0 key derivation function (RFC 7292 Appendix B). NOT + * PBKDF2 — this is the legacy SHA-1-based diversifier KDF specifically + * defined for PKCS#12. Used here for both encryption key (diversifier + * 1) and IV (diversifier 2) derivation against the same salt and + * iteration count. + * + * `includeNullTerminator` controls the well-known empty-password + * disagreement (see `decryptLegacyPbe`). For non-empty passwords every + * relevant implementation appends the UTF-16BE null terminator, so the + * flag is IGNORED for them — the function always uses the + * null-terminated encoding to match real-world interop. + * + * Exported for testing only — call sites should use `decryptLegacyPbe`, + * which handles the empty-password convention fallback. + */ +export function pkcs12Kdf( + password: string, + salt: Buffer, + iterations: number, + diversifier: 1 | 2 | 3, + outputLength: number, + includeNullTerminator = true +): Buffer { + const u = 20; // SHA-1 output size (bytes) + const v = 64; // SHA-1 input block size (bytes) + + // D: v bytes, each equal to the diversifier byte. + const D = Buffer.alloc(v, diversifier); + + // S: salt repeated to a multiple of v bytes (empty if salt is empty). + const S = repeatToMultiple(salt, v); + + // P: password as UTF-16BE bytes, repeated to a multiple of v bytes. + // Non-empty passwords always include the trailing null terminator + // (every implementation we care about agrees there); for the empty + // password, the `includeNullTerminator` flag picks between the two + // contested conventions that callers iterate over. + let pwBytes: Buffer; + if (password.length === 0) { + pwBytes = includeNullTerminator + ? Buffer.from([0x00, 0x00]) + : Buffer.alloc(0); + } else { + pwBytes = utf16BeWithNul(password); + } + const P = repeatToMultiple(pwBytes, v); + + // I = S || P. + let I = Buffer.concat([S, P]); + + // Number of u-byte rounds needed to fill the output. + const rounds = Math.ceil(outputLength / u); + const out = Buffer.alloc(outputLength); + + for (let i = 1; i <= rounds; i++) { + // A_i = H^c(D || I), with H = SHA-1 and c = iterations. + let A = createHash("sha1").update(D).update(I).digest(); + for (let k = 1; k < iterations; k++) { + A = createHash("sha1").update(A).digest(); + } + + // Copy A_i into the output (truncated on the last round if needed). + const offset = (i - 1) * u; + const take = Math.min(u, outputLength - offset); + A.copy(out, offset, 0, take); + + if (i < rounds) { + // B = A repeated to v bytes. + const B = Buffer.alloc(v); + for (let k = 0; k < v; k++) B[k] = A[k % u]; + + // For each v-byte block I_l of I, set I_l = (I_l + B + 1) mod 2^(8v). + // Big-endian arithmetic with carry propagation. + const blockCount = I.length / v; + const newI = Buffer.alloc(I.length); + for (let l = 0; l < blockCount; l++) { + let carry = 1; // the +1 in (I_l + B + 1) + for (let m = v - 1; m >= 0; m--) { + const sum = I[l * v + m] + B[m] + carry; + newI[l * v + m] = sum & 0xff; + carry = sum >>> 8; + } + // Overflow above 2^(8v) is dropped per the mod 2^(8v) in the spec. + } + I = newI; + } + } + + return out; +} + +/** + * Encode a string as UTF-16BE plus a trailing 16-bit null character — + * the password representation PKCS#12 v1.0 KDF expects for non-empty + * strings under every convention we care about. Surrogate pairs are + * preserved (the `String.prototype.charCodeAt` iteration produces the + * UTF-16 code units directly). + */ +function utf16BeWithNul(s: string): Buffer { + const buf = Buffer.alloc((s.length + 1) * 2); + for (let i = 0; i < s.length; i++) { + buf.writeUInt16BE(s.charCodeAt(i), i * 2); + } + // Trailing 2 bytes are already zero (Buffer.alloc zero-fills). + return buf; +} + +/** + * Repeat `src` (cycling) into a buffer whose length is the smallest + * multiple of `block` >= src.length. An empty `src` yields an empty + * result (matches the RFC 7292 note for empty salt / password). + */ +function repeatToMultiple(src: Buffer, block: number): Buffer { + if (src.length === 0) return Buffer.alloc(0); + const len = block * Math.ceil(src.length / block); + const out = Buffer.alloc(len); + for (let i = 0; i < len; i++) out[i] = src[i % src.length]; + return out; +} diff --git a/src/shared/src/index.ts b/src/shared/src/index.ts index 728656c..be77796 100644 --- a/src/shared/src/index.ts +++ b/src/shared/src/index.ts @@ -1,4 +1,7 @@ -export { initLogger, log, revealLogger } from "./logger"; +export { log, revealLogger, setLogSink } from "./logger"; +export type { LogSink } from "./logger"; +export { identityLocalizer } from "./localizer"; +export type { Localizer } from "./localizer"; export type { CertMaterial, CertKind, @@ -68,3 +71,73 @@ export type { SelectBestOptions, UsableDevCert, } from "./cert/classify"; +export { generateCertificate } from "./cert/generator"; +export type { GenerateAlgorithm, GeneratedCert } from "./cert/generator"; +export { + exportPfx, + exportPem, + exportRootPfx, + exportLoadedCert, + certToPem, + keyToPem, + certToDer, +} from "./cert/exporter"; +export type { ExportedLoadedCert } from "./cert/exporter"; +export { CertManager } from "./cert/manager"; +export type { CertManagerOptions } from "./cert/manager"; + +// Platform trust-store layer — orchestrates per-OS dev-cert storage and +// trust. Lives in shared so a future host CLI can share the implementation +// with the VS Code extension. +export { + createPlatformStore, +} from "./platform/types"; +export type { + PlatformCertificateStore, + CertificateStatus, + CreatePlatformStoreOptions, + BaseStoreOptions, + LinuxNssTrustReporter, +} from "./platform/types"; +export { + BaseCertificateStore, + classifyCandidate as classifyPlatformCandidate, + selectBestDevCert as selectBestPlatformDevCert, +} from "./platform/baseStore"; +export type { ClassifyOptions as PlatformClassifyOptions } from "./platform/baseStore"; +export { LinuxCertificateStore } from "./platform/linuxStore"; +export type { LinuxCertificateStoreOptions } from "./platform/linuxStore"; +export { MacCertificateStore } from "./platform/macStore"; +export { + WindowsCertificateStore, +} from "./platform/windowsStore"; +export type { + WindowsStoreLocation, + PsCandidate, + PsSkipped, + PsSkipReason, + PsEnumeration, +} from "./platform/windowsStore"; +export { trustInNss } from "./platform/nssTrust"; +export type { NssTrustResult } from "./platform/nssTrust"; +export { runProcess, resolveSafeExecPath } from "./platform/processUtil"; +export type { + ProcessResult, + ResolveSafeExecPathOptions, +} from "./platform/processUtil"; + +// Backend abstraction — selectable cert-generator backends for the +// VS Code host extension's `hostCertGenerator` setting. Lets the +// extension pick between the bundled native generator and the +// `dotnet dev-certs https` pass-through without the call site +// reimplementing availability detection / selection logic. +export { NativeBackend } from "./backends/native"; +export { DotnetBackend } from "./backends/dotnet"; +export { selectBackend, describeAutoBackend } from "./backends/select"; +export type { + Backend, + BackendKind, + BackendMode, + GenerateOptions, + GenerateResult, +} from "./backends/types"; diff --git a/src/shared/src/localizer.ts b/src/shared/src/localizer.ts new file mode 100644 index 0000000..98e8fa2 --- /dev/null +++ b/src/shared/src/localizer.ts @@ -0,0 +1,27 @@ +/** Argument type accepted by `Localizer` — matches `vscode.l10n.t`'s. */ +export type LocalizerArg = string | number | boolean; + +/** + * A function that resolves a template string + arguments into a localized + * string. Signature-compatible with `vscode.l10n.t` so the host extension can + * pass it through verbatim; non-VS-Code consumers (CLI, scripts, tests) use + * the identity implementation below to keep the English source strings. + */ +export type Localizer = (template: string, ...args: LocalizerArg[]) => string; + +/** + * Default Localizer used when no host-supplied l10n is wired up. Performs + * `{0}` placeholder substitution against `args` so the formatted output + * matches what `vscode.l10n.t` produces in the no-translation-loaded case + * — that's also the behavior the test l10n mock relies on, so platform + * stores constructed without a localizer still emit the same log strings. + */ +export const identityLocalizer: Localizer = (template, ...args) => + template.replace(/\{(\w+)\}/g, (_match, key: string) => { + const idx = Number(key); + if (!Number.isNaN(idx)) { + const value = args[idx]; + return value === undefined ? "" : String(value); + } + return ""; + }); diff --git a/src/shared/src/logger.ts b/src/shared/src/logger.ts index f91d242..96e54cd 100644 --- a/src/shared/src/logger.ts +++ b/src/shared/src/logger.ts @@ -1,27 +1,41 @@ -import * as vscode from "vscode"; +/** + * Generic log sink interface — structurally compatible with + * `vscode.OutputChannel` so the VS Code extension hosts can plug their + * channel directly into `setLogSink`. Non-VS-Code consumers (the host CLI, + * scripts) supply their own implementation (typically a console wrapper) so + * the shared cert / platform layer can call `log()` without a `vscode` + * dependency. + */ +export interface LogSink { + appendLine(message: string): void; + /** Optional. Honored by `revealLogger`. */ + show?(preserveFocus: boolean): void; +} -let channel: vscode.OutputChannel | undefined; +let sink: LogSink | undefined; /** - * Initialize the shared logger with an output channel. - * Call once from the extension's activate() function. - * Returns the channel so it can be registered as a disposable. + * Wire up the active log sink. `undefined` disables logging entirely. Safe + * to call repeatedly — the most recent call wins. */ -export function initLogger(channelName: string): vscode.OutputChannel { - channel = vscode.window.createOutputChannel(channelName); - return channel; +export function setLogSink(newSink: LogSink | undefined): void { + sink = newSink; } /** - * Log a timestamped message to the output channel. - * Requires initLogger() to have been called first. + * Log a timestamped message to the active sink. No-op if no sink is set — + * the platform / cert layer calls this freely without coordinating with + * its hosts about whether logging is enabled. */ export function log(message: string): void { - channel?.appendLine(`[${new Date().toISOString()}] ${message}`); + sink?.appendLine(`[${new Date().toISOString()}] ${message}`); } -/** Reveal the shared output channel. `preserveFocus = true` so a - * concurrent prompt doesn't lose focus. No-op if uninitialised. */ +/** + * Reveal the active sink (VS Code OutputChannel.show). `preserveFocus = true` + * so a concurrent prompt doesn't lose focus. No-op if uninitialized or the + * sink doesn't implement `show`. + */ export function revealLogger(): void { - channel?.show(true); + sink?.show?.(true); } diff --git a/src/shared/src/loggerVscode.ts b/src/shared/src/loggerVscode.ts new file mode 100644 index 0000000..b1aa407 --- /dev/null +++ b/src/shared/src/loggerVscode.ts @@ -0,0 +1,17 @@ +import * as vscode from "vscode"; +import { setLogSink } from "./logger"; + +/** + * VS Code-host helper: create a named OutputChannel and wire it up as the + * shared log sink. Returns the channel so the caller can register it with + * `context.subscriptions.push(...)` for automatic disposal. + * + * Kept in a dedicated submodule (not re-exported from the package barrel) so + * the shared cert / platform layer remains importable from non-VS-Code + * contexts (host CLI, scripts) without resolving `vscode` at module load. + */ +export function initLogger(channelName: string): vscode.OutputChannel { + const channel = vscode.window.createOutputChannel(channelName); + setLogSink(channel); + return channel; +} diff --git a/src/shared/src/platform/baseStore.ts b/src/shared/src/platform/baseStore.ts new file mode 100644 index 0000000..4cea1de --- /dev/null +++ b/src/shared/src/platform/baseStore.ts @@ -0,0 +1,359 @@ +import * as fs from "fs"; +import * as path from "path"; +import { type PlatformCertificateStore, type CertificateStatus, type BaseStoreOptions } from "./types"; +import { identityLocalizer, type Localizer } from "../localizer"; +import { log } from "../logger"; +import { type DevCert, type DevKey } from "../cert/types"; +import { buildPfx, parsePfx } from "../cert/pfx"; +import { getCertificateVersion } from "../cert/validation"; +import { + classifyCandidate as classifyCandidateShared, + selectBestDevCert as selectBestDevCertShared, + extractThumbprintHintFromFilename, + type CandidateInput, + type ClassifiedCandidate, + type SkipReport, + type UsableDevCert, +} from "../cert/classify"; + +// Re-export the pure shared types so existing imports of +// `./platform/baseStore` keep working without a sweeping rename. +export type { + ClassifiedCandidate, + CandidateInput, + CandidateMetadata, + UsableDevCert, +} from "../cert/classify"; +export { extractThumbprintHintFromFilename } from "../cert/classify"; + +export interface ClassifyOptions { + /** Optional Localizer; defaults to `identityLocalizer`. */ + localize?: Localizer; +} + +/** + * Side-effectful classifier wrapper. Delegates the pure classification to the + * shared module and emits the same "skipping ASP.NET dev cert ..." log line + * the host extension has always produced — now templated via an injected + * Localizer so non-VS-Code consumers (CLI / scripts / tests) can reuse the + * exact same skip-log surface without taking on a `vscode` dependency. + */ +export function classifyCandidate( + input: CandidateInput, + options: ClassifyOptions = {} +): ClassifiedCandidate | null { + const localize = options.localize ?? identityLocalizer; + return classifyCandidateShared(input, { + onSkipped: (report) => emitSkipLog(report, localize), + }); +} + +/** + * Side-effectful selection wrapper. Delegates to the shared selector and + * emits the multi-candidate warning when more than one usable candidate is + * present, templated via the injected Localizer. + */ +export function selectBestDevCert( + usable: UsableDevCert[], + context: string, + options: ClassifyOptions = {} +): UsableDevCert | null { + const localize = options.localize ?? identityLocalizer; + return selectBestDevCertShared(usable, context, { + onMultipleCandidates: ({ selected, candidates }) => { + const header = localize( + "Multiple valid ASP.NET dev certs found in {0}; selected {1}.", + context, + selected.thumbprint + ); + const candidatesHeader = localize(" Candidates:"); + const selectedTag = localize("[selected]"); + const skippedTag = localize("[skipped] "); + const lines = [ + header, + candidatesHeader, + ...candidates.map((c, i) => + localize( + " {0} thumbprint={1} version={2} notBefore={3} notAfter={4}", + i === 0 ? selectedTag : skippedTag, + c.thumbprint, + getCertificateVersion(c.cert), + c.cert.notBefore.toISOString(), + c.cert.notAfter.toISOString() + ) + ), + ]; + log(lines.join("\n")); + }, + }); +} + +/** + * Render the localized "skipping ASP.NET dev cert" log line for one skipped + * candidate. Maps the shared classifier's reason code to a localized string. + * `forced` skips carry the caller's free-form reason verbatim — the platform + * stores (linuxStore / macStore / windowsStore) localize their own forced- + * skip reasons before handing them in, so we pass through. + */ +function emitSkipLog(report: SkipReport, localize: Localizer): void { + let localizedReason: string; + switch (report.reasonCode) { + case "missing-private-key": + localizedReason = localize( + "PFX contains certificate without matching private key" + ); + break; + case "parse-failed": + localizedReason = localize( + "failed to parse PFX (corrupt or wrong password)" + ); + break; + case "forced": + // Caller localized this string before classifyCandidate received it. + localizedReason = report.forcedReason ?? ""; + break; + } + const unknown = localize("(unknown)"); + const meta = report.metadata; + const subjectCN = meta.subjectCN ?? unknown; + const version = + meta.version === undefined || meta.version === null + ? unknown + : String(meta.version); + const notBefore = meta.notBefore ? meta.notBefore.toISOString() : unknown; + const notAfter = meta.notAfter ? meta.notAfter.toISOString() : unknown; + log( + localize( + "Skipping ASP.NET dev cert {0} ({1}): {2}.\n subjectCN={3} version={4} notBefore={5} notAfter={6}", + meta.thumbprint ?? unknown, + report.source, + localizedReason, + subjectCN, + version, + notBefore, + notAfter + ) + ); +} + +/** + * Base implementation for platform certificate stores. + * + * Provides common logic shared across Windows, macOS, and Linux: + * - checkStatus() with a consistent pattern (find → check trust → build status) + * - PFX loading and writing helpers + * - The localized classifier / selector wrappers above, threaded through + * `this.localize` so subclasses don't repeat the plumbing. + * + * Subclasses implement the platform-specific methods: findExistingDevCert, + * saveCertificate, trustCertificate, removeCertificates, and isTrusted. + */ +export abstract class BaseCertificateStore implements PlatformCertificateStore { + protected readonly localize: Localizer; + + constructor(options: BaseStoreOptions = {}) { + this.localize = options.localize ?? identityLocalizer; + } + + async checkStatus(): Promise { + const found = await this.findExistingDevCert(); + if (!found) { + return { + exists: false, + isTrusted: false, + thumbprint: null, + notBefore: null, + notAfter: null, + version: -1, + }; + } + + const { cert, thumbprint } = found; + const trusted = await this.isTrusted(cert, thumbprint); + const version = getCertificateVersion(cert); + + return { + exists: true, + isTrusted: trusted, + thumbprint, + notBefore: cert.notBefore.toISOString(), + notAfter: cert.notAfter.toISOString(), + version, + }; + } + + abstract findExistingDevCert(): Promise; + + abstract saveCertificate( + cert: DevCert, + key: DevKey, + thumbprint: string + ): Promise; + + abstract trustCertificate(cert: DevCert): Promise; + + abstract removeCertificates(): Promise; + + /** + * Public wrapper around `isTrusted` that satisfies the + * `PlatformCertificateStore.isCertTrusted` contract — verify the + * current on-disk / OS trust state for a specific certificate. Lets + * callers (notably `CertManager.trustExternalCertificate`) decide + * whether the platform-level trust step needs to run at all, + * avoiding redundant `security add-trusted-cert` / `certutil + * -addstore` calls that aren't true no-ops. + */ + async isCertTrusted(cert: DevCert): Promise { + return this.isTrusted(cert, cert.thumbprintSha1); + } + + /** + * Platform-specific trust verification. + * Called by checkStatus() to determine if the certificate is trusted. + */ + protected abstract isTrusted( + cert: DevCert, + thumbprint: string + ): Promise; + + // --- Shared helpers --- + + /** + * Parse a PFX file and extract the certificate, private key, and thumbprint. + * Returns `{ cert, key }` where `key` may be null when the PFX is cert-only. + * Returns null only on outright parse failure (corrupt bytes, wrong + * password, unsupported PBE). + */ + protected async loadPfxLenient( + pfxPath: string, + password: string = "" + ): Promise<{ cert: DevCert; key: DevKey | null; thumbprint: string } | null> { + try { + const pfxBytes = fs.readFileSync(pfxPath); + const { cert, key } = await parsePfx(pfxBytes, password); + return { cert, key: key ?? null, thumbprint: cert.thumbprintSha1 }; + } catch { + return null; + } + } + + /** + * Parse a PFX file and extract the certificate, private key, and thumbprint. + * Returns null if the file cannot be parsed or is missing cert/key bags. + * Strict variant — used by existing call sites that want a usable identity + * or nothing. + */ + protected async loadPfx( + pfxPath: string, + password: string = "" + ): Promise { + const loaded = await this.loadPfxLenient(pfxPath, password); + if (!loaded || !loaded.key) return null; + return { cert: loaded.cert, key: loaded.key, thumbprint: loaded.thumbprint }; + } + + /** + * Write a certificate and key as a PFX file. + */ + protected async writePfx( + cert: DevCert, + key: DevKey, + pfxPath: string, + password: string = "", + mode?: number + ): Promise { + const der = await buildPfx({ cert, key, password }); + const options = mode !== undefined ? { mode } : undefined; + fs.writeFileSync(pfxPath, der, options); + } + + /** + * Subclass-facing convenience: classify one candidate using this store's + * Localizer, so subclasses don't have to thread it through manually. + */ + protected classify(input: CandidateInput): ClassifiedCandidate | null { + return classifyCandidate(input, { localize: this.localize }); + } + + /** + * Subclass-facing convenience: select the best candidate using this + * store's Localizer. + */ + protected selectBest( + usable: UsableDevCert[], + context: string + ): UsableDevCert | null { + return selectBestDevCert(usable, context, { localize: this.localize }); + } + + /** + * Scan a directory for `*.pfx` files and classify each one. For every + * match: + * + * 1. Try to parse it. Parse failures on canonically-named files + * (`aspnetcore-localhost-.pfx` or `.pfx`) emit the + * "failed to parse PFX" unusable warning; parse failures on other + * filenames are silent — they may belong to other tools. + * 2. If parsed and the cert passes `isValidDevCert` but there's no private + * key, emit the "no matching private key" unusable warning. + * 3. Otherwise classify as `usable`. + * + * Selection runs only over the surviving usable candidates via + * `selectBestDevCert`. Multi-candidate selection emits its own warning. + * + * Callers may narrow which files to consider via `filenamePredicate` + * (default: accept every `*.pfx`). Whether a parse failure produces a + * warning is controlled separately by `extractThumbprintHintFromFilename` + * — see step 1. + */ + protected async findBestDevCertInDir( + dir: string, + password: string = "", + options: { + filenamePredicate?: (filename: string) => boolean; + context?: string; + } = {} + ): Promise { + if (!fs.existsSync(dir)) return null; + + const context = options.context ?? dir; + const usable: UsableDevCert[] = []; + + const files = fs + .readdirSync(dir) + .filter((f) => f.endsWith(".pfx")) + .filter((f) => + options.filenamePredicate ? options.filenamePredicate(f) : true + ); + + for (const file of files) { + const filePath = path.join(dir, file); + const thumbprintHint = extractThumbprintHintFromFilename(file); + const loaded = await this.loadPfxLenient(filePath, password); + + if (!loaded) { + // Canonical name + parse failure → warn; otherwise silent. + this.classify({ + kind: "parseFailure", + source: filePath, + thumbprintHint, + }); + continue; + } + + const classified = this.classify({ + kind: "loaded", + source: filePath, + loaded, + }); + + if (classified === null) continue; + if (classified.kind === "usable") { + usable.push(classified); + } + // skipped → already logged inside this.classify + } + + return this.selectBest(usable, context); + } +} diff --git a/src/shared/src/platform/linuxStore.ts b/src/shared/src/platform/linuxStore.ts new file mode 100644 index 0000000..704f811 --- /dev/null +++ b/src/shared/src/platform/linuxStore.ts @@ -0,0 +1,274 @@ +import * as fs from "fs"; +import * as path from "path"; +import { BaseCertificateStore } from "./baseStore"; +import { trustInNss, type NssTrustResult } from "./nssTrust"; +import { runProcess } from "./processUtil"; +import { type LinuxNssTrustReporter, type BaseStoreOptions } from "./types"; +import { type DevCert, type DevKey } from "../cert/types"; +import { ASPNET_HTTPS_OID } from "../cert/properties"; +import { buildPfx } from "../cert/pfx"; +import { + getDotNetStorePath, + getDotNetRootStorePath, + getOpenSslTrustDir, + getPemFileName, +} from "../paths"; +import { log } from "../logger"; + +export interface LinuxCertificateStoreOptions extends BaseStoreOptions { + /** + * Optional callback invoked once with the result of the best-effort + * browser-NSS trust step inside `trustCertificate`. Omitting it disables + * the NSS step entirely (used by integration tests and CLI contexts + * where browser trust isn't relevant). + */ + nssTrustReporter?: LinuxNssTrustReporter; +} + +/** + * Linux certificate store implementation. + * + * Storage locations: + * - .NET X509Store path: ~/.dotnet/corefx/cryptography/x509stores/my/ + * - OpenSSL trust dir: ~/.aspnet/dev-certs/trust/ (or DOTNET_DEV_CERTS_OPENSSL_CERTIFICATE_DIRECTORY) + * + * Trust is established by: + * 1. Writing a PFX to the .NET Root store path (for .NET runtime validation) + * 2. Writing a PEM to the OpenSSL trust directory with hash symlinks (for OpenSSL/curl/etc.) + * 3. Best-effort import into Linux browser NSS databases, when a reporter + * is configured. + */ +export class LinuxCertificateStore extends BaseCertificateStore { + private readonly nssTrustReporter?: LinuxNssTrustReporter; + + constructor(options: LinuxCertificateStoreOptions = {}) { + super(options); + this.nssTrustReporter = options.nssTrustReporter; + } + + private get dotNetRootStorePath(): string { + return getDotNetRootStorePath(); + } + + async findExistingDevCert(): Promise<{ + cert: DevCert; + key: DevKey; + thumbprint: string; + } | null> { + return this.findBestDevCertInDir(getDotNetStorePath()); + } + + async saveCertificate( + cert: DevCert, + key: DevKey, + thumbprint: string + ): Promise { + const storeDir = getDotNetStorePath(); + fs.mkdirSync(storeDir, { recursive: true }); + await this.writePfx( + cert, + key, + path.join(storeDir, `${thumbprint}.pfx`), + "", + 0o600 + ); + } + + async trustCertificate(cert: DevCert): Promise { + await this.trustInDotNetRootStore(cert); + await this.trustViaOpenSsl(cert); + await this.trustInNssBrowsers(cert); + } + + /** + * Best-effort browser NSS trust. Mirrors the Windows / macOS pattern where + * `trustCertificate` performs every trust step the platform supports out + * of the box — on Linux that includes adding the cert to the user's NSS + * databases for Firefox / Chromium-family browsers. Never throws: NSS + * tooling or stores are commonly absent and shouldn't break .NET / OpenSSL + * trust. Results are surfaced via the reporter callback (typically used by + * the extension host to show a manual-guidance toast on failure). + */ + private async trustInNssBrowsers(cert: DevCert): Promise { + if (!this.nssTrustReporter) return; + + const pemPath = path.join( + getOpenSslTrustDir(), + getPemFileName(cert.thumbprintSha1) + ); + if (!fs.existsSync(pemPath)) { + log(`Linux NSS trust: PEM not found at ${pemPath}, skipping.`); + return; + } + + let result: NssTrustResult; + try { + result = await trustInNss(pemPath); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + log(`Linux NSS trust threw unexpectedly: ${message}`); + result = { success: false, message }; + } + + this.nssTrustReporter(result, pemPath); + } + + async removeCertificates(): Promise { + await this.removeDevCertsFromDir(getDotNetStorePath()); + await this.removeDevCertsFromDir(this.dotNetRootStorePath); + + const trustDir = getOpenSslTrustDir(); + if (fs.existsSync(trustDir)) { + const entries = fs.readdirSync(trustDir); + for (const entry of entries) { + const fullPath = path.join(trustDir, entry); + if (entry.startsWith("aspnetcore-localhost-")) { + fs.unlinkSync(fullPath); + } else if (isHashSymlink(entry)) { + try { + if (fs.lstatSync(fullPath).isSymbolicLink()) { + fs.unlinkSync(fullPath); + } + } catch { + // ignore + } + } + } + } + } + + protected isTrusted( + _cert: DevCert, + thumbprint: string + ): Promise { + const pemPath = path.join(getOpenSslTrustDir(), getPemFileName(thumbprint)); + return Promise.resolve(fs.existsSync(pemPath)); + } + + // --- Linux-specific trust helpers --- + + private async trustInDotNetRootStore(cert: DevCert): Promise { + fs.mkdirSync(this.dotNetRootStorePath, { recursive: true }); + + const thumbprint = cert.thumbprintSha1; + const certPath = path.join(this.dotNetRootStorePath, `${thumbprint}.pfx`); + + // .NET's X509Store on Linux stores certs as individual PFX files. + // For the Root store, we need a PFX containing only the public cert (no private key). + const pfxBytes = await buildPfx({ cert }); + fs.writeFileSync(certPath, pfxBytes, { mode: 0o644 }); + } + + private async trustViaOpenSsl(cert: DevCert): Promise { + const trustDir = getOpenSslTrustDir(); + fs.mkdirSync(trustDir, { recursive: true }); + + const thumbprint = cert.thumbprintSha1; + const pemFileName = getPemFileName(thumbprint); + const pemPath = path.join(trustDir, pemFileName); + + // Purely additive: write the new PEM, but do NOT delete other + // `aspnetcore-localhost-*.pem` files in the trust dir. The previous + // implementation swept "old rotations" defensively, but that turned + // every `trustCertificate` call into an implicit revocation of every + // other dev cert in the trust dir — including the host-generated + // cert when the container-push reverse-sync flow trusts an + // additional one, and vice versa. Removing trust from a cert the + // user (or another flow) explicitly trusted is the cleanup + // command's job — gated by an explicit user prompt — not + // trustCertificate's. The cleanup sweep continues to handle + // legitimately stale artifacts; this method stays additive so the + // generation flow and the reverse-sync flow can coexist without + // ping-ponging trust. + // + // Writing to the exact same path on a same-thumbprint repeat call + // is idempotent (overwrites identical content); rehashing afterward + // is a no-op when nothing changed. + fs.writeFileSync(pemPath, cert.pem, { mode: 0o644 }); + await this.rehashDirectory(trustDir); + } + + private async rehashDirectory(directory: string): Promise { + const entries = fs.readdirSync(directory); + + // Remove existing hash symlinks + for (const entry of entries) { + if (isHashSymlink(entry)) { + const fullPath = path.join(directory, entry); + try { + if (fs.lstatSync(fullPath).isSymbolicLink()) { + fs.unlinkSync(fullPath); + } + } catch { + // ignore + } + } + } + + // Create new hash symlinks for all PEM/CRT files + const certFiles = fs + .readdirSync(directory) + .filter((f) => /\.(pem|crt|cer)$/i.test(f)); + + for (const certFile of certFiles) { + const fullPath = path.join(directory, certFile); + try { + if (fs.lstatSync(fullPath).isSymbolicLink()) continue; + } catch { + continue; + } + + const hash = await this.getOpenSslSubjectHash(fullPath); + if (!hash) continue; + + // Slot 0-9 covers any realistic collision count. Catch EEXIST so a + // concurrent rehash can't crash this one mid-loop. + for (let i = 0; i < 10; i++) { + const linkPath = path.join(directory, `${hash}.${i}`); + if (fs.existsSync(linkPath)) continue; + try { + fs.symlinkSync(certFile, linkPath); + break; + } catch (err: unknown) { + if ((err as NodeJS.ErrnoException).code === "EEXIST") continue; + throw err; + } + } + } + } + + private async getOpenSslSubjectHash( + certPath: string + ): Promise { + const result = await runProcess("openssl", [ + "x509", + "-hash", + "-noout", + "-in", + certPath, + ]); + if (result.exitCode !== 0) return null; + return result.stdout.trim() || null; + } + + private async removeDevCertsFromDir(dir: string): Promise { + if (!fs.existsSync(dir)) return; + + const files = fs.readdirSync(dir).filter((f) => f.endsWith(".pfx")); + for (const file of files) { + const pfxPath = path.join(dir, file); + try { + const result = await this.loadPfx(pfxPath); + if (result && result.cert.hasExtension(ASPNET_HTTPS_OID)) { + fs.unlinkSync(pfxPath); + } + } catch { + // Skip files that can't be parsed + } + } + } +} + +function isHashSymlink(filename: string): boolean { + return /^[0-9a-f]{8}\.\d+$/.test(filename); +} diff --git a/src/shared/src/platform/macStore.ts b/src/shared/src/platform/macStore.ts new file mode 100644 index 0000000..ca8cea7 --- /dev/null +++ b/src/shared/src/platform/macStore.ts @@ -0,0 +1,344 @@ +import { randomUUID } from "crypto"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { + BaseCertificateStore, + extractThumbprintHintFromFilename, + type UsableDevCert, +} from "./baseStore"; +import { runProcess } from "./processUtil"; +import { getCertificateVersion, isValidDevCert } from "../cert/validation"; +import { certToDer } from "../cert/exporter"; +import { ASPNET_HTTPS_OID } from "../cert/properties"; +import { DevCert, type DevKey } from "../cert/types"; + +/** + * macOS certificate store implementation. + * + * Storage locations: + * - Disk: ~/.aspnet/dev-certs/https/aspnetcore-localhost-{thumbprint}.pfx + * - Keychain: login keychain for trust validation + * + * Uses the `security` CLI for keychain trust operations. + */ +export class MacCertificateStore extends BaseCertificateStore { + private get devCertsDir(): string { + return path.join(os.homedir(), ".aspnet", "dev-certs", "https"); + } + + private get keychainPath(): string { + return path.join(os.homedir(), "Library", "Keychains", "login.keychain-db"); + } + + async findExistingDevCert(): Promise { + const context = "macOS login keychain"; + const usable: UsableDevCert[] = []; + const seenThumbprints = new Set(); + + // 1) Walk ~/.aspnet/dev-certs/https/. For each parseable PFX whose cert + // passes isValidDevCert, additionally verify a matching cert is + // actually present in the login keychain via `security find- + // certificate -Z ` (public-only, no prompt). PFXs whose cert + // isn't in the keychain are classified as "orphaned cache file" + // skipped entries and excluded from selection. + if (fs.existsSync(this.devCertsDir)) { + const pfxFiles = fs + .readdirSync(this.devCertsDir) + .filter( + (f) => f.startsWith("aspnetcore-localhost-") && f.endsWith(".pfx") + ); + + for (const pfxFile of pfxFiles) { + const pfxPath = path.join(this.devCertsDir, pfxFile); + const loaded = await this.loadPfxLenient(pfxPath); + + if (!loaded) { + this.classify({ + kind: "parseFailure", + source: pfxPath, + thumbprintHint: extractThumbprintHintFromFilename(pfxFile), + }); + continue; + } + + const classified = this.classify({ + kind: "loaded", + source: pfxPath, + loaded, + }); + if (classified === null) continue; + if (classified.kind !== "usable") { + seenThumbprints.add(loaded.thumbprint); + continue; + } + + // Verify keychain presence — no prompt, public-only. + const inKeychain = await this.isCertInKeychain(classified.thumbprint); + if (!inKeychain) { + this.classify({ + kind: "forcedSkip", + source: pfxPath, + reason: this.localize( + "PFX present on disk but matching certificate not in macOS login keychain (orphaned cache file)" + ), + metadata: { + thumbprint: classified.thumbprint, + subjectCN: classified.cert.subjectCN, + version: getCertificateVersion(classified.cert), + notBefore: classified.cert.notBefore, + notAfter: classified.cert.notAfter, + }, + }); + seenThumbprints.add(classified.thumbprint); + continue; + } + + seenThumbprints.add(classified.thumbprint); + usable.push(classified); + } + } + + // 2) Soft keychain enumeration — emit a warning for keychain-resident + // dev certs that lack a matching cache PFX. Public-only read, never + // triggers an ACL prompt, low EDR signal. + const keychainEntries = await this.enumerateKeychainDevCerts(); + for (const entry of keychainEntries) { + if (seenThumbprints.has(entry.thumbprint)) continue; + this.classify({ + kind: "forcedSkip", + source: context, + reason: this.localize( + "present in keychain but no matching PFX in {0}", + `${this.devCertsDir}/aspnetcore-localhost-${entry.thumbprint}.pfx` + ), + metadata: { + thumbprint: entry.thumbprint, + subjectCN: entry.cert.subjectCN, + version: getCertificateVersion(entry.cert), + notBefore: entry.cert.notBefore, + notAfter: entry.cert.notAfter, + }, + }); + } + + return this.selectBest(usable, this.devCertsDir); + } + + /** + * Returns true if a certificate with the given SHA-1 thumbprint is + * present in the login keychain. Uses `security find-certificate -Z` + * which only reads the public certificate — no ACL prompt is raised + * regardless of the cert's private-key ACL. + */ + private async isCertInKeychain(thumbprint: string): Promise { + const result = await runProcess("security", [ + "find-certificate", + "-Z", + thumbprint, + this.keychainPath, + ]); + return result.exitCode === 0; + } + + /** + * Enumerate ASP.NET dev cert candidates that exist in the login keychain. + * Returns parsed certs whose CN is `localhost`, that bear the ASP.NET + * custom OID, and whose validity window is current. Public-only, no + * prompts. + */ + private async enumerateKeychainDevCerts(): Promise< + Array<{ cert: DevCert; thumbprint: string }> + > { + const result = await runProcess("security", [ + "find-certificate", + "-a", + "-p", + "-Z", + this.keychainPath, + ]); + if (result.exitCode !== 0) return []; + + const out: Array<{ cert: DevCert; thumbprint: string }> = []; + const pemBlocks = extractPemBlocks(result.stdout); + for (const pem of pemBlocks) { + try { + const cert = new DevCert(pem); + if (cert.subjectCN !== "localhost") continue; + if (!cert.hasExtension(ASPNET_HTTPS_OID)) continue; + if (!isValidDevCert(cert)) continue; + out.push({ cert, thumbprint: cert.thumbprintSha1 }); + } catch { + // Skip lines that don't parse as a cert (the -a -p -Z output + // includes SHA-1 lines interleaved with PEM blocks). + } + } + return out; + } + + async saveCertificate( + cert: DevCert, + key: DevKey, + thumbprint: string + ): Promise { + fs.mkdirSync(this.devCertsDir, { recursive: true }); + const pfxPath = path.join( + this.devCertsDir, + `aspnetcore-localhost-${thumbprint}.pfx` + ); + // ~/.aspnet/dev-certs/https/*.pfx contains the private key; force 0o600 + // so it can't be read by other users on a multi-user mac. + await this.writePfx(cert, key, pfxPath, "", 0o600); + } + + async trustCertificate(cert: DevCert): Promise { + // /tmp is shared on macOS; an unguessable filename rules out symlink + // races on the temporary public-cert artifact. + const tmpCert = path.join(os.tmpdir(), `devcert-trust-${randomUUID()}.cer`); + fs.writeFileSync(tmpCert, certToDer(cert)); + + const result = await runProcess("security", [ + "add-trusted-cert", + "-p", + "basic", + "-p", + "ssl", + "-k", + this.keychainPath, + tmpCert, + ]); + + try { + fs.unlinkSync(tmpCert); + } catch { + /* ignore */ + } + + if (result.exitCode !== 0) { + throw new Error( + `Failed to trust certificate in keychain: ${result.stderr}` + ); + } + } + + async removeCertificates(): Promise { + // For each PFX we manage on disk, load it, run untrust + delete-from- + // keychain by thumbprint, then unlink the PFX. Three-step structure + // because trust settings are stored separately from the cert (in + // TrustSettings.plist) and reference it by hash — if we delete the + // cert from the keychain first, the trust settings become orphaned + // dangling entries that the next `add-trusted-cert` may flag as + // duplicates. + // + // Matching dev certs by filename (`aspnetcore-localhost-*.pfx`) + + // the dev-cert OID is narrower than matching keychain entries by + // `-c localhost`: the user may have unrelated `localhost` certs + // added for other tools, and bulk-untrusting by keychain or by CN + // would nuke those too. + if (!fs.existsSync(this.devCertsDir)) return; + + const pfxFiles = fs + .readdirSync(this.devCertsDir) + .filter( + (f) => f.startsWith("aspnetcore-localhost-") && f.endsWith(".pfx") + ); + + for (const pfxFile of pfxFiles) { + const pfxPath = path.join(this.devCertsDir, pfxFile); + let parsed: Awaited>; + try { + parsed = await this.loadPfx(pfxPath); + } catch { + // Unparseable — skip the untrust step but still unlink below + // so we don't leave stale files around. + parsed = null; + } + + if (parsed && parsed.cert.hasExtension(ASPNET_HTTPS_OID)) { + // Step 1: untrust. `security remove-trusted-cert` takes a + // cert file (DER / PEM) as its positional, NOT a keychain + // path. Trust settings were added without `-d` (user domain, + // matching `add-trusted-cert` above), so we remove without + // `-d` too. Non-zero exit just means there was no trust + // settings entry to remove — not an error in cleanup. + const tmpCert = path.join( + os.tmpdir(), + `devcert-untrust-${randomUUID()}.cer` + ); + fs.writeFileSync(tmpCert, certToDer(parsed.cert)); + try { + await runProcess("security", ["remove-trusted-cert", tmpCert]); + } finally { + try { + fs.unlinkSync(tmpCert); + } catch { + /* ignore */ + } + } + + // Step 2: delete the keychain entries. delete-certificate exits + // non-zero once there are no more entries matching the hash; + // loop with a generous bound to drain any duplicates left by + // past regenerations. + for (let i = 0; i < 100; i++) { + const result = await runProcess("security", [ + "delete-certificate", + "-Z", + parsed.thumbprint, + this.keychainPath, + ]); + if (result.exitCode !== 0) break; + } + } + + // Step 3: unlink the PFX. Done last so a mid-cleanup interruption + // leaves the file in place and the cleanup is restartable. + try { + fs.unlinkSync(pfxPath); + } catch { + /* ignore */ + } + } + } + + protected async isTrusted( + cert: DevCert, + _thumbprint: string + ): Promise { + const tmpCert = path.join(os.tmpdir(), `devcert-verify-${randomUUID()}.cer`); + try { + fs.writeFileSync(tmpCert, certToDer(cert)); + + const result = await runProcess("security", [ + "verify-cert", + "-c", + tmpCert, + "-p", + "ssl", + ]); + + return result.exitCode === 0; + } finally { + try { + fs.unlinkSync(tmpCert); + } catch { + // best effort cleanup + } + } + } +} + +/** + * Pull PEM-encoded CERTIFICATE blocks out of `security find-certificate -p` + * output. The CLI interleaves SHA-1 hash lines (when `-Z` is passed) with + * the PEM blocks; we just grab the blocks. + */ +function extractPemBlocks(text: string): string[] { + const blocks: string[] = []; + const regex = /-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g; + let m: RegExpExecArray | null; + while ((m = regex.exec(text)) !== null) { + blocks.push(m[0]); + } + return blocks; +} diff --git a/src/shared/src/platform/nssTrust.ts b/src/shared/src/platform/nssTrust.ts new file mode 100644 index 0000000..d89b5f4 --- /dev/null +++ b/src/shared/src/platform/nssTrust.ts @@ -0,0 +1,264 @@ +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { runProcess } from "./processUtil"; +import { log } from "../logger"; + +export interface NssTrustResult { + success: boolean; + message: string; +} + +const CERT_NAME = "Dev Container Dev Cert"; + +type NssTargetKind = "chromium-shared" | "firefox-profiles"; + +interface NssTarget { + label: string; + kind: NssTargetKind; + root: string; +} + +interface DbOutcome { + label: string; + ok: boolean; + stderr?: string; +} + +/** + * Enumerate well-known NSS database roots on Linux. Covers native packages, + * Snap and Flatpak sandbox roots, and a handful of Firefox forks that keep + * their own profile directory. The list is ordered most-common-first; missing + * roots are silently skipped at scan time. + */ +function getNssTargets(home: string): NssTarget[] { + const chromium = (label: string, ...segs: string[]): NssTarget => ({ + label, + kind: "chromium-shared", + root: path.join(home, ...segs), + }); + const firefox = (label: string, ...segs: string[]): NssTarget => ({ + label, + kind: "firefox-profiles", + root: path.join(home, ...segs), + }); + + return [ + // Chromium-family browsers share a single user NSS DB. The native + // ~/.pki/nssdb path is the historical shared store used by Chromium, + // Chrome, Brave, Vivaldi, Edge, and Opera when installed as deb/rpm/AUR. + chromium("Chromium", ".pki", "nssdb"), + chromium("Chromium (Snap)", "snap", "chromium", "common", ".pki", "nssdb"), + chromium( + "Chromium (Flatpak)", + ".var", + "app", + "org.chromium.Chromium", + ".pki", + "nssdb" + ), + chromium( + "Chrome (Flatpak)", + ".var", + "app", + "com.google.Chrome", + ".pki", + "nssdb" + ), + chromium( + "Brave (Flatpak)", + ".var", + "app", + "com.brave.Browser", + ".pki", + "nssdb" + ), + chromium( + "Vivaldi (Flatpak)", + ".var", + "app", + "com.vivaldi.Vivaldi", + ".pki", + "nssdb" + ), + chromium( + "Edge (Flatpak)", + ".var", + "app", + "com.microsoft.Edge", + ".pki", + "nssdb" + ), + + // Firefox-family browsers keep an NSS DB per profile under a known root. + firefox("Firefox", ".mozilla", "firefox"), + firefox( + "Firefox (Snap)", + "snap", + "firefox", + "common", + ".mozilla", + "firefox" + ), + firefox( + "Firefox (Flatpak)", + ".var", + "app", + "org.mozilla.firefox", + ".mozilla", + "firefox" + ), + firefox("Firefox ESR", ".mozilla", "firefox-esr"), + firefox("LibreWolf", ".librewolf"), + firefox( + "LibreWolf (Flatpak)", + ".var", + "app", + "io.gitlab.librewolf-community", + ".librewolf" + ), + firefox("Waterfox", ".waterfox"), + firefox("Floorp", ".floorp"), + ]; +} + +/** + * Attempt to trust a PEM certificate in NSS databases used by Linux browsers. + * + * Requires `certutil` on the PATH (from libnss3-tools / nss-tools / nss). + * Enumerates well-known NSS database roots — native deb/rpm installs, Snap + * and Flatpak sandboxes, and Firefox forks — and adds the certificate to each + * existing database. Targets that aren't installed are skipped silently; the + * returned message lists only databases we actually touched. + */ +export async function trustInNss(pemPath: string): Promise { + const which = await runProcess("which", ["certutil"]); + if (which.exitCode !== 0) { + return { + success: false, + message: + "certutil is not installed. Install libnss3-tools (Debian/Ubuntu), nss-tools (Fedora/RHEL), or nss (Arch) to enable automatic browser trust.", + }; + } + + const outcomes: DbOutcome[] = []; + const targets = getNssTargets(os.homedir()); + + for (const target of targets) { + if (target.kind === "chromium-shared") { + await scanChromiumShared(target, pemPath, outcomes); + } else { + await scanFirefoxProfiles(target, pemPath, outcomes); + } + } + + if (outcomes.length === 0) { + return { + success: false, + message: + "No browser NSS databases found. Open Firefox or Chromium at least once to create a profile, then try again.", + }; + } + + const trusted = outcomes.filter((o) => o.ok).map((o) => o.label); + const failed = outcomes.filter((o) => !o.ok); + + const parts: string[] = []; + if (trusted.length > 0) parts.push(`Trusted in: ${trusted.join(", ")}`); + for (const f of failed) { + parts.push(`${f.label}: failed (${f.stderr?.trim() ?? "unknown error"})`); + } + + return { + success: failed.length === 0, + message: parts.join("; "), + }; +} + +async function scanChromiumShared( + target: NssTarget, + pemPath: string, + outcomes: DbOutcome[] +): Promise { + if (!fs.existsSync(path.join(target.root, "cert9.db"))) { + log(`NSS scan: ${target.label} not present at ${target.root}, skipping.`); + return; + } + const r = await trustInNssDb(`sql:${target.root}`, pemPath); + outcomes.push({ + label: target.label, + ok: r.exitCode === 0, + stderr: r.stderr, + }); +} + +async function scanFirefoxProfiles( + target: NssTarget, + pemPath: string, + outcomes: DbOutcome[] +): Promise { + if (!fs.existsSync(target.root)) { + log(`NSS scan: ${target.label} not present at ${target.root}, skipping.`); + return; + } + + let entries: string[]; + try { + entries = fs.readdirSync(target.root); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + log(`NSS scan: failed to enumerate ${target.label} profiles: ${message}`); + return; + } + + const profiles = entries.filter((d) => { + try { + return fs.existsSync(path.join(target.root, d, "cert9.db")); + } catch { + return false; + } + }); + + if (profiles.length === 0) { + log(`NSS scan: ${target.label} has no profiles with cert9.db, skipping.`); + return; + } + + for (const profile of profiles) { + const dbPath = path.join(target.root, profile); + const r = await trustInNssDb(`sql:${dbPath}`, pemPath); + outcomes.push({ + label: `${target.label} (${profile})`, + ok: r.exitCode === 0, + stderr: r.stderr, + }); + } +} + +async function trustInNssDb( + dbArg: string, + pemPath: string +): Promise<{ exitCode: number; stderr: string }> { + // Remove any existing cert with this name first to make the operation idempotent + await runProcess("certutil", ["-D", "-d", dbArg, "-n", CERT_NAME]); + + const result = await runProcess("certutil", [ + "-A", + "-d", + dbArg, + "-t", + "CT,,", + "-n", + CERT_NAME, + "-i", + pemPath, + ]); + + if (result.exitCode === 0) { + log(`Trusted cert in NSS database: ${dbArg}`); + } else { + log(`Failed to trust cert in ${dbArg}: ${result.stderr}`); + } + + return { exitCode: result.exitCode, stderr: result.stderr }; +} diff --git a/src/shared/src/platform/processUtil.ts b/src/shared/src/platform/processUtil.ts new file mode 100644 index 0000000..34e452b --- /dev/null +++ b/src/shared/src/platform/processUtil.ts @@ -0,0 +1,165 @@ +import { execFile } from "child_process"; +import * as fs from "fs"; +import * as path from "path"; +import { promisify } from "util"; + +const execFileAsync = promisify(execFile); + +export interface ProcessResult { + exitCode: number; + stdout: string; + stderr: string; +} + +export interface ResolveSafeExecPathOptions { + /** + * Override the detected platform. Default is `process.platform`. Tests + * use this to drive the Windows branch from Linux/macOS hosts. + */ + platform?: NodeJS.Platform; + /** + * PATH-like delimiter-separated list of directories to search. Default + * is `process.env.PATH`. + */ + searchPath?: string; + /** + * PATHEXT-style semicolon-separated list of executable suffixes to try + * when the command has no extension. Default is `process.env.PATHEXT`, + * falling back to the conventional Windows set if that's unset. + */ + pathExt?: string; + /** + * File-existence probe. Injectable so tests can drive the resolver + * without writing real fixture files to disk. + */ + fileExists?: (candidate: string) => boolean; +} + +/** + * Resolve a bare command name (`"dotnet"`, `"certutil.exe"`) to an + * absolute path on the executable search PATH, defeating Windows' + * cwd-first `CreateProcess` lookup. By handing `child_process.spawn` + * an absolute path, we skip the application-dir / cwd / system-dirs / + * PATH cascade entirely — a malicious `dotnet.exe` dropped into the + * user's workspace by a compromised dev-container repo can no longer + * hijack our shell-outs. + * + * Behavior: + * + * - Non-Windows hosts: returns the command unchanged. `execvp`-family + * syscalls on Linux / macOS never consult `cwd`, so the cwd-first + * hijack vector doesn't exist there and we save the disk walk. + * - Absolute paths or paths containing a separator: pass through + * verbatim. The caller has expressed explicit intent (e.g. an env + * var override) and we don't second-guess. + * - Bare names on Windows: scan PATH, respecting PATHEXT for implicit + * extensions. Returns `null` if the command isn't found on PATH — + * the caller (typically `runProcess`) translates that into a + * well-defined "command not found" `ProcessResult` rather than + * falling through to the unsafe spawn. + * - Relative PATH entries are skipped on Windows. A user who's added + * `.` (or `bin`, or any other relative directory) to PATH would + * re-introduce the exact hijack we're trying to prevent. Filtering + * them costs nothing for normal setups and provides defense in + * depth for hostile ones. + */ +export function resolveSafeExecPath( + command: string, + options: ResolveSafeExecPathOptions = {} +): string | null { + const platform = options.platform ?? process.platform; + + if (platform !== "win32") { + return command; + } + + // Use Windows path semantics regardless of the host OS — tests drive + // the Windows branch from Linux/macOS via the `platform` override and + // expect `C:\...` to be recognized as absolute, `;` as the PATH + // delimiter, etc. + const winPath = path.win32; + + if ( + winPath.isAbsolute(command) || + command.includes("/") || + command.includes("\\") + ) { + return command; + } + + const searchPath = options.searchPath ?? process.env.PATH ?? ""; + const pathExt = + options.pathExt ?? process.env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD"; + const fileExists = options.fileExists ?? fs.existsSync; + + const dirs = searchPath + .split(winPath.delimiter) + .map((d) => d.trim()) + .filter((d) => d.length > 0 && winPath.isAbsolute(d)); + const extensions = pathExt + .split(";") + .map((e) => e.trim().toLowerCase()) + .filter((e) => e.length > 0); + + const commandLower = command.toLowerCase(); + const hasExplicitExtension = extensions.some((ext) => + commandLower.endsWith(ext) + ); + + for (const dir of dirs) { + if (hasExplicitExtension) { + const candidate = winPath.join(dir, command); + if (fileExists(candidate)) return candidate; + } else { + for (const ext of extensions) { + const candidate = winPath.join(dir, command + ext); + if (fileExists(candidate)) return candidate; + } + } + } + + return null; +} + +/** + * Run an external process and return its exit code, stdout, and stderr. + * Does not throw on non-zero exit codes. + * + * On Windows, the command is resolved through `resolveSafeExecPath` + * before being handed to `execFile`. This is the single chokepoint for + * all of our shell-outs (dotnet backend, certutil.exe in the Windows + * store, pwsh, openssl, etc.), so the cwd-planting defense applies + * uniformly. Commands that resolve to `null` (not found on PATH) return + * `exitCode: 127` rather than falling through to a `cwd`-first spawn. + */ +export async function runProcess( + command: string, + args: string[], + timeout: number = 30000 +): Promise { + const resolved = resolveSafeExecPath(command); + if (resolved === null) { + return { + exitCode: 127, + stdout: "", + stderr: `command not found on PATH: ${command}`, + }; + } + try { + const result = await execFileAsync(resolved, args, { timeout }); + return { exitCode: 0, stdout: result.stdout, stderr: result.stderr }; + } catch (err: unknown) { + const error = err as Error & { + code?: number | string; + stdout?: string; + stderr?: string; + }; + // If the process ran but returned non-zero, we still have stdout/stderr + const exitCode = typeof error.code === "number" ? error.code : 1; + return { + exitCode, + stdout: error.stdout ?? "", + stderr: error.stderr ?? error.message, + }; + } +} diff --git a/src/shared/src/platform/types.ts b/src/shared/src/platform/types.ts new file mode 100644 index 0000000..f67f462 --- /dev/null +++ b/src/shared/src/platform/types.ts @@ -0,0 +1,127 @@ +import { type DevCert, type DevKey } from "../cert/types"; +import { type Localizer } from "../localizer"; +import { type NssTrustResult } from "./nssTrust"; + +/** + * Callback the Linux store invokes after attempting browser-NSS trust as + * part of `trustCertificate`. Lets the extension host surface a guidance + * toast on failure without giving the platform store a direct dependency + * on `vscode`. + */ +export type LinuxNssTrustReporter = ( + result: NssTrustResult, + pemPath: string +) => void; + +/** + * Common options accepted by every platform certificate store. Currently + * carries the host-supplied `Localizer` so log lines produced inside the + * shared platform layer match what the host extension surfaces via + * `vscode.l10n.t`. Non-VS-Code consumers (CLI, scripts, tests) can omit it + * and fall back to the identity localizer. + */ +export interface BaseStoreOptions { + /** Optional Localizer; defaults to `identityLocalizer`. */ + localize?: Localizer; +} + +export interface CreatePlatformStoreOptions extends BaseStoreOptions { + /** Optional reporter for NSS trust outcomes; honored only on Linux. */ + linuxNssTrustReporter?: LinuxNssTrustReporter; +} + +export interface CertificateStatus { + exists: boolean; + isTrusted: boolean; + /** SHA-1 thumbprint, uppercase hex (matches `X509Certificate2.Thumbprint`). */ + thumbprint: string | null; + notBefore: string | null; + notAfter: string | null; + version: number; +} + +/** + * Platform-specific certificate store interface. + * + * Throughout this interface, `thumbprint` is the SHA-1 thumbprint + * (`DevCert.thumbprintSha1`). This is what .NET, OpenSSL trust dirs, and + * the Windows / macOS stores use to identify and name cert files. The + * stronger `DevCert.thumbprint` (SHA-256) is for in-process identity only. + */ +export interface PlatformCertificateStore { + /** + * Find an existing valid ASP.NET dev cert in the platform store. + * Returns the cert, key, and SHA-1 thumbprint if found. + */ + findExistingDevCert(): Promise<{ + cert: DevCert; + key: DevKey; + thumbprint: string; + } | null>; + + /** + * Save a certificate with its private key to the platform store. + * `thumbprint` is the SHA-1 thumbprint and becomes the .NET X509Store + * filename stem (`{thumbprint}.pfx`). + */ + saveCertificate( + cert: DevCert, + key: DevKey, + thumbprint: string + ): Promise; + + /** + * Trust a certificate so the OS/browser accepts it. + */ + trustCertificate(cert: DevCert): Promise; + + /** + * Verify on-disk / OS trust state for a specific certificate. Callers + * use this to short-circuit redundant `trustCertificate` calls — on + * macOS in particular, `security add-trusted-cert` is not a true + * no-op for an already-trusted cert (it re-touches the trust-settings + * record and may re-prompt for the keychain password). The cache that + * the host's CertProvider maintains is a goal-state, not a record of + * machine state — every trust operation re-verifies trust here + * before deciding whether to invoke trustCertificate again. + */ + isCertTrusted(cert: DevCert): Promise; + + /** + * Remove dev certificates from all stores. + */ + removeCertificates(): Promise; + + /** + * Check the status of the dev certificate. + */ + checkStatus(): Promise; +} + +/** + * Create the appropriate store for the current platform. + */ +export async function createPlatformStore( + options: CreatePlatformStoreOptions = {} +): Promise { + const baseOptions: BaseStoreOptions = { localize: options.localize }; + switch (process.platform) { + case "win32": { + const { WindowsCertificateStore } = await import("./windowsStore.js"); + return new WindowsCertificateStore("CurrentUser", baseOptions); + } + case "darwin": { + const { MacCertificateStore } = await import("./macStore.js"); + return new MacCertificateStore(baseOptions); + } + case "linux": { + const { LinuxCertificateStore } = await import("./linuxStore.js"); + return new LinuxCertificateStore({ + ...baseOptions, + nssTrustReporter: options.linuxNssTrustReporter, + }); + } + default: + throw new Error(`Unsupported platform: ${process.platform}`); + } +} diff --git a/src/shared/src/platform/windowsStore.ts b/src/shared/src/platform/windowsStore.ts new file mode 100644 index 0000000..34bbddd --- /dev/null +++ b/src/shared/src/platform/windowsStore.ts @@ -0,0 +1,430 @@ +import { randomUUID } from "crypto"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { + BaseCertificateStore, + type UsableDevCert, +} from "./baseStore"; +import { runProcess } from "./processUtil"; +import { type BaseStoreOptions } from "./types"; +import { ASPNET_HTTPS_OID } from "../cert/properties"; +import { certToDer } from "../cert/exporter"; +import { type DevCert, type DevKey } from "../cert/types"; + +/** + * Cached PowerShell executable name — prefers pwsh (PowerShell 7+) over + * powershell (5.1). Only the POSITIVE result is cached: if the first + * probe finds pwsh, every subsequent call returns it without re-probing. + * If the probe DOESN'T find pwsh, we re-probe on each call rather than + * pinning to powershell forever — Windows installers commonly defer + * `PATH` propagation to existing processes, so a freshly-installed pwsh + * may only become discoverable after the first few cert ops. + */ +let resolvedPwsh: "pwsh" | null = null; + +export type WindowsStoreLocation = "CurrentUser" | "LocalMachine"; + +async function getPowerShell(): Promise { + if (resolvedPwsh === "pwsh") return resolvedPwsh; + + const pwshResult = await runProcess("pwsh", ["-NoProfile", "-Command", "echo ok"]); + if (pwshResult.exitCode === 0) { + resolvedPwsh = "pwsh"; + return "pwsh"; + } + // Don't cache the fallback — re-probe next call so a freshly-installed + // pwsh becomes available without needing to restart the extension host. + return "powershell"; +} + +/** + * Shape of one entry in the PowerShell enumeration script's `candidates` + * array — a cert whose private key was successfully exported as a PFX. + */ +export interface PsCandidate { + thumbprint: string; + pfxPath: string; + subjectCN: string | null; + notBefore: string; + notAfter: string; +} + +/** Classification of why the PS script couldn't export a cert. */ +export type PsSkipReason = + | "no-private-key" + | "not-exportable" + | "export-failed"; + +/** + * Shape of one entry in the PowerShell enumeration script's `skipped` + * array — a cert that matched the dev-cert OID but couldn't be exported + * (no private key, or key not exportable). + * + * `reasonDetail` carries the underlying exception message for the + * "export-failed" code (we can't classify it more precisely without + * reaching into .NET-specific types). TS-side maps the code to a + * localized human-readable reason; details get appended verbatim. + */ +export interface PsSkipped { + thumbprint: string; + subjectCN: string | null; + notBefore: string; + notAfter: string; + reasonCode: PsSkipReason; + reasonDetail?: string; +} + +export interface PsEnumeration { + candidates: PsCandidate[]; + skipped: PsSkipped[]; +} + +/** + * Windows certificate store implementation. + * + * Uses PowerShell to interact with the Windows Certificate Store: + * - CurrentUser\My: stores cert with private key + * - CurrentUser\Root: trusts the public cert + * + * Prefers pwsh (PowerShell 7+) when available, falls back to powershell (5.1). + */ +export class WindowsCertificateStore extends BaseCertificateStore { + private readonly storeLocation: WindowsStoreLocation; + + constructor( + storeLocation: WindowsStoreLocation = "CurrentUser", + options: BaseStoreOptions = {} + ) { + super(options); + this.storeLocation = storeLocation; + } + + async findExistingDevCert(): Promise { + // Enumerate every dev-cert candidate in the configured My store, then + // attempt to export each as a PBES2/AES PFX. We hand selection back to + // shared TS so the version-byte tiebreaker logic stays in one place. + // + // The script stays inside the PS cert-provider surface and built-in + // cmdlets — no `New-Object System.*`, no explicit [System.X.Y.Z] type + // references, no .NET-specific property paths (e.g. CNG-only + // PrivateKey.Key.ExportPolicy). Properties accessed on the + // X509Certificate2 objects yielded by `Cert:\…` are the same surface + // the rest of the codebase already relies on (Thumbprint, Subject, + // NotBefore/NotAfter, HasPrivateKey, Extensions). + const script = ` + $ErrorActionPreference = 'Stop' + $oid = '${ASPNET_HTTPS_OID}' + $candidates = @() + $skipped = @() + $certs = Get-ChildItem Cert:\\${this.storeLocation}\\My | Where-Object { + $_.Extensions | Where-Object { $_.Oid.Value -eq $oid } + } + foreach ($cert in $certs) { + $thumb = $cert.Thumbprint + # Subject is a comma-separated RDN string ("CN=localhost, O=..."). Pull + # the first CN out via regex instead of GetNameInfo, which would need + # an explicit [System.Security.Cryptography.X509Certificates.X509NameType] + # type reference. + $cn = $null + if ($cert.Subject -match 'CN=([^,]+)') { $cn = $matches[1].Trim() } + $nbf = $cert.NotBefore.ToUniversalTime().ToString('o') + $exp = $cert.NotAfter.ToUniversalTime().ToString('o') + if (-not $cert.HasPrivateKey) { + $skipped += @{ thumbprint = $thumb; subjectCN = $cn; notBefore = $nbf; notAfter = $exp; reasonCode = 'no-private-key' } + continue + } + try { + $tmpPfx = Join-Path $env:TEMP ("devcert-" + [guid]::NewGuid().ToString("N") + ".pfx") + $pwd = ConvertTo-SecureString -String 'export' -Force -AsPlainText + # AES256_SHA256 forces a PBES2/AES PFX. The default (TripleDES_SHA1) + # produces a legacy PKCS#12 PBE format that our pkijs-based parser + # deliberately rejects (see cert/pfx.ts). + Export-PfxCertificate -Cert $cert -FilePath $tmpPfx -Password $pwd -CryptoAlgorithmOption AES256_SHA256 | Out-Null + $candidates += @{ thumbprint = $thumb; pfxPath = $tmpPfx; subjectCN = $cn; notBefore = $nbf; notAfter = $exp } + } catch { + # No CNG / RSA-specific introspection here — that would mean + # touching .NET types beyond what the cert provider already + # surfaces. Coarse message-string matching distinguishes the + # "key locked" case from everything else; TS-side maps the code + # to a localized human-readable reason. + $msg = $_.Exception.Message + if ($msg -match 'not exportable' -or $msg -match 'cannot be exported') { + $skipped += @{ thumbprint = $thumb; subjectCN = $cn; notBefore = $nbf; notAfter = $exp; reasonCode = 'not-exportable' } + } else { + $skipped += @{ thumbprint = $thumb; subjectCN = $cn; notBefore = $nbf; notAfter = $exp; reasonCode = 'export-failed'; reasonDetail = $msg } + } + } + } + $payload = @{ candidates = $candidates; skipped = $skipped } + $payload | ConvertTo-Json -Compress -Depth 4 + `; + + const pwsh = await getPowerShell(); + const result = await runProcess(pwsh, [ + "-NoProfile", + "-NonInteractive", + "-Command", + script, + ]); + + if (result.exitCode !== 0) return null; + + const parsed = parseEnumeration(result.stdout); + if (!parsed) return null; + + const tempPfxPaths: string[] = parsed.candidates.map((c) => c.pfxPath); + try { + const usable: UsableDevCert[] = []; + const storeContext = `Windows ${this.storeLocation}\\My`; + + for (const cand of parsed.candidates) { + const loaded = await this.loadPfxLenient(cand.pfxPath, "export"); + if (!loaded) { + // PFX produced by Export-PfxCertificate failed to parse — surface + // as an unusable warning so the user has visibility. + this.classify({ + kind: "forcedSkip", + source: storeContext, + reason: this.localize( + "Export-PfxCertificate produced a PFX that could not be parsed" + ), + metadata: { + thumbprint: cand.thumbprint, + subjectCN: cand.subjectCN, + notBefore: parseDateOrNull(cand.notBefore), + notAfter: parseDateOrNull(cand.notAfter), + }, + }); + continue; + } + + const classified = this.classify({ + kind: "loaded", + source: storeContext, + loaded, + }); + if (classified === null) continue; + if (classified.kind === "usable") usable.push(classified); + } + + for (const sk of parsed.skipped) { + // Re-apply isValidDevCert gates against the metadata we received so + // we don't emit the unusable warning for clearly-unrelated certs + // that happened to share the OID but are e.g. expired. + if (!metadataLooksLikeValidDevCert(sk)) continue; + this.classify({ + kind: "forcedSkip", + source: storeContext, + reason: this.localizeSkipReason(sk), + metadata: { + thumbprint: sk.thumbprint, + subjectCN: sk.subjectCN, + notBefore: parseDateOrNull(sk.notBefore), + notAfter: parseDateOrNull(sk.notAfter), + }, + }); + } + + return this.selectBest(usable, storeContext); + } finally { + for (const p of tempPfxPaths) { + try { + fs.unlinkSync(p); + } catch { + // best effort cleanup + } + } + } + } + + async saveCertificate( + cert: DevCert, + key: DevKey, + _thumbprint: string + ): Promise { + // Export to temp PFX, then import via Import-PfxCertificate. Our + // hand-rolled DER PFX writer (cert/pfx.ts) emits a PFX that CryptoAPI's + // PFXImportCertStore — the function this cmdlet wraps — accepts cleanly. + // Unguessable filename + 0o600 keeps the PFX (with private key) + // unreadable to other local users during the import window. + const tmpPfx = path.join(os.tmpdir(), `devcert-save-${randomUUID()}.pfx`); + await this.writePfx(cert, key, tmpPfx, "import", 0o600); + + const script = + `$ErrorActionPreference = 'Stop'; ` + + `$pwd = ConvertTo-SecureString -String 'import' -Force -AsPlainText; ` + + `Import-PfxCertificate -FilePath '${tmpPfx.replace(/'/g, "''")}' -CertStoreLocation Cert:\\${this.storeLocation}\\My -Password $pwd -Exportable | Out-Null; ` + + `Remove-Item '${tmpPfx.replace(/'/g, "''")}'`; + + const pwsh = await getPowerShell(); + const result = await runProcess(pwsh, [ + "-NoProfile", + "-NonInteractive", + "-Command", + script, + ]); + + if (result.exitCode !== 0) { + // Clean up temp file if PowerShell didn't + try { + fs.unlinkSync(tmpPfx); + } catch { + /* ignore */ + } + throw new Error( + `Failed to save certificate to Windows store: ${result.stderr}` + ); + } + } + + async trustCertificate(cert: DevCert): Promise { + // Use certutil.exe — the built-in Windows CA admin tool from + // %SystemRoot%\System32, present on every Windows install since XP — to + // add the public cert to the configured Root store. + // + // We can't go back to `Import-Certificate` (the PowerShell PKI cmdlet + // PR #36 switched to): when it targets Cert:\CurrentUser\Root the + // underlying CryptoAPI call shows a "You are about to install a + // certificate from a certification authority..." confirmation dialog, + // which fails under `-NonInteractive` with "UI is not allowed in this + // operation." We also intentionally avoid the older path of + // `New-Object System.Security.Cryptography.X509Certificates.X509Store` + // — the host extension is meant to work without taking on a .NET + // dependency. certutil.exe uses CryptoAPI directly, skips the + // confirmation dialog, and is the same tool mkcert and similar dev-cert + // utilities use on Windows for the same reason. + // + // Public-cert only — no private key — but the random name still + // prevents concurrent invocations from colliding on the same temp path. + const tmpCert = path.join(os.tmpdir(), `devcert-trust-${randomUUID()}.cer`); + fs.writeFileSync(tmpCert, certToDer(cert)); + + const args = ["-f"]; + if (this.storeLocation === "CurrentUser") args.push("-user"); + args.push("-addstore", "Root", tmpCert); + + const result = await runProcess("certutil.exe", args); + + try { + fs.unlinkSync(tmpCert); + } catch { + /* best effort */ + } + + if (result.exitCode !== 0) { + throw new Error( + `Failed to trust certificate on Windows: ${result.stderr || result.stdout}` + ); + } + } + + async removeCertificates(): Promise { + const script = ` + $ErrorActionPreference = 'SilentlyContinue' + $oid = '${ASPNET_HTTPS_OID}' + foreach ($storePath in @('Cert:\\${this.storeLocation}\\My', 'Cert:\\${this.storeLocation}\\Root')) { + Get-ChildItem $storePath | Where-Object { + $_.Extensions | Where-Object { $_.Oid.Value -eq $oid } + } | ForEach-Object { + Remove-Item -LiteralPath $_.PSPath -Force + } + } + `; + + const pwsh = await getPowerShell(); + await runProcess(pwsh, [ + "-NoProfile", + "-NonInteractive", + "-Command", + script, + ]); + } + + protected async isTrusted( + _cert: DevCert, + thumbprint: string + ): Promise { + const script = ` + $cert = Get-ChildItem Cert:\\${this.storeLocation}\\Root | Where-Object { $_.Thumbprint -eq '${thumbprint}' } + if ($cert) { Write-Output 'true' } else { Write-Output 'false' } + `; + + const pwsh = await getPowerShell(); + const result = await runProcess(pwsh, [ + "-NoProfile", + "-NonInteractive", + "-Command", + script, + ]); + + return result.stdout.trim() === "true"; + } + + private localizeSkipReason(sk: PsSkipped): string { + switch (sk.reasonCode) { + case "no-private-key": + return this.localize("no private key in store"); + case "not-exportable": + return this.localize("private key not exportable"); + case "export-failed": + return this.localize( + "Export-PfxCertificate failed: {0}", + sk.reasonDetail ?? "" + ); + } + } +} + +function parseEnumeration(stdout: string): PsEnumeration | null { + const trimmed = stdout.trim(); + if (!trimmed) return { candidates: [], skipped: [] }; + + let parsed: unknown; + try { + parsed = JSON.parse(trimmed); + } catch { + return null; + } + + // PowerShell's ConvertTo-Json emits a hashtable with a single child as a + // bare object rather than a 1-element array; coerce defensively. + if (!parsed || typeof parsed !== "object") return null; + const root = parsed as Record; + return { + candidates: coerceArray(root.candidates), + skipped: coerceArray(root.skipped), + }; +} + +function coerceArray(value: unknown): T[] { + if (value === undefined || value === null) return []; + if (Array.isArray(value)) return value as T[]; + return [value as T]; +} + +function parseDateOrNull(s: string | null | undefined): Date | null { + if (!s) return null; + const d = new Date(s); + return Number.isNaN(d.getTime()) ? null : d; +} + +function metadataLooksLikeValidDevCert(sk: PsSkipped): boolean { + // The PS script already filtered by ASPNET_HTTPS_OID. We layer CN + + // validity-window checks on top so we don't emit the unusable warning + // for clearly-unrelated certs (e.g. expired or differently-named) that + // happened to carry the same OID. We can't check the version byte from + // TS without parsing the cert, so we skip that gate here. + // + // CN comparison is intentionally exact-match to mirror isValidDevCert + // (see cert/validation.ts:isValidDevCert) — staying loose here while the + // canonical gate is strict would just produce warnings for certs we'd + // never accept downstream anyway. + if (sk.subjectCN !== "localhost") return false; + const nbf = parseDateOrNull(sk.notBefore); + const exp = parseDateOrNull(sk.notAfter); + if (!nbf || !exp) return false; + const now = new Date(); + if (nbf > now || exp < now) return false; + return true; +} diff --git a/src/vscode-ui-extension/package.json b/src/vscode-ui-extension/package.json index 5c6544f..0ba6ee5 100644 --- a/src/vscode-ui-extension/package.json +++ b/src/vscode-ui-extension/package.json @@ -3,7 +3,7 @@ "displayName": "Dev Container Dev Certificates (Host)", "description": "Generates and trusts ASP.NET and Aspire compatible HTTPS development certificates on the host machine for use in Dev Containers and remote environments.", "icon": "images/dn_ui_extension_icon_256.png", - "version": "1.3.2-pre", + "version": "1.4.0-pre.1", "publisher": "dnegstad", "license": "MIT", "repository": { @@ -56,6 +56,21 @@ "default": true, "description": "Auto-generate the ASP.NET Core / Aspire compatible HTTPS development certificate and trust it in the host OS store. When false, user-managed certificates (if any) are still synced but no dev cert is generated." }, + "devcontainerDevCerts.hostCertGenerator": { + "type": "string", + "enum": [ + "auto", + "native", + "dotnet" + ], + "default": "auto", + "enumDescriptions": [ + "On macOS, prefer the 'dotnet' backend when the dotnet CLI is on PATH (better keychain-trust UX via a signed binary); fall back to 'native' everywhere else.", + "Always use the bundled cert primitives. No dotnet SDK required. Same code path the extension has historically used; the OS trust prompt is fired by the extension's own binary.", + "Always shell out to `dotnet dev-certs https`. Requires the dotnet SDK to be installed and on PATH. Generates the cert and runs the OS trust step via the dotnet CLI." + ], + "description": "Which backend to use when this extension generates the host dev certificate. Only affects the auto-generated ASP.NET Core dev cert (gated by devcontainerDevCerts.generateDotNetCert); user-managed certificates are unaffected. Existing certs found in the OS store are not regenerated regardless of this setting — switching backends only affects fresh provisioning." + }, "devcontainerDevCerts.userCertificates": { "type": "array", "default": [], diff --git a/src/vscode-ui-extension/src/cert/exporter.ts b/src/vscode-ui-extension/src/cert/exporter.ts index 62700d6..58f7f41 100644 --- a/src/vscode-ui-extension/src/cert/exporter.ts +++ b/src/vscode-ui-extension/src/cert/exporter.ts @@ -1,134 +1,14 @@ -import * as fs from "fs"; -import * as path from "path"; -import type { LoadedCert } from "./loader"; -import { type DevCert, type DevKey } from "./types"; -import { ASPNET_HTTPS_OID_FRIENDLY_NAME } from "./properties"; -import { buildPfx } from "./pfx"; - -// PFX and unencrypted key PEM contain private key material. Use mode 0o600 on -// every write — the temp dir created upstream is already mkdtemp'd 0o700, but -// explicit file modes survive being copied or extracted elsewhere. -const PRIVATE_FILE_MODE = 0o600; -const PUBLIC_FILE_MODE = 0o644; - -/** - * Export a certificate with its private key as a PFX/PKCS12 file. - */ -export async function exportPfx( - cert: DevCert, - key: DevKey, - outputDir: string, - password?: string -): Promise { - fs.mkdirSync(outputDir, { recursive: true }); - const der = await buildPfx({ - cert, - key, - password, - friendlyName: ASPNET_HTTPS_OID_FRIENDLY_NAME, - }); - const outPath = path.join(outputDir, "aspnetcore-dev.pfx"); - fs.writeFileSync(outPath, der, { mode: PRIVATE_FILE_MODE }); - return outPath; -} - -/** - * Export a certificate and private key as PEM files. - * Returns { certPath, keyPath }. - */ -export function exportPem( - cert: DevCert, - key: DevKey, - outputDir: string -): { certPath: string; keyPath: string } { - fs.mkdirSync(outputDir, { recursive: true }); - - const certPath = path.join(outputDir, "aspnetcore-dev.pem"); - const keyPath = path.join(outputDir, "aspnetcore-dev.key"); - - fs.writeFileSync(certPath, cert.pem, { mode: PUBLIC_FILE_MODE }); - fs.writeFileSync(keyPath, key.pem, { mode: PRIVATE_FILE_MODE }); - - return { certPath, keyPath }; -} - -/** - * Convert a certificate to PEM format string. - */ -export function certToPem(cert: DevCert): string { - return cert.pem; -} - -/** - * Convert a private key to PEM format string (PKCS#8 unencrypted). - */ -export function keyToPem(key: DevKey): string { - return key.pem; -} - -/** - * Export certificate as DER-encoded bytes (public cert only, no private key). - */ -export function certToDer(cert: DevCert): Buffer { - return cert.der; -} - -/** - * Export a public-cert-only PFX for the .NET Root store. - * This matches what `dotnet dev-certs https --trust` writes to - * ~/.dotnet/corefx/cryptography/x509stores/root/ on Linux. - */ -export async function exportRootPfx( - cert: DevCert, - outputDir: string -): Promise { - fs.mkdirSync(outputDir, { recursive: true }); - const der = await buildPfx({ cert }); - const outPath = path.join(outputDir, "aspnetcore-dev-root.pfx"); - fs.writeFileSync(outPath, der, { mode: PUBLIC_FILE_MODE }); - return outPath; -} - -export interface ExportedLoadedCert { - pemCertPath: string; - pemKeyPath: string | null; - rootPfxPath: string | null; -} - -/** - * Export a user-managed (or generically loaded) certificate's PEM artifacts - * to a directory under a stable `{name}.*` filename scheme. The cert is - * always written; the key is only written when a key is attached; the - * public-cert-only root PFX is only produced when `includeRootPfx` is true. - * - * Notably this does NOT synthesize a key-bearing `{name}.pfx`. That decision - * lives in certProvider, where the user's pfxPassword is in scope — silently - * encoding a passwordless PFX here would strip the user's password without - * their consent. See certProvider.ts:loadUserCert for the PFX byte source. - */ -export async function exportLoadedCert( - loaded: LoadedCert, - name: string, - outputDir: string, - options: { includeRootPfx?: boolean } = {} -): Promise { - fs.mkdirSync(outputDir, { recursive: true }); - - const pemCertPath = path.join(outputDir, `${name}.pem`); - fs.writeFileSync(pemCertPath, loaded.cert.pem, { mode: PUBLIC_FILE_MODE }); - - let pemKeyPath: string | null = null; - if (loaded.key) { - pemKeyPath = path.join(outputDir, `${name}.key`); - fs.writeFileSync(pemKeyPath, loaded.key.pem, { mode: PRIVATE_FILE_MODE }); - } - - let rootPfxPath: string | null = null; - if (options.includeRootPfx) { - const rootBytes = await buildPfx({ cert: loaded.cert }); - rootPfxPath = path.join(outputDir, `${name}-root.pfx`); - fs.writeFileSync(rootPfxPath, rootBytes, { mode: PUBLIC_FILE_MODE }); - } - - return { pemCertPath, pemKeyPath, rootPfxPath }; -} +// Re-export shim: the canonical home for the exporter helpers is now +// `@devcontainer-dev-certs/shared`. Keeping this thin re-export so existing +// `./cert/exporter` imports across the UI extension (and its test suite) +// keep resolving without a sweeping rename. +export { + exportPfx, + exportPem, + exportRootPfx, + exportLoadedCert, + certToPem, + keyToPem, + certToDer, +} from "@devcontainer-dev-certs/shared"; +export type { ExportedLoadedCert } from "@devcontainer-dev-certs/shared"; diff --git a/src/vscode-ui-extension/src/cert/generator.ts b/src/vscode-ui-extension/src/cert/generator.ts index f1fc159..7b106be 100644 --- a/src/vscode-ui-extension/src/cert/generator.ts +++ b/src/vscode-ui-extension/src/cert/generator.ts @@ -1,235 +1,17 @@ -import { - AuthorityKeyIdentifierExtension, - BasicConstraintsExtension, - ExtendedKeyUsage, - ExtendedKeyUsageExtension, - Extension, - KeyUsageFlags, - KeyUsagesExtension, - SubjectAlternativeNameExtension, - SubjectKeyIdentifierExtension, - X509CertificateGenerator, - cryptoProvider, -} from "@peculiar/x509"; -import { randomBytes, webcrypto } from "node:crypto"; -import { - DevCert, - DevKey, - ASPNET_HTTPS_OID, - CURRENT_CERTIFICATE_VERSION, - RSA_KEY_SIZE, - SAN_DNS_NAMES, - SAN_IP_ADDRESSES, -} from "@devcontainer-dev-certs/shared"; - -// `isValidDevCert` and `getCertificateVersion` used to live in this file; -// they now live in `@devcontainer-dev-certs/shared/cert/validation` so the -// workspace extension can call the same code path when scanning for a -// container-side dev cert to push to the host. Re-exported here so existing -// imports of `./cert/generator` keep working unchanged. +// Re-export shim: the canonical home for `generateCertificate` is now +// `@devcontainer-dev-certs/shared`. Keeping this thin re-export so existing +// `./cert/generator` imports across the UI extension (and its test suite) +// keep resolving without a sweeping rename. The validation helpers +// (`isValidDevCert`, `getCertificateVersion`, `computeThumbprint`) used to be +// defined alongside the generator and were re-exported here for historical +// reasons — we preserve those re-exports so existing call sites still resolve. export { + generateCertificate, isValidDevCert, getCertificateVersion, computeThumbprint, } from "@devcontainer-dev-certs/shared"; - -let cryptoProviderConfigured = false; -function ensureCryptoProvider(): void { - if (cryptoProviderConfigured) return; - cryptoProvider.set(webcrypto as unknown as Crypto); - cryptoProviderConfigured = true; -} - -export interface GeneratedCert { - cert: DevCert; - key: DevKey; - /** - * SHA-1 thumbprint, uppercase hex. This is the .NET-compatible - * `X509Certificate2.Thumbprint` value used as the X509Store filename - * (`{thumbprint}.pfx`). For a stronger cert identifier, use - * `cert.thumbprint` (SHA-256). - */ - thumbprint: string; -} - -/** - * Algorithm choices for `generateCertificate`. Defaults to RSA-2048 to match - * the historical ASP.NET dev cert format, but the same code path supports - * ECDSA P-256/P-384/P-521 and Ed25519 for user-managed flows. - */ -export type GenerateAlgorithm = - | { kind: "rsa"; modulusLength?: number } - | { kind: "ec"; namedCurve: "P-256" | "P-384" | "P-521" } - | { kind: "ed25519" } - | { kind: "ed448" }; - -/** - * Generate a self-signed certificate matching the ASP.NET Core HTTPS dev - * cert format (subject, validity, SANs, custom OID, SKI/AKI). - * - * The default algorithm is RSA-2048 with SHA-256 signing — a byte-for-byte - * compatible replacement for the previous `node-forge` path. Pass an - * `algorithm` to opt into ECDSA / Ed25519 (used by user-managed cert flows - * that need to round-trip non-RSA keys). - */ -export async function generateCertificate( - notBefore: Date, - notAfter: Date, - algorithm: GenerateAlgorithm = { kind: "rsa" } -): Promise { - ensureCryptoProvider(); - - const { keyPair, signingAlgorithm } = await generateKeyPair(algorithm); - - const serialNumber = generateSerialNumber(); - const subject = "CN=localhost"; - const issuer = subject; - - const sanEntries = [ - ...SAN_DNS_NAMES.map( - (dns) => ({ type: "dns" as const, value: dns }) - ), - ...SAN_IP_ADDRESSES.map( - (ip) => ({ type: "ip" as const, value: ip }) - ), - ]; - - const extensions: Extension[] = [ - new BasicConstraintsExtension(false, undefined, true), - new KeyUsagesExtension( - KeyUsageFlags.digitalSignature | KeyUsageFlags.keyEncipherment, - true - ), - new ExtendedKeyUsageExtension([ExtendedKeyUsage.serverAuth], true), - new SubjectAlternativeNameExtension(sanEntries, true), - new Extension( - ASPNET_HTTPS_OID, - false, - new Uint8Array([CURRENT_CERTIFICATE_VERSION]).buffer - ), - await SubjectKeyIdentifierExtension.create(keyPair.publicKey), - await AuthorityKeyIdentifierExtension.create(keyPair.publicKey), - ]; - - const cert = await X509CertificateGenerator.create({ - serialNumber, - subject, - issuer, - notBefore, - notAfter, - signingAlgorithm, - publicKey: keyPair.publicKey, - signingKey: keyPair.privateKey, - extensions, - }); - - const devCert = new DevCert(cert); - const devKey = await DevKey.fromCryptoKey(keyPair.privateKey); - - return { - cert: devCert, - key: devKey, - thumbprint: devCert.thumbprintSha1, - }; -} - -async function generateKeyPair( - algorithm: GenerateAlgorithm -): Promise<{ - keyPair: CryptoKeyPair; - signingAlgorithm: Algorithm | RsaHashedKeyGenParams | EcdsaParams; -}> { - const subtle = webcrypto.subtle; - - if (algorithm.kind === "rsa") { - const modulusLength = algorithm.modulusLength ?? RSA_KEY_SIZE; - const params: RsaHashedKeyGenParams = { - name: "RSASSA-PKCS1-v1_5", - modulusLength, - publicExponent: new Uint8Array([1, 0, 1]), - hash: "SHA-256", - }; - const keyPair = (await subtle.generateKey(params, true, [ - "sign", - "verify", - ])); - return { - keyPair, - signingAlgorithm: { - name: "RSASSA-PKCS1-v1_5", - hash: "SHA-256", - } as RsaHashedKeyGenParams, - }; - } - - if (algorithm.kind === "ec") { - const params: EcKeyGenParams = { - name: "ECDSA", - namedCurve: algorithm.namedCurve, - }; - const keyPair = (await subtle.generateKey(params, true, [ - "sign", - "verify", - ])); - return { - keyPair, - signingAlgorithm: { - name: "ECDSA", - hash: defaultEcHash(algorithm.namedCurve), - } as EcdsaParams, - }; - } - - if (algorithm.kind === "ed25519") { - const keyPair = (await subtle.generateKey( - "Ed25519", - true, - ["sign", "verify"] - )) as CryptoKeyPair; - return { - keyPair, - signingAlgorithm: { name: "Ed25519" }, - }; - } - - if (algorithm.kind === "ed448") { - const keyPair = (await subtle.generateKey( - { name: "Ed448" }, - true, - ["sign", "verify"] - )) as CryptoKeyPair; - return { - keyPair, - signingAlgorithm: { name: "Ed448" }, - }; - } - - throw new Error( - `Unsupported algorithm: ${(algorithm as { kind: string }).kind}` - ); -} - -function defaultEcHash(curve: string): string { - switch (curve) { - case "P-256": - return "SHA-256"; - case "P-384": - return "SHA-384"; - case "P-521": - return "SHA-512"; - default: - return "SHA-256"; - } -} - -function generateSerialNumber(): string { - const maxAttempts = 5; - for (let attempt = 0; attempt < maxAttempts; attempt++) { - const bytes = randomBytes(16); - bytes[0] &= 0x7f; // ensure non-negative - if (bytes.some((value) => value !== 0)) { - return bytes.toString("hex"); - } - } - throw new Error("Failed to generate a non-zero certificate serial number."); -} +export type { + GenerateAlgorithm, + GeneratedCert, +} from "@devcontainer-dev-certs/shared"; diff --git a/src/vscode-ui-extension/src/cert/manager.ts b/src/vscode-ui-extension/src/cert/manager.ts index acdb937..78922f1 100644 --- a/src/vscode-ui-extension/src/cert/manager.ts +++ b/src/vscode-ui-extension/src/cert/manager.ts @@ -1,207 +1,3 @@ -import { generateCertificate, type GeneratedCert } from "./generator"; -import { exportPfx, exportPem, exportRootPfx } from "./exporter"; -import { VALIDITY_DAYS } from "./properties"; -import { - type PlatformCertificateStore, - type CertificateStatus, - type LinuxNssTrustReporter, - createPlatformStore, -} from "../platform/types"; -import { log } from "@devcontainer-dev-certs/shared"; - -export interface CertManagerOptions { - /** - * Optional reporter invoked by the Linux platform store after attempting - * browser-NSS trust as part of `trustCertificate`. No-op on other - * platforms. - */ - linuxNssTrustReporter?: LinuxNssTrustReporter; -} - -/** - * Certificate manager that orchestrates generation, trust, export, and status - * checking using platform-specific stores. - */ -export class CertManager { - private store: PlatformCertificateStore | null = null; - private currentCert: GeneratedCert | null = null; - - constructor(private readonly options: CertManagerOptions = {}) {} - - private async getStore(): Promise { - this.store ??= await createPlatformStore({ - linuxNssTrustReporter: this.options.linuxNssTrustReporter, - }); - return this.store; - } - - /** - * Generate a new dev cert and save it to the platform store. - * If force is true, removes existing certs first. - */ - async generate(force: boolean = false): Promise { - const store = await this.getStore(); - - if (force) { - log("Removing existing certificates..."); - await store.removeCertificates(); - } - - log("Generating new dev certificate..."); - const now = new Date(); - const expiry = new Date( - now.getTime() + VALIDITY_DAYS * 24 * 60 * 60 * 1000 - ); - const generated = await generateCertificate(now, expiry); - this.currentCert = generated; - - log(`Certificate generated. Thumbprint: ${generated.thumbprint}`); - await store.saveCertificate( - generated.cert, - generated.key, - generated.thumbprint - ); - log("Certificate saved to platform store."); - } - - /** - * Trust an externally-supplied certificate (e.g. one pushed from a Dev - * Container via the syncContainerCert reverse-sync flow) in the host - * OS trust store. - * - * Delegates directly to `store.trustCertificate(cert)` — the SAME hook - * the host-generation flow (`trust()`) uses on its final step — so - * "trusted on the host" means the same thing regardless of whether - * the cert was generated here or accepted from a container. On Linux - * that's `.NET Root store + OpenSSL trust dir + NSS browser DBs` (the - * NSS step uses the same `linuxNssTrustReporter` callback the host - * generation flow wires up). On macOS, login keychain trust policy. - * On Windows, CurrentUser/Root via certutil. - * - * Public-cert-only: the cert lands in every trust surface listed - * above but NEVER in CurrentUser/My, the keychain's identity slot, or - * the .NET store's `my/` directory. Skipping `saveCertificate` is - * deliberate — the host doesn't need (and shouldn't store) the - * private key, because Kestrel runs in the container with its own - * copy of the key. Future changes to this method MUST preserve both - * properties: (a) trust goes through `store.trustCertificate`; (b) - * no `saveCertificate` call. `tests/manager.test.ts` pins both. - * - * Does NOT update `currentCert`. The host's auto-generation flow - * (`generate()` / `trust()`) is a separate state machine that the - * container-push path doesn't feed into; if the user also has - * `generateDotNetCert: true` and a subsequent `getAllCertMaterial` - * pull arrives, the host will generate its own (separate) cert as - * normal. - */ - async trustExternalCertificate( - cert: GeneratedCert["cert"] - ): Promise { - const store = await this.getStore(); - - // Verify on-disk state before invoking the platform trust step. - // Skipping a redundant call matters on macOS where - // `security add-trusted-cert` is not a no-op for an already-trusted - // cert (re-touches the trust-settings record, may re-prompt for - // the keychain password). The same cache-as-goal-state / - // verify-on-disk pattern is used by the host-generation flow's - // `trust()` method, just expressed differently because it goes - // through `checkStatus()` instead of a direct `isCertTrusted`. - if (await store.isCertTrusted(cert)) { - log( - `Externally-supplied dev certificate ${cert.thumbprintSha1} is already trusted on host; skipping platform trust call.` - ); - return; - } - - log( - `Trusting externally-supplied dev certificate ${cert.thumbprintSha1} (public cert only, via the same platform trust path as host-generated)...` - ); - await store.trustCertificate(cert); - log("Externally-supplied certificate trusted."); - } - - /** - * Ensure a cert exists and is trusted. Generates one if needed. - */ - async trust(): Promise { - const store = await this.getStore(); - const status = await store.checkStatus(); - - if (!status.exists) { - await this.generate(); - } - - // Re-check: load from store if we didn't just generate - if (!this.currentCert) { - const found = await store.findExistingDevCert(); - if (!found) { - throw new Error("Failed to find certificate after generation."); - } - this.currentCert = found; - } - - const recheck = await store.checkStatus(); - if (!recheck.isTrusted) { - log("Trusting certificate in OS store..."); - await store.trustCertificate(this.currentCert.cert); - log("Certificate trusted."); - } - } - - /** - * Export the current cert in the specified format. - */ - async exportCert( - format: "pfx" | "pem" | "root-pfx", - outputDir: string, - password?: string - ): Promise { - await this.ensureLoaded(); - - if (format === "pfx") { - await exportPfx( - this.currentCert!.cert, - this.currentCert!.key, - outputDir, - password - ); - } else if (format === "root-pfx") { - await exportRootPfx(this.currentCert!.cert, outputDir); - } else { - exportPem(this.currentCert!.cert, this.currentCert!.key, outputDir); - } - } - - /** - * Check the status of the dev certificate. - */ - async check(): Promise { - const store = await this.getStore(); - return store.checkStatus(); - } - - /** - * Remove all dev certificates from the platform store. - */ - async clean(): Promise { - const store = await this.getStore(); - await store.removeCertificates(); - this.currentCert = null; - log("All dev certificates removed."); - } - - /** - * Ensure we have a loaded cert (from store or freshly generated). - */ - private async ensureLoaded(): Promise { - if (this.currentCert) return; - - const store = await this.getStore(); - const found = await store.findExistingDevCert(); - if (!found) { - throw new Error("No dev certificate found. Generate one first."); - } - this.currentCert = found; - } -} +// Re-export shim: canonical home is `@devcontainer-dev-certs/shared`. +export { CertManager } from "@devcontainer-dev-certs/shared"; +export type { CertManagerOptions } from "@devcontainer-dev-certs/shared"; diff --git a/src/vscode-ui-extension/src/certProvider.ts b/src/vscode-ui-extension/src/certProvider.ts index 1d0a780..0b582a6 100644 --- a/src/vscode-ui-extension/src/certProvider.ts +++ b/src/vscode-ui-extension/src/certProvider.ts @@ -7,14 +7,20 @@ import { exportLoadedCert } from "./cert/exporter"; import { loadPemPair, loadPfx } from "./cert/loader"; import type { LoadedCert } from "./cert/loader"; import { buildPfx } from "./cert/pfx"; -import { assertValidCertName, log } from "@devcontainer-dev-certs/shared"; +import { + assertValidCertName, + log, + selectBackend, +} from "@devcontainer-dev-certs/shared"; import type { + BackendMode, CertBundle, CertBundleV3, CertMaterial, CertMaterialV2, CertMaterialV3, DefaultKestrelCertSelection, + LinuxNssTrustReporter, } from "@devcontainer-dev-certs/shared"; import { DOTNET_DEV_CERT_NAME } from "@devcontainer-dev-certs/shared"; @@ -49,7 +55,21 @@ export class CertProvider { private cachedUser = new Map(); private warnedExpiredCerts = new Set(); - constructor(private readonly certManager: CertManager) {} + /** + * @param certManager Drives the native provisioning path. + * @param linuxNssTrustReporter Forwarded to the dotnet backend's + * `generate()` so the dotnet path on Linux supplements + * `dotnet dev-certs --trust` (which only writes the OpenSSL trust + * dir + .NET root) with our NSS browser-trust step. Without this + * wiring, the host extension's toast guidance for NSS failures + * never fires under `hostCertGenerator: "dotnet"`. The native / + * auto-resolved-to-native paths get the reporter via `certManager`'s + * own wiring. + */ + constructor( + private readonly certManager: CertManager, + private readonly linuxNssTrustReporter?: LinuxNssTrustReporter + ) {} /** * Legacy single-cert entry point. Returns the dotnet-dev cert material in @@ -183,6 +203,60 @@ export class CertProvider { return certs; } + /** + * Provision the host dev cert via the backend the user has selected + * (`devcontainerDevCerts.hostCertGenerator`). Default `auto` resolves + * to the dotnet backend on macOS (when the dotnet CLI is on PATH) and + * to native everywhere else — both end up writing the cert into the + * same OS platform store that `certManager` reads from, so the + * downstream `exportCert` path works regardless of which backend + * actually performed the provisioning. + */ + private async provisionViaConfiguredBackend(): Promise { + const setting = vscode.workspace + .getConfiguration("devcontainerDevCerts") + .get("hostCertGenerator", "auto"); + + const backend = await selectBackend(setting); + + // Native — whether the user explicitly picked it or `auto` resolved + // to it (non-macOS, or macOS without dotnet) — defers to the + // pre-configured CertManager on `this`. NativeBackend.generate would + // produce equivalent OS-store state, but its bare manager misses the + // extension-only `localize` wiring (vscode.l10n.t). Until that's + // also threadable through GenerateOptions, this is the one knob + // worth keeping the bypass for. + if (backend.kind === "native") { + await this.certManager.trust(); + return; + } + + // dotnet backend: the cert + trust side effects are what we care + // about; the on-disk PFX/PEM in the tmp dir is a byproduct of the + // backend's contract that we discard. The platform store ends up + // populated identically to the native path, so the subsequent + // `exportCert` calls work without further special-casing. + // + // On Linux we forward `linuxNssTrustReporter` so the dotnet backend + // supplements `dotnet dev-certs --trust` (which doesn't touch NSS + // browser DBs) with our own `trustInNss` step — wired through the + // same reporter the native path uses so the manual-guidance toast + // fires uniformly regardless of which backend the user picked. + log(`Provisioning host dev cert via '${backend.kind}' backend.`); + const tmpProvisioningDir = fs.mkdtempSync( + path.join(os.tmpdir(), "devcerts-provision-") + ); + try { + await backend.generate({ + outDir: tmpProvisioningDir, + noTrust: false, + linuxNssTrustReporter: this.linuxNssTrustReporter, + }); + } finally { + fs.rmSync(tmpProvisioningDir, { recursive: true, force: true }); + } + } + private async ensureDotNetDevCert( autoProvision: boolean ): Promise { @@ -215,7 +289,7 @@ export class CertProvider { return null; } log("Ensuring certificate is generated and trusted..."); - await this.certManager.trust(); + await this.provisionViaConfiguredBackend(); } // mkdtempSync gives us a unique 0o700 dir in tmpdir. Combined with diff --git a/src/vscode-ui-extension/src/extension.ts b/src/vscode-ui-extension/src/extension.ts index 6419de0..dad43cd 100644 --- a/src/vscode-ui-extension/src/extension.ts +++ b/src/vscode-ui-extension/src/extension.ts @@ -13,30 +13,42 @@ import { } from "./containerCertAccept"; import { trustInNss } from "./platform/nssTrust"; import { - initLogger, log, getOpenSslTrustDir, getPemFileName, type NonLocalSanEntry, } from "@devcontainer-dev-certs/shared"; -import type { CertBundle, CertBundleV3 } from "@devcontainer-dev-certs/shared"; +import { initLogger } from "@devcontainer-dev-certs/shared/src/loggerVscode"; +import type { + CertBundle, + CertBundleV3, + LinuxNssTrustReporter, +} from "@devcontainer-dev-certs/shared"; const CONTAINER_CERT_CONSENT_KEY = "containerCertProvisionConsented"; export function activate(context: vscode.ExtensionContext): void { context.subscriptions.push(initLogger("Dev Container Dev Certs")); + // Shared NSS reporter: the native path picks it up via `CertManager`'s + // construction; the dotnet path picks it up via `CertProvider`'s + // `provisionViaConfiguredBackend`, which forwards it into the dotnet + // backend's `generate()` so the trust outcome surfaces identically + // regardless of `hostCertGenerator` setting. + const linuxNssTrustReporter: LinuxNssTrustReporter = (result, pemPath) => { + if (result.success) { + log(`Linux NSS trust: ${result.message}`); + return; + } + log(`Linux NSS trust did not fully succeed: ${result.message}`); + void showBrowserTrustFailureGuidance(pemPath, result.message); + }; + const certManager = new CertManager({ - linuxNssTrustReporter: (result, pemPath) => { - if (result.success) { - log(`Linux NSS trust: ${result.message}`); - return; - } - log(`Linux NSS trust did not fully succeed: ${result.message}`); - void showBrowserTrustFailureGuidance(pemPath, result.message); - }, + localize: vscode.l10n.t, + linuxNssTrustReporter, }); - const certProvider = new CertProvider(certManager); + const certProvider = new CertProvider(certManager, linuxNssTrustReporter); log("UI extension activated (managed certificate provider)."); diff --git a/src/vscode-ui-extension/src/platform/baseStore.ts b/src/vscode-ui-extension/src/platform/baseStore.ts index ba467f0..c64a6da 100644 --- a/src/vscode-ui-extension/src/platform/baseStore.ts +++ b/src/vscode-ui-extension/src/platform/baseStore.ts @@ -1,324 +1,18 @@ -import * as fs from "fs"; -import * as path from "path"; -import * as vscode from "vscode"; -import { type PlatformCertificateStore, type CertificateStatus } from "./types"; -import { - log, - type DevCert, - type DevKey, - buildPfx, - parsePfx, - getCertificateVersion, - classifyCandidate as classifyCandidateShared, - selectBestDevCert as selectBestDevCertShared, +// Re-export shim: the canonical home for the localized platform classifier +// wrappers and `BaseCertificateStore` is now in +// `@devcontainer-dev-certs/shared`. Imports go through the submodule path +// (rather than the barrel) because the barrel aliases the platform-flavored +// `classifyCandidate` / `selectBestDevCert` to disambiguate from the pure +// classifier; existing tests and call sites expect the unaliased names. +export { + BaseCertificateStore, + classifyCandidate, + selectBestDevCert, extractThumbprintHintFromFilename, - type CandidateInput, - type ClassifiedCandidate, - type SkipReport, - type UsableDevCert, -} from "@devcontainer-dev-certs/shared"; - -// Re-export the pure shared types so existing imports of -// `../platform/baseStore` keep working without touching ~15 test files. +} from "@devcontainer-dev-certs/shared/src/platform/baseStore"; export type { ClassifiedCandidate, CandidateInput, UsableDevCert, -} from "@devcontainer-dev-certs/shared"; -export { extractThumbprintHintFromFilename } from "@devcontainer-dev-certs/shared"; - -/** - * Side-effectful host-side classifier wrapper. Delegates the pure - * classification to the shared module and emits the same localized "skipping - * ASP.NET dev cert ..." log line via `vscode.l10n.t` that the host has - * always produced. New container-side scan paths can call the shared - * `classifyCandidate` directly with their own (or no) localizer. - */ -export function classifyCandidate( - input: CandidateInput -): ClassifiedCandidate | null { - return classifyCandidateShared(input, { - onSkipped: (report) => emitHostSkipLog(report), - }); -} - -/** - * Side-effectful host-side selection wrapper. Delegates to the shared - * selector and emits the multi-candidate localized warning when more than - * one usable candidate is present. - */ -export function selectBestDevCert( - usable: UsableDevCert[], - context: string -): UsableDevCert | null { - return selectBestDevCertShared(usable, context, { - onMultipleCandidates: ({ selected, candidates }) => { - const header = vscode.l10n.t( - "Multiple valid ASP.NET dev certs found in {0}; selected {1}.", - context, - selected.thumbprint - ); - const candidatesHeader = vscode.l10n.t(" Candidates:"); - const selectedTag = vscode.l10n.t("[selected]"); - const skippedTag = vscode.l10n.t("[skipped] "); - const lines = [ - header, - candidatesHeader, - ...candidates.map((c, i) => - vscode.l10n.t( - " {0} thumbprint={1} version={2} notBefore={3} notAfter={4}", - i === 0 ? selectedTag : skippedTag, - c.thumbprint, - getCertificateVersion(c.cert), - c.cert.notBefore.toISOString(), - c.cert.notAfter.toISOString() - ) - ), - ]; - log(lines.join("\n")); - }, - }); -} - -/** - * Render the localized "skipping ASP.NET dev cert" log line for one skipped - * candidate. Maps the shared classifier's reason code to a host-localized - * string. `forced` skips carry the caller's free-form reason verbatim — the - * platform stores (linuxStore / macStore / windowsStore) localize their own - * forced-skip reasons before handing them in, so we pass through. - */ -function emitHostSkipLog(report: SkipReport): void { - let localizedReason: string; - switch (report.reasonCode) { - case "missing-private-key": - localizedReason = vscode.l10n.t( - "PFX contains certificate without matching private key" - ); - break; - case "parse-failed": - localizedReason = vscode.l10n.t( - "failed to parse PFX (corrupt or wrong password)" - ); - break; - case "forced": - // Caller localized this string before classifyCandidate received it. - localizedReason = report.forcedReason ?? ""; - break; - } - const unknown = vscode.l10n.t("(unknown)"); - const meta = report.metadata; - const subjectCN = meta.subjectCN ?? unknown; - const version = - meta.version === undefined || meta.version === null - ? unknown - : String(meta.version); - const notBefore = meta.notBefore ? meta.notBefore.toISOString() : unknown; - const notAfter = meta.notAfter ? meta.notAfter.toISOString() : unknown; - log( - vscode.l10n.t( - "Skipping ASP.NET dev cert {0} ({1}): {2}.\n subjectCN={3} version={4} notBefore={5} notAfter={6}", - meta.thumbprint ?? unknown, - report.source, - localizedReason, - subjectCN, - version, - notBefore, - notAfter - ) - ); -} - -/** - * Base implementation for platform certificate stores. - * - * Provides common logic shared across Windows, macOS, and Linux: - * - checkStatus() with a consistent pattern (find → check trust → build status) - * - PFX loading and writing helpers - * - * Subclasses implement the platform-specific methods: findExistingDevCert, - * saveCertificate, trustCertificate, removeCertificates, and isTrusted. - */ -export abstract class BaseCertificateStore implements PlatformCertificateStore { - async checkStatus(): Promise { - const found = await this.findExistingDevCert(); - if (!found) { - return { - exists: false, - isTrusted: false, - thumbprint: null, - notBefore: null, - notAfter: null, - version: -1, - }; - } - - const { cert, thumbprint } = found; - const trusted = await this.isTrusted(cert, thumbprint); - const version = getCertificateVersion(cert); - - return { - exists: true, - isTrusted: trusted, - thumbprint, - notBefore: cert.notBefore.toISOString(), - notAfter: cert.notAfter.toISOString(), - version, - }; - } - - abstract findExistingDevCert(): Promise; - - abstract saveCertificate( - cert: DevCert, - key: DevKey, - thumbprint: string - ): Promise; - - abstract trustCertificate(cert: DevCert): Promise; - - abstract removeCertificates(): Promise; - - /** - * Public wrapper around `isTrusted` that satisfies the - * `PlatformCertificateStore.isCertTrusted` contract — verify the - * current on-disk / OS trust state for a specific certificate. Lets - * callers (notably `CertManager.trustExternalCertificate`) decide - * whether the platform-level trust step needs to run at all, - * avoiding redundant `security add-trusted-cert` / `certutil - * -addstore` calls that aren't true no-ops. - */ - async isCertTrusted(cert: DevCert): Promise { - return this.isTrusted(cert, cert.thumbprintSha1); - } - - /** - * Platform-specific trust verification. - * Called by checkStatus() to determine if the certificate is trusted. - */ - protected abstract isTrusted( - cert: DevCert, - thumbprint: string - ): Promise; - - // --- Shared helpers --- - - /** - * Parse a PFX file and extract the certificate, private key, and thumbprint. - * Returns `{ cert, key }` where `key` may be null when the PFX is cert-only. - * Returns null only on outright parse failure (corrupt bytes, wrong - * password, unsupported PBE). - */ - protected async loadPfxLenient( - pfxPath: string, - password: string = "" - ): Promise<{ cert: DevCert; key: DevKey | null; thumbprint: string } | null> { - try { - const pfxBytes = fs.readFileSync(pfxPath); - const { cert, key } = await parsePfx(pfxBytes, password); - return { cert, key: key ?? null, thumbprint: cert.thumbprintSha1 }; - } catch { - return null; - } - } - - /** - * Parse a PFX file and extract the certificate, private key, and thumbprint. - * Returns null if the file cannot be parsed or is missing cert/key bags. - * Strict variant — used by existing call sites that want a usable identity - * or nothing. - */ - protected async loadPfx( - pfxPath: string, - password: string = "" - ): Promise { - const loaded = await this.loadPfxLenient(pfxPath, password); - if (!loaded || !loaded.key) return null; - return { cert: loaded.cert, key: loaded.key, thumbprint: loaded.thumbprint }; - } - - /** - * Write a certificate and key as a PFX file. - */ - protected async writePfx( - cert: DevCert, - key: DevKey, - pfxPath: string, - password: string = "", - mode?: number - ): Promise { - const der = await buildPfx({ cert, key, password }); - const options = mode !== undefined ? { mode } : undefined; - fs.writeFileSync(pfxPath, der, options); - } - - /** - * Scan a directory for `*.pfx` files and classify each one. For every - * match: - * - * 1. Try to parse it. Parse failures on canonically-named files - * (`aspnetcore-localhost-.pfx` or `.pfx`) emit the - * "failed to parse PFX" unusable warning; parse failures on other - * filenames are silent — they may belong to other tools. - * 2. If parsed and the cert passes `isValidDevCert` but there's no private - * key, emit the "no matching private key" unusable warning. - * 3. Otherwise classify as `usable`. - * - * Selection runs only over the surviving usable candidates via - * `selectBestDevCert`. Multi-candidate selection emits its own warning. - * - * Callers may narrow which files to consider via `filenamePredicate` - * (default: accept every `*.pfx`). Whether a parse failure produces a - * warning is controlled separately by `extractThumbprintHintFromFilename` - * — see step 1. - */ - protected async findBestDevCertInDir( - dir: string, - password: string = "", - options: { - filenamePredicate?: (filename: string) => boolean; - context?: string; - } = {} - ): Promise { - if (!fs.existsSync(dir)) return null; - - const context = options.context ?? dir; - const usable: UsableDevCert[] = []; - - const files = fs - .readdirSync(dir) - .filter((f) => f.endsWith(".pfx")) - .filter((f) => - options.filenamePredicate ? options.filenamePredicate(f) : true - ); - - for (const file of files) { - const filePath = path.join(dir, file); - const thumbprintHint = extractThumbprintHintFromFilename(file); - const loaded = await this.loadPfxLenient(filePath, password); - - if (!loaded) { - // Canonical name + parse failure → warn; otherwise silent. - classifyCandidate({ - kind: "parseFailure", - source: filePath, - thumbprintHint, - }); - continue; - } - - const classified = classifyCandidate({ - kind: "loaded", - source: filePath, - loaded, - }); - - if (classified === null) continue; - if (classified.kind === "usable") { - usable.push(classified); - } - // skipped → already logged inside classifyCandidate - } - - return selectBestDevCert(usable, context); - } -} + ClassifyOptions, +} from "@devcontainer-dev-certs/shared/src/platform/baseStore"; diff --git a/src/vscode-ui-extension/src/platform/linuxStore.ts b/src/vscode-ui-extension/src/platform/linuxStore.ts index 1f52473..dac9222 100644 --- a/src/vscode-ui-extension/src/platform/linuxStore.ts +++ b/src/vscode-ui-extension/src/platform/linuxStore.ts @@ -1,275 +1,3 @@ -import * as fs from "fs"; -import * as path from "path"; -import { BaseCertificateStore } from "./baseStore"; -import { trustInNss, type NssTrustResult } from "./nssTrust"; -import { runProcess } from "./processUtil"; -import { type LinuxNssTrustReporter } from "./types"; -import { type DevCert, type DevKey } from "../cert/types"; -import { ASPNET_HTTPS_OID } from "../cert/properties"; -import { buildPfx } from "../cert/pfx"; -import { - getDotNetStorePath, - getDotNetRootStorePath, - getOpenSslTrustDir, - getPemFileName, - log, -} from "@devcontainer-dev-certs/shared"; - -export interface LinuxCertificateStoreOptions { - /** - * Optional callback invoked once with the result of the best-effort - * browser-NSS trust step inside `trustCertificate`. Omitting it disables - * the NSS step entirely (used by integration tests and CLI contexts - * where browser trust isn't relevant). - */ - nssTrustReporter?: LinuxNssTrustReporter; -} - -/** - * Linux certificate store implementation. - * - * Storage locations: - * - .NET X509Store path: ~/.dotnet/corefx/cryptography/x509stores/my/ - * - OpenSSL trust dir: ~/.aspnet/dev-certs/trust/ (or DOTNET_DEV_CERTS_OPENSSL_CERTIFICATE_DIRECTORY) - * - * Trust is established by: - * 1. Writing a PFX to the .NET Root store path (for .NET runtime validation) - * 2. Writing a PEM to the OpenSSL trust directory with hash symlinks (for OpenSSL/curl/etc.) - * 3. Best-effort import into Linux browser NSS databases, when a reporter - * is configured. - */ -export class LinuxCertificateStore extends BaseCertificateStore { - private readonly nssTrustReporter?: LinuxNssTrustReporter; - - constructor(options: LinuxCertificateStoreOptions = {}) { - super(); - this.nssTrustReporter = options.nssTrustReporter; - } - - private get dotNetRootStorePath(): string { - return getDotNetRootStorePath(); - } - - async findExistingDevCert(): Promise<{ - cert: DevCert; - key: DevKey; - thumbprint: string; - } | null> { - return this.findBestDevCertInDir(getDotNetStorePath()); - } - - async saveCertificate( - cert: DevCert, - key: DevKey, - thumbprint: string - ): Promise { - const storeDir = getDotNetStorePath(); - fs.mkdirSync(storeDir, { recursive: true }); - await this.writePfx( - cert, - key, - path.join(storeDir, `${thumbprint}.pfx`), - "", - 0o600 - ); - } - - async trustCertificate(cert: DevCert): Promise { - await this.trustInDotNetRootStore(cert); - await this.trustViaOpenSsl(cert); - await this.trustInNssBrowsers(cert); - } - - /** - * Best-effort browser NSS trust. Mirrors the Windows / macOS pattern where - * `trustCertificate` performs every trust step the platform supports out - * of the box — on Linux that includes adding the cert to the user's NSS - * databases for Firefox / Chromium-family browsers. Never throws: NSS - * tooling or stores are commonly absent and shouldn't break .NET / OpenSSL - * trust. Results are surfaced via the reporter callback (typically used by - * the extension host to show a manual-guidance toast on failure). - */ - private async trustInNssBrowsers(cert: DevCert): Promise { - if (!this.nssTrustReporter) return; - - const pemPath = path.join( - getOpenSslTrustDir(), - getPemFileName(cert.thumbprintSha1) - ); - if (!fs.existsSync(pemPath)) { - log(`Linux NSS trust: PEM not found at ${pemPath}, skipping.`); - return; - } - - let result: NssTrustResult; - try { - result = await trustInNss(pemPath); - } catch (err: unknown) { - const message = err instanceof Error ? err.message : String(err); - log(`Linux NSS trust threw unexpectedly: ${message}`); - result = { success: false, message }; - } - - this.nssTrustReporter(result, pemPath); - } - - async removeCertificates(): Promise { - await this.removeDevCertsFromDir(getDotNetStorePath()); - await this.removeDevCertsFromDir(this.dotNetRootStorePath); - - const trustDir = getOpenSslTrustDir(); - if (fs.existsSync(trustDir)) { - const entries = fs.readdirSync(trustDir); - for (const entry of entries) { - const fullPath = path.join(trustDir, entry); - if (entry.startsWith("aspnetcore-localhost-")) { - fs.unlinkSync(fullPath); - } else if (isHashSymlink(entry)) { - try { - if (fs.lstatSync(fullPath).isSymbolicLink()) { - fs.unlinkSync(fullPath); - } - } catch { - // ignore - } - } - } - } - } - - protected isTrusted( - _cert: DevCert, - thumbprint: string - ): Promise { - const pemPath = path.join(getOpenSslTrustDir(), getPemFileName(thumbprint)); - return Promise.resolve(fs.existsSync(pemPath)); - } - - // --- Linux-specific trust helpers --- - - private async trustInDotNetRootStore(cert: DevCert): Promise { - fs.mkdirSync(this.dotNetRootStorePath, { recursive: true }); - - const thumbprint = cert.thumbprintSha1; - const certPath = path.join(this.dotNetRootStorePath, `${thumbprint}.pfx`); - - // .NET's X509Store on Linux stores certs as individual PFX files. - // For the Root store, we need a PFX containing only the public cert (no private key). - const pfxBytes = await buildPfx({ cert }); - fs.writeFileSync(certPath, pfxBytes, { mode: 0o644 }); - } - - private async trustViaOpenSsl(cert: DevCert): Promise { - const trustDir = getOpenSslTrustDir(); - fs.mkdirSync(trustDir, { recursive: true }); - - const thumbprint = cert.thumbprintSha1; - const pemFileName = getPemFileName(thumbprint); - const pemPath = path.join(trustDir, pemFileName); - - // Purely additive: write the new PEM, but do NOT delete other - // `aspnetcore-localhost-*.pem` files in the trust dir. The previous - // implementation swept "old rotations" defensively, but that turned - // every `trustCertificate` call into an implicit revocation of every - // other dev cert in the trust dir — including the host-generated - // cert when the container-push reverse-sync flow trusts an - // additional one, and vice versa. Removing trust from a cert the - // user (or another flow) explicitly trusted is the cleanup - // command's job — gated by an explicit user prompt — not - // trustCertificate's. The cleanup sweep continues to handle - // legitimately stale artifacts; this method stays additive so the - // generation flow and the reverse-sync flow can coexist without - // ping-ponging trust. - // - // Writing to the exact same path on a same-thumbprint repeat call - // is idempotent (overwrites identical content); rehashing afterward - // is a no-op when nothing changed. - fs.writeFileSync(pemPath, cert.pem, { mode: 0o644 }); - await this.rehashDirectory(trustDir); - } - - private async rehashDirectory(directory: string): Promise { - const entries = fs.readdirSync(directory); - - // Remove existing hash symlinks - for (const entry of entries) { - if (isHashSymlink(entry)) { - const fullPath = path.join(directory, entry); - try { - if (fs.lstatSync(fullPath).isSymbolicLink()) { - fs.unlinkSync(fullPath); - } - } catch { - // ignore - } - } - } - - // Create new hash symlinks for all PEM/CRT files - const certFiles = fs - .readdirSync(directory) - .filter((f) => /\.(pem|crt|cer)$/i.test(f)); - - for (const certFile of certFiles) { - const fullPath = path.join(directory, certFile); - try { - if (fs.lstatSync(fullPath).isSymbolicLink()) continue; - } catch { - continue; - } - - const hash = await this.getOpenSslSubjectHash(fullPath); - if (!hash) continue; - - // Slot 0-9 covers any realistic collision count. Catch EEXIST so a - // concurrent rehash can't crash this one mid-loop. - for (let i = 0; i < 10; i++) { - const linkPath = path.join(directory, `${hash}.${i}`); - if (fs.existsSync(linkPath)) continue; - try { - fs.symlinkSync(certFile, linkPath); - break; - } catch (err: unknown) { - if ((err as NodeJS.ErrnoException).code === "EEXIST") continue; - throw err; - } - } - } - } - - private async getOpenSslSubjectHash( - certPath: string - ): Promise { - const result = await runProcess("openssl", [ - "x509", - "-hash", - "-noout", - "-in", - certPath, - ]); - if (result.exitCode !== 0) return null; - return result.stdout.trim() || null; - } - - private async removeDevCertsFromDir(dir: string): Promise { - if (!fs.existsSync(dir)) return; - - const files = fs.readdirSync(dir).filter((f) => f.endsWith(".pfx")); - for (const file of files) { - const pfxPath = path.join(dir, file); - try { - const result = await this.loadPfx(pfxPath); - if (result && result.cert.hasExtension(ASPNET_HTTPS_OID)) { - fs.unlinkSync(pfxPath); - } - } catch { - // Skip files that can't be parsed - } - } - } -} - -function isHashSymlink(filename: string): boolean { - return /^[0-9a-f]{8}\.\d+$/.test(filename); -} - +// Re-export shim: canonical home is `@devcontainer-dev-certs/shared`. +export { LinuxCertificateStore } from "@devcontainer-dev-certs/shared"; +export type { LinuxCertificateStoreOptions } from "@devcontainer-dev-certs/shared"; diff --git a/src/vscode-ui-extension/src/platform/macStore.ts b/src/vscode-ui-extension/src/platform/macStore.ts index 70b00d6..d7f2551 100644 --- a/src/vscode-ui-extension/src/platform/macStore.ts +++ b/src/vscode-ui-extension/src/platform/macStore.ts @@ -1,329 +1,2 @@ -import { randomUUID } from "crypto"; -import * as fs from "fs"; -import * as os from "os"; -import * as path from "path"; -import * as vscode from "vscode"; -import { - BaseCertificateStore, - classifyCandidate, - extractThumbprintHintFromFilename, - selectBestDevCert, - type UsableDevCert, -} from "./baseStore"; -import { runProcess } from "./processUtil"; -import { - getCertificateVersion, - isValidDevCert, -} from "../cert/generator"; -import { certToDer } from "../cert/exporter"; -import { ASPNET_HTTPS_OID } from "../cert/properties"; -import { DevCert, type DevKey } from "../cert/types"; - -/** - * macOS certificate store implementation. - * - * Storage locations: - * - Disk: ~/.aspnet/dev-certs/https/aspnetcore-localhost-{thumbprint}.pfx - * - Keychain: login keychain for trust validation - * - * Uses the `security` CLI for keychain trust operations. - */ -export class MacCertificateStore extends BaseCertificateStore { - private get devCertsDir(): string { - return path.join(os.homedir(), ".aspnet", "dev-certs", "https"); - } - - private get keychainPath(): string { - return path.join(os.homedir(), "Library", "Keychains", "login.keychain-db"); - } - - async findExistingDevCert(): Promise { - const context = "macOS login keychain"; - const usable: UsableDevCert[] = []; - const seenThumbprints = new Set(); - - // 1) Walk ~/.aspnet/dev-certs/https/. For each parseable PFX whose cert - // passes isValidDevCert, additionally verify a matching cert is - // actually present in the login keychain via `security find- - // certificate -Z ` (public-only, no prompt). PFXs whose cert - // isn't in the keychain are classified as "orphaned cache file" - // skipped entries and excluded from selection. - if (fs.existsSync(this.devCertsDir)) { - const pfxFiles = fs - .readdirSync(this.devCertsDir) - .filter( - (f) => f.startsWith("aspnetcore-localhost-") && f.endsWith(".pfx") - ); - - for (const pfxFile of pfxFiles) { - const pfxPath = path.join(this.devCertsDir, pfxFile); - const loaded = await this.loadPfxLenient(pfxPath); - - if (!loaded) { - classifyCandidate({ - kind: "parseFailure", - source: pfxPath, - thumbprintHint: extractThumbprintHintFromFilename(pfxFile), - }); - continue; - } - - const classified = classifyCandidate({ - kind: "loaded", - source: pfxPath, - loaded, - }); - if (classified === null) continue; - if (classified.kind !== "usable") { - seenThumbprints.add(loaded.thumbprint); - continue; - } - - // Verify keychain presence — no prompt, public-only. - const inKeychain = await this.isCertInKeychain(classified.thumbprint); - if (!inKeychain) { - classifyCandidate({ - kind: "forcedSkip", - source: pfxPath, - reason: vscode.l10n.t( - "PFX present on disk but matching certificate not in macOS login keychain (orphaned cache file)" - ), - metadata: { - thumbprint: classified.thumbprint, - subjectCN: classified.cert.subjectCN, - version: getCertificateVersion(classified.cert), - notBefore: classified.cert.notBefore, - notAfter: classified.cert.notAfter, - }, - }); - seenThumbprints.add(classified.thumbprint); - continue; - } - - seenThumbprints.add(classified.thumbprint); - usable.push(classified); - } - } - - // 2) Soft keychain enumeration — emit a warning for keychain-resident - // dev certs that lack a matching cache PFX. Public-only read, never - // triggers an ACL prompt, low EDR signal. - const keychainEntries = await this.enumerateKeychainDevCerts(); - for (const entry of keychainEntries) { - if (seenThumbprints.has(entry.thumbprint)) continue; - classifyCandidate({ - kind: "forcedSkip", - source: context, - reason: vscode.l10n.t( - "present in keychain but no matching PFX in {0}", - `${this.devCertsDir}/aspnetcore-localhost-${entry.thumbprint}.pfx` - ), - metadata: { - thumbprint: entry.thumbprint, - subjectCN: entry.cert.subjectCN, - version: getCertificateVersion(entry.cert), - notBefore: entry.cert.notBefore, - notAfter: entry.cert.notAfter, - }, - }); - } - - return selectBestDevCert(usable, this.devCertsDir); - } - - /** - * Returns true if a certificate with the given SHA-1 thumbprint is - * present in the login keychain. Uses `security find-certificate -Z` - * which only reads the public certificate — no ACL prompt is raised - * regardless of the cert's private-key ACL. - */ - private async isCertInKeychain(thumbprint: string): Promise { - const result = await runProcess("security", [ - "find-certificate", - "-Z", - thumbprint, - this.keychainPath, - ]); - return result.exitCode === 0; - } - - /** - * Enumerate ASP.NET dev cert candidates that exist in the login keychain. - * Returns parsed certs whose CN is `localhost`, that bear the ASP.NET - * custom OID, and whose validity window is current. Public-only, no - * prompts. - */ - private async enumerateKeychainDevCerts(): Promise< - Array<{ cert: DevCert; thumbprint: string }> - > { - const result = await runProcess("security", [ - "find-certificate", - "-a", - "-p", - "-Z", - this.keychainPath, - ]); - if (result.exitCode !== 0) return []; - - const out: Array<{ cert: DevCert; thumbprint: string }> = []; - const pemBlocks = extractPemBlocks(result.stdout); - for (const pem of pemBlocks) { - try { - const cert = new DevCert(pem); - if (cert.subjectCN !== "localhost") continue; - if (!cert.hasExtension(ASPNET_HTTPS_OID)) continue; - if (!isValidDevCert(cert)) continue; - out.push({ cert, thumbprint: cert.thumbprintSha1 }); - } catch { - // Skip lines that don't parse as a cert (the -a -p -Z output - // includes SHA-1 lines interleaved with PEM blocks). - } - } - return out; - } - - async saveCertificate( - cert: DevCert, - key: DevKey, - thumbprint: string - ): Promise { - fs.mkdirSync(this.devCertsDir, { recursive: true }); - const pfxPath = path.join( - this.devCertsDir, - `aspnetcore-localhost-${thumbprint}.pfx` - ); - // ~/.aspnet/dev-certs/https/*.pfx contains the private key; force 0o600 - // so it can't be read by other users on a multi-user mac. - await this.writePfx(cert, key, pfxPath, "", 0o600); - } - - async trustCertificate(cert: DevCert): Promise { - // /tmp is shared on macOS; an unguessable filename rules out symlink - // races on the temporary public-cert artifact. - const tmpCert = path.join(os.tmpdir(), `devcert-trust-${randomUUID()}.cer`); - fs.writeFileSync(tmpCert, certToDer(cert)); - - const result = await runProcess("security", [ - "add-trusted-cert", - "-p", - "basic", - "-p", - "ssl", - "-k", - this.keychainPath, - tmpCert, - ]); - - try { - fs.unlinkSync(tmpCert); - } catch { - /* ignore */ - } - - if (result.exitCode !== 0) { - throw new Error( - `Failed to trust certificate in keychain: ${result.stderr}` - ); - } - } - - async removeCertificates(): Promise { - // Collect SHA-1 thumbprints of every dev cert we have on disk so we - // can delete keychain entries by hash. Matching on `-c localhost` is - // too broad — the user may have unrelated `localhost` certs added - // for other tools and we don't want to nuke those. - const thumbprints = new Set(); - if (fs.existsSync(this.devCertsDir)) { - const pfxFiles = fs - .readdirSync(this.devCertsDir) - .filter( - (f) => f.startsWith("aspnetcore-localhost-") && f.endsWith(".pfx") - ); - for (const pfxFile of pfxFiles) { - try { - const result = await this.loadPfx(path.join(this.devCertsDir, pfxFile)); - if (result && result.cert.hasExtension(ASPNET_HTTPS_OID)) { - thumbprints.add(result.thumbprint); - } - } catch { - // Skip unparseable files; they're not ours to delete by hash. - } - } - } - - for (const thumbprint of thumbprints) { - // delete-certificate exits non-zero once there are no more entries - // matching the hash; loop with a generous bound to drain any - // duplicates left by past regenerations. - for (let i = 0; i < 100; i++) { - const result = await runProcess("security", [ - "delete-certificate", - "-Z", - thumbprint, - this.keychainPath, - ]); - if (result.exitCode !== 0) break; - } - } - - // Remove trust settings entries that pointed at any of those certs. - await runProcess("security", [ - "remove-trusted-cert", - "-d", - this.keychainPath, - ]); - - // Remove PFX files from disk - if (fs.existsSync(this.devCertsDir)) { - const pfxFiles = fs - .readdirSync(this.devCertsDir) - .filter( - (f) => f.startsWith("aspnetcore-localhost-") && f.endsWith(".pfx") - ); - for (const pfxFile of pfxFiles) { - fs.unlinkSync(path.join(this.devCertsDir, pfxFile)); - } - } - } - - protected async isTrusted( - cert: DevCert, - _thumbprint: string - ): Promise { - const tmpCert = path.join(os.tmpdir(), `devcert-verify-${randomUUID()}.cer`); - try { - fs.writeFileSync(tmpCert, certToDer(cert)); - - const result = await runProcess("security", [ - "verify-cert", - "-c", - tmpCert, - "-p", - "ssl", - ]); - - return result.exitCode === 0; - } finally { - try { - fs.unlinkSync(tmpCert); - } catch { - // best effort cleanup - } - } - } -} - -/** - * Pull PEM-encoded CERTIFICATE blocks out of `security find-certificate -p` - * output. The CLI interleaves SHA-1 hash lines (when `-Z` is passed) with - * the PEM blocks; we just grab the blocks. - */ -function extractPemBlocks(text: string): string[] { - const blocks: string[] = []; - const regex = /-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g; - let m: RegExpExecArray | null; - while ((m = regex.exec(text)) !== null) { - blocks.push(m[0]); - } - return blocks; -} +// Re-export shim: canonical home is `@devcontainer-dev-certs/shared`. +export { MacCertificateStore } from "@devcontainer-dev-certs/shared"; diff --git a/src/vscode-ui-extension/src/platform/nssTrust.ts b/src/vscode-ui-extension/src/platform/nssTrust.ts index f178fe4..dba495a 100644 --- a/src/vscode-ui-extension/src/platform/nssTrust.ts +++ b/src/vscode-ui-extension/src/platform/nssTrust.ts @@ -1,264 +1,3 @@ -import * as fs from "fs"; -import * as os from "os"; -import * as path from "path"; -import { runProcess } from "./processUtil"; -import { log } from "@devcontainer-dev-certs/shared"; - -export interface NssTrustResult { - success: boolean; - message: string; -} - -const CERT_NAME = "Dev Container Dev Cert"; - -type NssTargetKind = "chromium-shared" | "firefox-profiles"; - -interface NssTarget { - label: string; - kind: NssTargetKind; - root: string; -} - -interface DbOutcome { - label: string; - ok: boolean; - stderr?: string; -} - -/** - * Enumerate well-known NSS database roots on Linux. Covers native packages, - * Snap and Flatpak sandbox roots, and a handful of Firefox forks that keep - * their own profile directory. The list is ordered most-common-first; missing - * roots are silently skipped at scan time. - */ -function getNssTargets(home: string): NssTarget[] { - const chromium = (label: string, ...segs: string[]): NssTarget => ({ - label, - kind: "chromium-shared", - root: path.join(home, ...segs), - }); - const firefox = (label: string, ...segs: string[]): NssTarget => ({ - label, - kind: "firefox-profiles", - root: path.join(home, ...segs), - }); - - return [ - // Chromium-family browsers share a single user NSS DB. The native - // ~/.pki/nssdb path is the historical shared store used by Chromium, - // Chrome, Brave, Vivaldi, Edge, and Opera when installed as deb/rpm/AUR. - chromium("Chromium", ".pki", "nssdb"), - chromium("Chromium (Snap)", "snap", "chromium", "common", ".pki", "nssdb"), - chromium( - "Chromium (Flatpak)", - ".var", - "app", - "org.chromium.Chromium", - ".pki", - "nssdb" - ), - chromium( - "Chrome (Flatpak)", - ".var", - "app", - "com.google.Chrome", - ".pki", - "nssdb" - ), - chromium( - "Brave (Flatpak)", - ".var", - "app", - "com.brave.Browser", - ".pki", - "nssdb" - ), - chromium( - "Vivaldi (Flatpak)", - ".var", - "app", - "com.vivaldi.Vivaldi", - ".pki", - "nssdb" - ), - chromium( - "Edge (Flatpak)", - ".var", - "app", - "com.microsoft.Edge", - ".pki", - "nssdb" - ), - - // Firefox-family browsers keep an NSS DB per profile under a known root. - firefox("Firefox", ".mozilla", "firefox"), - firefox( - "Firefox (Snap)", - "snap", - "firefox", - "common", - ".mozilla", - "firefox" - ), - firefox( - "Firefox (Flatpak)", - ".var", - "app", - "org.mozilla.firefox", - ".mozilla", - "firefox" - ), - firefox("Firefox ESR", ".mozilla", "firefox-esr"), - firefox("LibreWolf", ".librewolf"), - firefox( - "LibreWolf (Flatpak)", - ".var", - "app", - "io.gitlab.librewolf-community", - ".librewolf" - ), - firefox("Waterfox", ".waterfox"), - firefox("Floorp", ".floorp"), - ]; -} - -/** - * Attempt to trust a PEM certificate in NSS databases used by Linux browsers. - * - * Requires `certutil` on the PATH (from libnss3-tools / nss-tools / nss). - * Enumerates well-known NSS database roots — native deb/rpm installs, Snap - * and Flatpak sandboxes, and Firefox forks — and adds the certificate to each - * existing database. Targets that aren't installed are skipped silently; the - * returned message lists only databases we actually touched. - */ -export async function trustInNss(pemPath: string): Promise { - const which = await runProcess("which", ["certutil"]); - if (which.exitCode !== 0) { - return { - success: false, - message: - "certutil is not installed. Install libnss3-tools (Debian/Ubuntu), nss-tools (Fedora/RHEL), or nss (Arch) to enable automatic browser trust.", - }; - } - - const outcomes: DbOutcome[] = []; - const targets = getNssTargets(os.homedir()); - - for (const target of targets) { - if (target.kind === "chromium-shared") { - await scanChromiumShared(target, pemPath, outcomes); - } else { - await scanFirefoxProfiles(target, pemPath, outcomes); - } - } - - if (outcomes.length === 0) { - return { - success: false, - message: - "No browser NSS databases found. Open Firefox or Chromium at least once to create a profile, then try again.", - }; - } - - const trusted = outcomes.filter((o) => o.ok).map((o) => o.label); - const failed = outcomes.filter((o) => !o.ok); - - const parts: string[] = []; - if (trusted.length > 0) parts.push(`Trusted in: ${trusted.join(", ")}`); - for (const f of failed) { - parts.push(`${f.label}: failed (${f.stderr?.trim() ?? "unknown error"})`); - } - - return { - success: failed.length === 0, - message: parts.join("; "), - }; -} - -async function scanChromiumShared( - target: NssTarget, - pemPath: string, - outcomes: DbOutcome[] -): Promise { - if (!fs.existsSync(path.join(target.root, "cert9.db"))) { - log(`NSS scan: ${target.label} not present at ${target.root}, skipping.`); - return; - } - const r = await trustInNssDb(`sql:${target.root}`, pemPath); - outcomes.push({ - label: target.label, - ok: r.exitCode === 0, - stderr: r.stderr, - }); -} - -async function scanFirefoxProfiles( - target: NssTarget, - pemPath: string, - outcomes: DbOutcome[] -): Promise { - if (!fs.existsSync(target.root)) { - log(`NSS scan: ${target.label} not present at ${target.root}, skipping.`); - return; - } - - let entries: string[]; - try { - entries = fs.readdirSync(target.root); - } catch (err: unknown) { - const message = err instanceof Error ? err.message : String(err); - log(`NSS scan: failed to enumerate ${target.label} profiles: ${message}`); - return; - } - - const profiles = entries.filter((d) => { - try { - return fs.existsSync(path.join(target.root, d, "cert9.db")); - } catch { - return false; - } - }); - - if (profiles.length === 0) { - log(`NSS scan: ${target.label} has no profiles with cert9.db, skipping.`); - return; - } - - for (const profile of profiles) { - const dbPath = path.join(target.root, profile); - const r = await trustInNssDb(`sql:${dbPath}`, pemPath); - outcomes.push({ - label: `${target.label} (${profile})`, - ok: r.exitCode === 0, - stderr: r.stderr, - }); - } -} - -async function trustInNssDb( - dbArg: string, - pemPath: string -): Promise<{ exitCode: number; stderr: string }> { - // Remove any existing cert with this name first to make the operation idempotent - await runProcess("certutil", ["-D", "-d", dbArg, "-n", CERT_NAME]); - - const result = await runProcess("certutil", [ - "-A", - "-d", - dbArg, - "-t", - "CT,,", - "-n", - CERT_NAME, - "-i", - pemPath, - ]); - - if (result.exitCode === 0) { - log(`Trusted cert in NSS database: ${dbArg}`); - } else { - log(`Failed to trust cert in ${dbArg}: ${result.stderr}`); - } - - return { exitCode: result.exitCode, stderr: result.stderr }; -} +// Re-export shim: canonical home is `@devcontainer-dev-certs/shared`. +export { trustInNss } from "@devcontainer-dev-certs/shared"; +export type { NssTrustResult } from "@devcontainer-dev-certs/shared"; diff --git a/src/vscode-ui-extension/src/platform/processUtil.ts b/src/vscode-ui-extension/src/platform/processUtil.ts index 0ccdbb9..df7686c 100644 --- a/src/vscode-ui-extension/src/platform/processUtil.ts +++ b/src/vscode-ui-extension/src/platform/processUtil.ts @@ -1,39 +1,3 @@ -import { execFile } from "child_process"; -import { promisify } from "util"; - -const execFileAsync = promisify(execFile); - -export interface ProcessResult { - exitCode: number; - stdout: string; - stderr: string; -} - -/** - * Run an external process and return its exit code, stdout, and stderr. - * Does not throw on non-zero exit codes. - */ -export async function runProcess( - command: string, - args: string[], - timeout: number = 30000 -): Promise { - try { - const result = await execFileAsync(command, args, { timeout }); - return { exitCode: 0, stdout: result.stdout, stderr: result.stderr }; - } catch (err: unknown) { - const error = err as Error & { - code?: number | string; - stdout?: string; - stderr?: string; - }; - // If the process ran but returned non-zero, we still have stdout/stderr - const exitCode = - typeof error.code === "number" ? error.code : 1; - return { - exitCode, - stdout: error.stdout ?? "", - stderr: error.stderr ?? error.message, - }; - } -} +// Re-export shim: canonical home is `@devcontainer-dev-certs/shared`. +export { runProcess } from "@devcontainer-dev-certs/shared"; +export type { ProcessResult } from "@devcontainer-dev-certs/shared"; diff --git a/src/vscode-ui-extension/src/platform/types.ts b/src/vscode-ui-extension/src/platform/types.ts index 7ac2040..12314f6 100644 --- a/src/vscode-ui-extension/src/platform/types.ts +++ b/src/vscode-ui-extension/src/platform/types.ts @@ -1,112 +1,12 @@ -import { type DevCert, type DevKey } from "../cert/types"; -import { type NssTrustResult } from "./nssTrust"; - -/** - * Callback the Linux store invokes after attempting browser-NSS trust as - * part of `trustCertificate`. Lets the extension host surface a guidance - * toast on failure without giving the platform store a direct dependency - * on `vscode`. - */ -export type LinuxNssTrustReporter = ( - result: NssTrustResult, - pemPath: string -) => void; - -export interface CreatePlatformStoreOptions { - /** Optional reporter for NSS trust outcomes; honored only on Linux. */ - linuxNssTrustReporter?: LinuxNssTrustReporter; -} - -export interface CertificateStatus { - exists: boolean; - isTrusted: boolean; - /** SHA-1 thumbprint, uppercase hex (matches `X509Certificate2.Thumbprint`). */ - thumbprint: string | null; - notBefore: string | null; - notAfter: string | null; - version: number; -} - -/** - * Platform-specific certificate store interface. - * - * Throughout this interface, `thumbprint` is the SHA-1 thumbprint - * (`DevCert.thumbprintSha1`). This is what .NET, OpenSSL trust dirs, and - * the Windows / macOS stores use to identify and name cert files. The - * stronger `DevCert.thumbprint` (SHA-256) is for in-process identity only. - */ -export interface PlatformCertificateStore { - /** - * Find an existing valid ASP.NET dev cert in the platform store. - * Returns the cert, key, and SHA-1 thumbprint if found. - */ - findExistingDevCert(): Promise<{ - cert: DevCert; - key: DevKey; - thumbprint: string; - } | null>; - - /** - * Save a certificate with its private key to the platform store. - * `thumbprint` is the SHA-1 thumbprint and becomes the .NET X509Store - * filename stem (`{thumbprint}.pfx`). - */ - saveCertificate( - cert: DevCert, - key: DevKey, - thumbprint: string - ): Promise; - - /** - * Trust a certificate so the OS/browser accepts it. - */ - trustCertificate(cert: DevCert): Promise; - - /** - * Verify on-disk / OS trust state for a specific certificate. Callers - * use this to short-circuit redundant `trustCertificate` calls — on - * macOS in particular, `security add-trusted-cert` is not a true - * no-op for an already-trusted cert (it re-touches the trust-settings - * record and may re-prompt for the keychain password). The cache that - * the host's CertProvider maintains is a goal-state, not a record of - * machine state — every trust operation re-verifies trust here - * before deciding whether to invoke trustCertificate again. - */ - isCertTrusted(cert: DevCert): Promise; - - /** - * Remove dev certificates from all stores. - */ - removeCertificates(): Promise; - - /** - * Check the status of the dev certificate. - */ - checkStatus(): Promise; -} - -/** - * Create the appropriate store for the current platform. - */ -export async function createPlatformStore( - options: CreatePlatformStoreOptions = {} -): Promise { - switch (process.platform) { - case "win32": { - const { WindowsCertificateStore } = await import("./windowsStore.js"); - return new WindowsCertificateStore(); - } - case "darwin": { - const { MacCertificateStore } = await import("./macStore.js"); - return new MacCertificateStore(); - } - case "linux": { - const { LinuxCertificateStore } = await import("./linuxStore.js"); - return new LinuxCertificateStore({ - nssTrustReporter: options.linuxNssTrustReporter, - }); - } - default: - throw new Error(`Unsupported platform: ${process.platform}`); - } -} +// Re-export shim: the canonical home for the platform store types and the +// `createPlatformStore` factory is now `@devcontainer-dev-certs/shared`. +// Keeping this thin re-export so existing `./platform/types` imports across +// the UI extension and its test suite keep resolving without a rename. +export type { + LinuxNssTrustReporter, + BaseStoreOptions, + CreatePlatformStoreOptions, + CertificateStatus, + PlatformCertificateStore, +} from "@devcontainer-dev-certs/shared"; +export { createPlatformStore } from "@devcontainer-dev-certs/shared"; diff --git a/src/vscode-ui-extension/src/platform/windowsStore.ts b/src/vscode-ui-extension/src/platform/windowsStore.ts index 939034a..f3ab097 100644 --- a/src/vscode-ui-extension/src/platform/windowsStore.ts +++ b/src/vscode-ui-extension/src/platform/windowsStore.ts @@ -1,417 +1,9 @@ -import { randomUUID } from "crypto"; -import * as fs from "fs"; -import * as os from "os"; -import * as path from "path"; -import * as vscode from "vscode"; -import { - BaseCertificateStore, - classifyCandidate, - selectBestDevCert, - type UsableDevCert, -} from "./baseStore"; -import { runProcess } from "./processUtil"; -import { ASPNET_HTTPS_OID } from "../cert/properties"; -import { certToDer } from "../cert/exporter"; -import { type DevCert, type DevKey } from "../cert/types"; - -/** Cached PowerShell executable name — prefers pwsh (PowerShell 7+) over powershell (5.1). */ -let resolvedPwsh: string | null = null; - -export type WindowsStoreLocation = "CurrentUser" | "LocalMachine"; - -async function getPowerShell(): Promise { - if (resolvedPwsh) return resolvedPwsh; - - const pwshResult = await runProcess("pwsh", ["-NoProfile", "-Command", "echo ok"]); - if (pwshResult.exitCode === 0) { - resolvedPwsh = "pwsh"; - } else { - resolvedPwsh = "powershell"; - } - return resolvedPwsh; -} - -/** - * Shape of one entry in the PowerShell enumeration script's `candidates` - * array — a cert whose private key was successfully exported as a PFX. - */ -export interface PsCandidate { - thumbprint: string; - pfxPath: string; - subjectCN: string | null; - notBefore: string; - notAfter: string; -} - -/** Classification of why the PS script couldn't export a cert. */ -export type PsSkipReason = - | "no-private-key" - | "not-exportable" - | "export-failed"; - -/** - * Shape of one entry in the PowerShell enumeration script's `skipped` - * array — a cert that matched the dev-cert OID but couldn't be exported - * (no private key, or key not exportable). - * - * `reasonDetail` carries the underlying exception message for the - * "export-failed" code (we can't classify it more precisely without - * reaching into .NET-specific types). TS-side maps the code to a - * localized human-readable reason; details get appended verbatim. - */ -export interface PsSkipped { - thumbprint: string; - subjectCN: string | null; - notBefore: string; - notAfter: string; - reasonCode: PsSkipReason; - reasonDetail?: string; -} - -export interface PsEnumeration { - candidates: PsCandidate[]; - skipped: PsSkipped[]; -} - -/** - * Windows certificate store implementation. - * - * Uses PowerShell to interact with the Windows Certificate Store: - * - CurrentUser\My: stores cert with private key - * - CurrentUser\Root: trusts the public cert - * - * Prefers pwsh (PowerShell 7+) when available, falls back to powershell (5.1). - */ -export class WindowsCertificateStore extends BaseCertificateStore { - constructor(private readonly storeLocation: WindowsStoreLocation = "CurrentUser") { - super(); - } - - async findExistingDevCert(): Promise { - // Enumerate every dev-cert candidate in the configured My store, then - // attempt to export each as a PBES2/AES PFX. We hand selection back to - // shared TS so the version-byte tiebreaker logic stays in one place. - // - // The script stays inside the PS cert-provider surface and built-in - // cmdlets — no `New-Object System.*`, no explicit [System.X.Y.Z] type - // references, no .NET-specific property paths (e.g. CNG-only - // PrivateKey.Key.ExportPolicy). Properties accessed on the - // X509Certificate2 objects yielded by `Cert:\…` are the same surface - // the rest of the codebase already relies on (Thumbprint, Subject, - // NotBefore/NotAfter, HasPrivateKey, Extensions). - const script = ` - $ErrorActionPreference = 'Stop' - $oid = '${ASPNET_HTTPS_OID}' - $candidates = @() - $skipped = @() - $certs = Get-ChildItem Cert:\\${this.storeLocation}\\My | Where-Object { - $_.Extensions | Where-Object { $_.Oid.Value -eq $oid } - } - foreach ($cert in $certs) { - $thumb = $cert.Thumbprint - # Subject is a comma-separated RDN string ("CN=localhost, O=..."). Pull - # the first CN out via regex instead of GetNameInfo, which would need - # an explicit [System.Security.Cryptography.X509Certificates.X509NameType] - # type reference. - $cn = $null - if ($cert.Subject -match 'CN=([^,]+)') { $cn = $matches[1].Trim() } - $nbf = $cert.NotBefore.ToUniversalTime().ToString('o') - $exp = $cert.NotAfter.ToUniversalTime().ToString('o') - if (-not $cert.HasPrivateKey) { - $skipped += @{ thumbprint = $thumb; subjectCN = $cn; notBefore = $nbf; notAfter = $exp; reasonCode = 'no-private-key' } - continue - } - try { - $tmpPfx = Join-Path $env:TEMP ("devcert-" + [guid]::NewGuid().ToString("N") + ".pfx") - $pwd = ConvertTo-SecureString -String 'export' -Force -AsPlainText - # AES256_SHA256 forces a PBES2/AES PFX. The default (TripleDES_SHA1) - # produces a legacy PKCS#12 PBE format that our pkijs-based parser - # deliberately rejects (see cert/pfx.ts). - Export-PfxCertificate -Cert $cert -FilePath $tmpPfx -Password $pwd -CryptoAlgorithmOption AES256_SHA256 | Out-Null - $candidates += @{ thumbprint = $thumb; pfxPath = $tmpPfx; subjectCN = $cn; notBefore = $nbf; notAfter = $exp } - } catch { - # No CNG / RSA-specific introspection here — that would mean - # touching .NET types beyond what the cert provider already - # surfaces. Coarse message-string matching distinguishes the - # "key locked" case from everything else; TS-side maps the code - # to a localized human-readable reason. - $msg = $_.Exception.Message - if ($msg -match 'not exportable' -or $msg -match 'cannot be exported') { - $skipped += @{ thumbprint = $thumb; subjectCN = $cn; notBefore = $nbf; notAfter = $exp; reasonCode = 'not-exportable' } - } else { - $skipped += @{ thumbprint = $thumb; subjectCN = $cn; notBefore = $nbf; notAfter = $exp; reasonCode = 'export-failed'; reasonDetail = $msg } - } - } - } - $payload = @{ candidates = $candidates; skipped = $skipped } - $payload | ConvertTo-Json -Compress -Depth 4 - `; - - const pwsh = await getPowerShell(); - const result = await runProcess(pwsh, [ - "-NoProfile", - "-NonInteractive", - "-Command", - script, - ]); - - if (result.exitCode !== 0) return null; - - const parsed = parseEnumeration(result.stdout); - if (!parsed) return null; - - const tempPfxPaths: string[] = parsed.candidates.map((c) => c.pfxPath); - try { - const usable: UsableDevCert[] = []; - const storeContext = `Windows ${this.storeLocation}\\My`; - - for (const cand of parsed.candidates) { - const loaded = await this.loadPfxLenient(cand.pfxPath, "export"); - if (!loaded) { - // PFX produced by Export-PfxCertificate failed to parse — surface - // as an unusable warning so the user has visibility. - classifyCandidate({ - kind: "forcedSkip", - source: storeContext, - reason: vscode.l10n.t( - "Export-PfxCertificate produced a PFX that could not be parsed" - ), - metadata: { - thumbprint: cand.thumbprint, - subjectCN: cand.subjectCN, - notBefore: parseDateOrNull(cand.notBefore), - notAfter: parseDateOrNull(cand.notAfter), - }, - }); - continue; - } - - const classified = classifyCandidate({ - kind: "loaded", - source: storeContext, - loaded, - }); - if (classified === null) continue; - if (classified.kind === "usable") usable.push(classified); - } - - for (const sk of parsed.skipped) { - // Re-apply isValidDevCert gates against the metadata we received so - // we don't emit the unusable warning for clearly-unrelated certs - // that happened to share the OID but are e.g. expired. - if (!metadataLooksLikeValidDevCert(sk)) continue; - classifyCandidate({ - kind: "forcedSkip", - source: storeContext, - reason: localizeSkipReason(sk), - metadata: { - thumbprint: sk.thumbprint, - subjectCN: sk.subjectCN, - notBefore: parseDateOrNull(sk.notBefore), - notAfter: parseDateOrNull(sk.notAfter), - }, - }); - } - - return selectBestDevCert(usable, storeContext); - } finally { - for (const p of tempPfxPaths) { - try { - fs.unlinkSync(p); - } catch { - // best effort cleanup - } - } - } - } - - async saveCertificate( - cert: DevCert, - key: DevKey, - _thumbprint: string - ): Promise { - // Export to temp PFX, then import via Import-PfxCertificate. Our - // hand-rolled DER PFX writer (cert/pfx.ts) emits a PFX that CryptoAPI's - // PFXImportCertStore — the function this cmdlet wraps — accepts cleanly. - // Unguessable filename + 0o600 keeps the PFX (with private key) - // unreadable to other local users during the import window. - const tmpPfx = path.join(os.tmpdir(), `devcert-save-${randomUUID()}.pfx`); - await this.writePfx(cert, key, tmpPfx, "import", 0o600); - - const script = - `$ErrorActionPreference = 'Stop'; ` + - `$pwd = ConvertTo-SecureString -String 'import' -Force -AsPlainText; ` + - `Import-PfxCertificate -FilePath '${tmpPfx.replace(/'/g, "''")}' -CertStoreLocation Cert:\\${this.storeLocation}\\My -Password $pwd -Exportable | Out-Null; ` + - `Remove-Item '${tmpPfx.replace(/'/g, "''")}'`; - - const pwsh = await getPowerShell(); - const result = await runProcess(pwsh, [ - "-NoProfile", - "-NonInteractive", - "-Command", - script, - ]); - - if (result.exitCode !== 0) { - // Clean up temp file if PowerShell didn't - try { - fs.unlinkSync(tmpPfx); - } catch { - /* ignore */ - } - throw new Error( - `Failed to save certificate to Windows store: ${result.stderr}` - ); - } - } - - async trustCertificate(cert: DevCert): Promise { - // Use certutil.exe — the built-in Windows CA admin tool from - // %SystemRoot%\System32, present on every Windows install since XP — to - // add the public cert to the configured Root store. - // - // We can't go back to `Import-Certificate` (the PowerShell PKI cmdlet - // PR #36 switched to): when it targets Cert:\CurrentUser\Root the - // underlying CryptoAPI call shows a "You are about to install a - // certificate from a certification authority..." confirmation dialog, - // which fails under `-NonInteractive` with "UI is not allowed in this - // operation." We also intentionally avoid the older path of - // `New-Object System.Security.Cryptography.X509Certificates.X509Store` - // — the host extension is meant to work without taking on a .NET - // dependency. certutil.exe uses CryptoAPI directly, skips the - // confirmation dialog, and is the same tool mkcert and similar dev-cert - // utilities use on Windows for the same reason. - // - // Public-cert only — no private key — but the random name still - // prevents concurrent invocations from colliding on the same temp path. - const tmpCert = path.join(os.tmpdir(), `devcert-trust-${randomUUID()}.cer`); - fs.writeFileSync(tmpCert, certToDer(cert)); - - const args = ["-f"]; - if (this.storeLocation === "CurrentUser") args.push("-user"); - args.push("-addstore", "Root", tmpCert); - - const result = await runProcess("certutil.exe", args); - - try { - fs.unlinkSync(tmpCert); - } catch { - /* best effort */ - } - - if (result.exitCode !== 0) { - throw new Error( - `Failed to trust certificate on Windows: ${result.stderr || result.stdout}` - ); - } - } - - async removeCertificates(): Promise { - const script = ` - $ErrorActionPreference = 'SilentlyContinue' - $oid = '${ASPNET_HTTPS_OID}' - foreach ($storePath in @('Cert:\\${this.storeLocation}\\My', 'Cert:\\${this.storeLocation}\\Root')) { - Get-ChildItem $storePath | Where-Object { - $_.Extensions | Where-Object { $_.Oid.Value -eq $oid } - } | ForEach-Object { - Remove-Item -LiteralPath $_.PSPath -Force - } - } - `; - - const pwsh = await getPowerShell(); - await runProcess(pwsh, [ - "-NoProfile", - "-NonInteractive", - "-Command", - script, - ]); - } - - protected async isTrusted( - _cert: DevCert, - thumbprint: string - ): Promise { - const script = ` - $cert = Get-ChildItem Cert:\\${this.storeLocation}\\Root | Where-Object { $_.Thumbprint -eq '${thumbprint}' } - if ($cert) { Write-Output 'true' } else { Write-Output 'false' } - `; - - const pwsh = await getPowerShell(); - const result = await runProcess(pwsh, [ - "-NoProfile", - "-NonInteractive", - "-Command", - script, - ]); - - return result.stdout.trim() === "true"; - } -} - -function parseEnumeration(stdout: string): PsEnumeration | null { - const trimmed = stdout.trim(); - if (!trimmed) return { candidates: [], skipped: [] }; - - let parsed: unknown; - try { - parsed = JSON.parse(trimmed); - } catch { - return null; - } - - // PowerShell's ConvertTo-Json emits a hashtable with a single child as a - // bare object rather than a 1-element array; coerce defensively. - if (!parsed || typeof parsed !== "object") return null; - const root = parsed as Record; - return { - candidates: coerceArray(root.candidates), - skipped: coerceArray(root.skipped), - }; -} - -function coerceArray(value: unknown): T[] { - if (value === undefined || value === null) return []; - if (Array.isArray(value)) return value as T[]; - return [value as T]; -} - -function parseDateOrNull(s: string | null | undefined): Date | null { - if (!s) return null; - const d = new Date(s); - return Number.isNaN(d.getTime()) ? null : d; -} - -function localizeSkipReason(sk: PsSkipped): string { - switch (sk.reasonCode) { - case "no-private-key": - return vscode.l10n.t("no private key in store"); - case "not-exportable": - return vscode.l10n.t("private key not exportable"); - case "export-failed": - return vscode.l10n.t( - "Export-PfxCertificate failed: {0}", - sk.reasonDetail ?? "" - ); - } -} - -function metadataLooksLikeValidDevCert(sk: PsSkipped): boolean { - // The PS script already filtered by ASPNET_HTTPS_OID. We layer CN + - // validity-window checks on top so we don't emit the unusable warning - // for clearly-unrelated certs (e.g. expired or differently-named) that - // happened to carry the same OID. We can't check the version byte from - // TS without parsing the cert, so we skip that gate here. - // - // CN comparison is intentionally exact-match to mirror isValidDevCert - // (see cert/generator.ts:isValidDevCert) — staying loose here while the - // canonical gate is strict would just produce warnings for certs we'd - // never accept downstream anyway. - if (sk.subjectCN !== "localhost") return false; - const nbf = parseDateOrNull(sk.notBefore); - const exp = parseDateOrNull(sk.notAfter); - if (!nbf || !exp) return false; - const now = new Date(); - if (nbf > now || exp < now) return false; - return true; -} +// Re-export shim: canonical home is `@devcontainer-dev-certs/shared`. +export { WindowsCertificateStore } from "@devcontainer-dev-certs/shared"; +export type { + WindowsStoreLocation, + PsCandidate, + PsSkipped, + PsSkipReason, + PsEnumeration, +} from "@devcontainer-dev-certs/shared"; diff --git a/src/vscode-ui-extension/tests/_helpers.ts b/src/vscode-ui-extension/tests/_helpers.ts new file mode 100644 index 0000000..64b58ac --- /dev/null +++ b/src/vscode-ui-extension/tests/_helpers.ts @@ -0,0 +1,20 @@ +/** + * Shared test helpers for the UI extension test suite. Mirrors the + * CLI workspace's `tests/_helpers.ts` — same contract, different + * workspace, kept in sync by hand because no cross-workspace test- + * helpers package exists. + */ + +/** + * Override `process.platform` for the duration of a test. Returns a + * restore callback to call from `finally` (or `afterEach`) so the + * stub doesn't leak into sibling tests. Tolerant of platforms where + * the original descriptor is undefined. + */ +export function stubPlatform(value: NodeJS.Platform): () => void { + const original = Object.getOwnPropertyDescriptor(process, "platform"); + Object.defineProperty(process, "platform", { value, configurable: true }); + return () => { + if (original) Object.defineProperty(process, "platform", original); + }; +} diff --git a/src/vscode-ui-extension/tests/classifyCandidate.test.ts b/src/vscode-ui-extension/tests/classifyCandidate.test.ts index dfc85cd..769d45c 100644 --- a/src/vscode-ui-extension/tests/classifyCandidate.test.ts +++ b/src/vscode-ui-extension/tests/classifyCandidate.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach } from "vitest"; -import { initLogger } from "@devcontainer-dev-certs/shared"; +import { initLogger } from "@devcontainer-dev-certs/shared/src/loggerVscode"; import { logMessages } from "./__mocks__/vscode"; import { classifyCandidate } from "../src/platform/baseStore"; import { generateCertificate } from "../src/cert/generator"; diff --git a/src/vscode-ui-extension/tests/containerCertAccept.test.ts b/src/vscode-ui-extension/tests/containerCertAccept.test.ts index 117dad8..1332907 100644 --- a/src/vscode-ui-extension/tests/containerCertAccept.test.ts +++ b/src/vscode-ui-extension/tests/containerCertAccept.test.ts @@ -10,10 +10,10 @@ import { DevCert, ASPNET_HTTPS_OID, CURRENT_CERTIFICATE_VERSION, - initLogger, SAN_DNS_NAMES, SAN_IP_ADDRESSES, } from "@devcontainer-dev-certs/shared"; +import { initLogger } from "@devcontainer-dev-certs/shared/src/loggerVscode"; import { acceptContainerDevCert, type AcceptContainerCertDeps, diff --git a/src/vscode-ui-extension/tests/dotnetBackend.test.ts b/src/vscode-ui-extension/tests/dotnetBackend.test.ts new file mode 100644 index 0000000..4d38fc0 --- /dev/null +++ b/src/vscode-ui-extension/tests/dotnetBackend.test.ts @@ -0,0 +1,252 @@ +import { + describe, + it, + expect, + beforeEach, + afterEach, + vi, +} from "vitest"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import type * as Shared from "@devcontainer-dev-certs/shared"; +import type * as PlatformTypes from "@devcontainer-dev-certs/shared/src/platform/types"; + +// DotnetBackend shells out to the real `dotnet` CLI (which isn't on the +// test runner). Mock the shell-out chokepoint and the platform-store +// constructor so the test drives a synthetic outcome end-to-end. +vi.mock("@devcontainer-dev-certs/shared/src/platform/processUtil", () => ({ + runProcess: vi.fn(), + resolveSafeExecPath: vi.fn((c: string) => c), +})); + +vi.mock("@devcontainer-dev-certs/shared/src/platform/types", async () => { + const actual = await vi.importActual( + "@devcontainer-dev-certs/shared/src/platform/types" + ); + return { ...actual, createPlatformStore: vi.fn() }; +}); + +vi.mock("@devcontainer-dev-certs/shared/src/platform/nssTrust", () => ({ + trustInNss: vi.fn(), +})); + +import { DotnetBackend } from "@devcontainer-dev-certs/shared"; +import { runProcess } from "@devcontainer-dev-certs/shared/src/platform/processUtil"; +import { createPlatformStore } from "@devcontainer-dev-certs/shared/src/platform/types"; +import { trustInNss } from "@devcontainer-dev-certs/shared/src/platform/nssTrust"; +import { + generateCertificate, + VALIDITY_DAYS, +} from "@devcontainer-dev-certs/shared"; + +const mockedRunProcess = vi.mocked(runProcess); +const mockedCreatePlatformStore = vi.mocked(createPlatformStore); +const mockedTrustInNss = vi.mocked(trustInNss); + +import { stubPlatform } from "./_helpers"; + +const cleanupDirs: string[] = []; + +async function makeCert(): ReturnType { + const now = new Date(); + const expiry = new Date(now.getTime() + VALIDITY_DAYS * 86400_000); + return generateCertificate(now, expiry); +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +afterEach(() => { + for (const dir of cleanupDirs) fs.rmSync(dir, { recursive: true, force: true }); + cleanupDirs.length = 0; +}); + +describe("DotnetBackend.generate", () => { + it("invokes `dotnet dev-certs https --trust` (no --no-password, no --format)", async () => { + const outDir = fs.mkdtempSync(path.join(os.tmpdir(), "devcerts-dotnet-")); + cleanupDirs.push(outDir); + const generated = await makeCert(); + + mockedRunProcess.mockResolvedValue({ exitCode: 0, stdout: "", stderr: "" }); + mockedCreatePlatformStore.mockResolvedValue({ + findExistingDevCert: vi.fn(async () => generated), + } as unknown as Shared.PlatformCertificateStore); + + const result = await new DotnetBackend().generate({ + outDir, + noTrust: false, + }); + + // Regression guard for the broken `--no-password` + `--format Pfx` + // combo: we should never invoke dotnet with either flag. + expect(mockedRunProcess).toHaveBeenCalledWith( + "dotnet", + ["dev-certs", "https", "--trust"], + expect.any(Number) + ); + for (const call of mockedRunProcess.mock.calls) { + const args = call[1]; + expect(args).not.toContain("--no-password"); + expect(args).not.toContain("--format"); + expect(args).not.toContain("--export-path"); + } + expect(result.thumbprint).toBe(generated.thumbprint); + }); + + it("omits --trust when noTrust is set", async () => { + const outDir = fs.mkdtempSync(path.join(os.tmpdir(), "devcerts-dotnet-")); + cleanupDirs.push(outDir); + const generated = await makeCert(); + + mockedRunProcess.mockResolvedValue({ exitCode: 0, stdout: "", stderr: "" }); + mockedCreatePlatformStore.mockResolvedValue({ + findExistingDevCert: vi.fn(async () => generated), + } as unknown as Shared.PlatformCertificateStore); + + await new DotnetBackend().generate({ outDir, noTrust: true }); + + expect(mockedRunProcess).toHaveBeenCalledWith( + "dotnet", + ["dev-certs", "https"], + expect.any(Number) + ); + }); + + it("writes PFX, PEM, and key into outDir using our naming (foo.key, not foo.pem.key)", async () => { + const outDir = fs.mkdtempSync(path.join(os.tmpdir(), "devcerts-dotnet-")); + cleanupDirs.push(outDir); + const generated = await makeCert(); + + mockedRunProcess.mockResolvedValue({ exitCode: 0, stdout: "", stderr: "" }); + mockedCreatePlatformStore.mockResolvedValue({ + findExistingDevCert: vi.fn(async () => generated), + } as unknown as Shared.PlatformCertificateStore); + + const result = await new DotnetBackend().generate({ + outDir, + noTrust: false, + }); + + // Naming matches the native backend so bundle.ts and inspect.ts + // sibling-discovery work uniformly. The dotnet-native convention + // `aspnetcore-dev.pem.key` must NOT leak through here. + expect(result.pfxPath).toBe(path.join(outDir, "aspnetcore-dev.pfx")); + expect(result.pemPath).toBe(path.join(outDir, "aspnetcore-dev.pem")); + expect(result.pemKeyPath).toBe(path.join(outDir, "aspnetcore-dev.key")); + expect(fs.existsSync(result.pfxPath)).toBe(true); + expect(fs.existsSync(result.pemPath)).toBe(true); + expect(fs.existsSync(result.pemKeyPath!)).toBe(true); + // Never write the dotnet-style naming as a byproduct. + expect(fs.existsSync(path.join(outDir, "aspnetcore-dev.pem.key"))).toBe(false); + }); + + it("supplements the trust step with NSS on Linux", async () => { + const restore = stubPlatform("linux"); + try { + const outDir = fs.mkdtempSync(path.join(os.tmpdir(), "devcerts-dotnet-")); + cleanupDirs.push(outDir); + const generated = await makeCert(); + + mockedRunProcess.mockResolvedValue({ exitCode: 0, stdout: "", stderr: "" }); + mockedCreatePlatformStore.mockResolvedValue({ + findExistingDevCert: vi.fn(async () => generated), + } as unknown as Shared.PlatformCertificateStore); + mockedTrustInNss.mockResolvedValue({ + success: true, + message: "ok", + }); + + const reporter = vi.fn(); + await new DotnetBackend().generate({ + outDir, + noTrust: false, + linuxNssTrustReporter: reporter, + }); + + // Verifies the gap-fill: dotnet --trust on Linux misses NSS + // browser DBs, so the backend invokes trustInNss separately. + expect(mockedTrustInNss).toHaveBeenCalledTimes(1); + expect(mockedTrustInNss).toHaveBeenCalledWith( + path.join(outDir, "aspnetcore-dev.pem") + ); + expect(reporter).toHaveBeenCalledTimes(1); + expect(reporter).toHaveBeenCalledWith( + { success: true, message: "ok" }, + path.join(outDir, "aspnetcore-dev.pem") + ); + } finally { + restore(); + } + }); + + it("does NOT run the NSS step on macOS (keychain handles browser trust)", async () => { + const restore = stubPlatform("darwin"); + try { + const outDir = fs.mkdtempSync(path.join(os.tmpdir(), "devcerts-dotnet-")); + cleanupDirs.push(outDir); + const generated = await makeCert(); + + mockedRunProcess.mockResolvedValue({ exitCode: 0, stdout: "", stderr: "" }); + mockedCreatePlatformStore.mockResolvedValue({ + findExistingDevCert: vi.fn(async () => generated), + } as unknown as Shared.PlatformCertificateStore); + + await new DotnetBackend().generate({ outDir, noTrust: false }); + + expect(mockedTrustInNss).not.toHaveBeenCalled(); + } finally { + restore(); + } + }); + + it("does NOT run the NSS step on Linux when noTrust is set", async () => { + const restore = stubPlatform("linux"); + try { + const outDir = fs.mkdtempSync(path.join(os.tmpdir(), "devcerts-dotnet-")); + cleanupDirs.push(outDir); + const generated = await makeCert(); + + mockedRunProcess.mockResolvedValue({ exitCode: 0, stdout: "", stderr: "" }); + mockedCreatePlatformStore.mockResolvedValue({ + findExistingDevCert: vi.fn(async () => generated), + } as unknown as Shared.PlatformCertificateStore); + + await new DotnetBackend().generate({ outDir, noTrust: true }); + + expect(mockedTrustInNss).not.toHaveBeenCalled(); + } finally { + restore(); + } + }); + + it("throws when dotnet exits non-zero", async () => { + const outDir = fs.mkdtempSync(path.join(os.tmpdir(), "devcerts-dotnet-")); + cleanupDirs.push(outDir); + + mockedRunProcess.mockResolvedValue({ + exitCode: 1, + stdout: "", + stderr: "Unrecognized command or argument 'whatever'", + }); + + await expect( + new DotnetBackend().generate({ outDir, noTrust: false }) + ).rejects.toThrow(/dotnet dev-certs failed.*Unrecognized command/); + }); + + it("throws with a clear message when the store has no cert post-trust", async () => { + const outDir = fs.mkdtempSync(path.join(os.tmpdir(), "devcerts-dotnet-")); + cleanupDirs.push(outDir); + + mockedRunProcess.mockResolvedValue({ exitCode: 0, stdout: "", stderr: "" }); + mockedCreatePlatformStore.mockResolvedValue({ + findExistingDevCert: vi.fn(async () => null), + } as unknown as Shared.PlatformCertificateStore); + + await expect( + new DotnetBackend().generate({ outDir, noTrust: false }) + ).rejects.toThrow(/no dev cert was found in the platform store/); + }); +}); diff --git a/src/vscode-ui-extension/tests/dotnetMacosCache.integration.test.ts b/src/vscode-ui-extension/tests/dotnetMacosCache.integration.test.ts new file mode 100644 index 0000000..61b36a6 --- /dev/null +++ b/src/vscode-ui-extension/tests/dotnetMacosCache.integration.test.ts @@ -0,0 +1,220 @@ +import { describe, it, expect, beforeAll } from "vitest"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { execFileSync } from "child_process"; +import * as pkijs from "pkijs"; +import { loadPfx } from "../src/cert/loader"; + +/** + * macOS-only integration test guarding the read-side compatibility + * between `parsePfx` and the PFX `aspnetcore`'s `MacOSCertificateManager. + * SaveCertificateCore` writes to disk at + * `~/.aspnet/dev-certs/https/aspnetcore-localhost-{thumbprint}.pfx`. + * + * The cache file is produced by `dotnet dev-certs https` (no flags) — + * the writer calls `certificate.Export(X509ContentType.Pfx)` with no + * password. On every currently-supported .NET SDK that path emits + * legacy `pbeWithSHA1And3-KeyTripleDES-CBC` (OID 1.2.840.113549.1.12.1.3) + * + SHA-1, 2000 iterations. We need our parser to read this; the + * narrow legacy-PBE handler in `pkcs12LegacyPbe.ts` exists exactly for + * this case. + * + * Two things this test pins: + * + * 1. The cache file loads via `parsePfx` — Kestrel discovery, the + * VS Code host extension's read path, the workspace-extension's + * cert push, and anything else that goes through `parsePfx` keeps + * working as long as this passes. + * 2. The on-disk PBE algorithm OID is the one our legacy handler is + * designed for. When aspnetcore eventually switches the macOS + * writer to `ExportPkcs12(PbeParameters(Aes256Cbc, …))` and ships + * PBES2 instead — the day this OID flips — the second assertion + * fails loudly and tells the next maintainer "the legacy 3DES + * handler in `pkcs12LegacyPbe.ts` can now be removed; see its + * removal checklist." That's the only way we'll know the + * workaround has aged out without monitoring upstream by hand. + * + * Both observations require running against a real dotnet on a real + * macOS host; the test self-skips elsewhere. + */ + +const isMacOS = process.platform === "darwin"; + +let dotnetMajor = 0; +try { + const v = execFileSync("dotnet", ["--version"], { + timeout: 5000, + stdio: ["ignore", "pipe", "pipe"], + }) + .toString() + .trim(); + dotnetMajor = Number.parseInt(v.split(".")[0] ?? "", 10) || 0; +} catch { + // dotnet not on PATH; the describe.skipIf below skips the suite. +} + +const ready = isMacOS && dotnetMajor >= 6; + +// OIDs we recognize at the EncryptedData layer of the PKCS#12. Any +// other value means the writer started emitting a format we haven't +// catalogued; the test fails so the discrepancy is investigated. +const OID_PBE_3DES_SHA1 = "1.2.840.113549.1.12.1.3"; +const OID_PBES2 = "1.2.840.113549.1.5.13"; + +let cachePfxPath: string; +let loadResult: + | { kind: "ok"; thumbprint: string; hasKey: boolean } + | { kind: "err"; message: string }; +let observedPbeOid: string | null = null; + +beforeAll(async () => { + if (!ready) return; + + // 60s timeout because first-run cert generation can be slow on cold + // CI runners. We use the real `$HOME` deliberately: macOS keychain + // APIs resolve the login keychain via `$HOME/Library/Keychains/` + // and `dotnet dev-certs` fails before it ever writes the disk + // cache if HOME points at a tmpdir with no keychain. + execFileSync("dotnet", ["dev-certs", "https"], { + timeout: 60_000, + stdio: "pipe", + }); + + const home = os.homedir(); + const cacheDir = path.join(home, ".aspnet", "dev-certs", "https"); + if (!fs.existsSync(cacheDir)) { + throw new Error( + `Expected aspnetcore disk cache at ${cacheDir}; directory does not exist. ` + + `dotnet dev-certs may have changed its on-disk layout.` + ); + } + const pfxes = fs + .readdirSync(cacheDir) + .filter( + (f) => f.startsWith("aspnetcore-localhost-") && f.endsWith(".pfx") + ); + if (pfxes.length === 0) { + throw new Error( + `aspnetcore disk cache dir ${cacheDir} contains no aspnetcore-localhost-*.pfx files.` + ); + } + cachePfxPath = path.join(cacheDir, pfxes[0]); + + observedPbeOid = await inspectFirstEncryptedDataOid(cachePfxPath); + + try { + const loaded = await loadPfx(cachePfxPath); + loadResult = { + kind: "ok", + thumbprint: loaded.thumbprint, + hasKey: loaded.key !== null, + }; + } catch (err) { + loadResult = { + kind: "err", + message: err instanceof Error ? err.message : String(err), + }; + } +}, 120_000); + +// No afterAll cleanup — we wrote to the runner's real `~/.aspnet/dev-certs/ +// https/` and login keychain. Both are owned by the ephemeral CI runner +// VM; trying to scrub them would mostly just hide our footprint from a +// follow-up investigation if the next run sees stale state. + +describe.skipIf(!ready)( + "aspnetcore macOS PFX format compatibility", + () => { + it("parsePfx loads aspnetcore's macOS disk-cache PFX", () => { + // Diagnostic line carries the resolved path AND the observed PBE + // OID so CI logs always show the discrepancy at-a-glance even + // when the assertion passes. Stderr (not console.log) keeps the + // codebase's no-console lint rule happy. + process.stderr.write( + `[macos-pfx] dotnet major: ${dotnetMajor}, cache: ${cachePfxPath}, ` + + `observedPbeOid: ${observedPbeOid ?? "(unknown)"}, ` + + `result: ${JSON.stringify(loadResult)}\n` + ); + expect(loadResult.kind).toBe("ok"); + }); + + it("recovers a 40-char SHA-1 thumbprint from the cache PFX", () => { + if (loadResult.kind !== "ok") return; + expect(loadResult.thumbprint).toMatch(/^[0-9A-F]{40}$/); + }); + + it("includes the private key (otherwise Kestrel couldn't serve TLS)", () => { + if (loadResult.kind !== "ok") return; + expect(loadResult.hasKey).toBe(true); + }); + + it("aspnetcore is still emitting the legacy 3DES PBE algorithm we expect", () => { + // This assertion exists to fire on a SUCCESS event from + // aspnetcore's perspective — when they swap the macOS writer + // over to `ExportPkcs12(PbeParameters(Aes256Cbc, ...))` and the + // disk cache starts coming out as PBES2. When that day arrives, + // this test goes red; the failure message is the next + // maintainer's signal to clean up the legacy code: + // + // - `src/shared/src/cert/pkcs12LegacyPbe.ts` can be deleted + // (follow its removal checklist). + // - The OID can return to `REJECTED_LEGACY_PBE_NAMES` in + // `pfx.ts`. + // - This very assertion gets flipped to expect PBES2 (or just + // deleted, depending on whether the codebase still needs + // to know). + if (observedPbeOid === OID_PBES2) { + throw new Error( + "aspnetcore's macOS PFX writer appears to have switched to PBES2 " + + "(OID 1.2.840.113549.1.5.13). The legacy 3DES handler in " + + "src/shared/src/cert/pkcs12LegacyPbe.ts is no longer needed and " + + "can be removed — see its docstring for the removal checklist." + ); + } + expect(observedPbeOid).toBe(OID_PBE_3DES_SHA1); + }); + } +); + +/** + * Pull the encryption algorithm OID off the first `EncryptedData` + * `ContentInfo` inside the PFX's `authenticatedSafe`. That's the + * algorithm protecting the cert bag, which is what `pkcs12LegacyPbe.ts` + * has to know how to decrypt. + * + * Walks pkijs's parsed structure directly rather than going through + * `parsePfx` so it works regardless of whether the legacy handler can + * decode the file — the OID is observable from the headers alone, no + * password needed. `parseInternalValues` populates `pfx.parsedValue`; + * `checkIntegrity: false` skips the HMAC step (which `parsePfx` itself + * skips for the same reason — pkijs's MAC verification disagrees with + * .NET's empty-password convention). + * + * Returns null if the structure doesn't contain an `EncryptedData` + * (`Data`-typed contents are unencrypted, no OID to report). + */ +async function inspectFirstEncryptedDataOid( + pfxPath: string +): Promise { + const bytes = fs.readFileSync(pfxPath); + const ab = new ArrayBuffer(bytes.byteLength); + new Uint8Array(ab).set(bytes); + const pfx = pkijs.PFX.fromBER(ab); + await pfx.parseInternalValues({ + password: new ArrayBuffer(0), + checkIntegrity: false, + }); + const authSafe = pfx.parsedValue?.authenticatedSafe; + if (!authSafe) return null; + for (const contentInfo of authSafe.safeContents) { + // 1.2.840.113549.1.7.6 = pkcs-7-encryptedData + if (contentInfo.contentType !== "1.2.840.113549.1.7.6") continue; + const encryptedData = new pkijs.EncryptedData({ + schema: contentInfo.content, + }); + return encryptedData.encryptedContentInfo.contentEncryptionAlgorithm + .algorithmId; + } + return null; +} diff --git a/src/vscode-ui-extension/tests/hostCertGenerator.test.ts b/src/vscode-ui-extension/tests/hostCertGenerator.test.ts new file mode 100644 index 0000000..adb1073 --- /dev/null +++ b/src/vscode-ui-extension/tests/hostCertGenerator.test.ts @@ -0,0 +1,306 @@ +import { + describe, + it, + expect, + beforeEach, + vi, + type Mock, +} from "vitest"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import type { Backend } from "@devcontainer-dev-certs/shared"; + +import type * as Shared from "@devcontainer-dev-certs/shared"; + +// Mock `selectBackend` so the test doesn't need a real `dotnet` install on +// PATH. We keep the rest of the shared module intact via `importActual` +// so the manager / cert primitives the SUT uses still work normally. +vi.mock("@devcontainer-dev-certs/shared", async () => { + const actual = await vi.importActual( + "@devcontainer-dev-certs/shared" + ); + return { + ...actual, + selectBackend: vi.fn(), + }; +}); + +import { selectBackend } from "@devcontainer-dev-certs/shared"; +import { CertProvider } from "../src/certProvider"; +import { generateCertificate } from "../src/cert/generator"; +import { VALIDITY_DAYS } from "../src/cert/properties"; +import type { CertManager } from "../src/cert/manager"; +import type { DevCert, DevKey } from "../src/cert/types"; +import { __resetConfig, __setConfig } from "./__mocks__/vscode"; + +const mockedSelectBackend = vi.mocked(selectBackend); + +async function makeValidCert(): ReturnType { + const now = new Date(); + const expiry = new Date(now.getTime() + VALIDITY_DAYS * 86400_000); + return generateCertificate(now, expiry); +} + +interface ManagerSpyHandles { + manager: CertManager; + // Concrete callable signatures so call sites (`trustSpy()`, `trustSpy.mock.calls`) + // narrow correctly; `ReturnType` widens to Mock which TS won't let you invoke without first telling it + // which side of the union you mean. + trustSpy: Mock<() => Promise>; + checkSpy: Mock<() => Promise>; +} + +/** + * Build a CertManager-shaped mock that initially reports the cert as + * absent / untrusted (so `ensureDotNetDevCert` is forced down the + * provisioning path), then flips to present + trusted on subsequent + * `check()` calls — simulating the side effect of a successful trust + * step. `exportCert` writes real cert files into the export dir so the + * subsequent base64-reading + cache-population step doesn't blow up. + */ +function buildManagerMock(cert: DevCert, key: DevKey, thumbprint: string): ManagerSpyHandles { + let provisioned = false; + const status = () => ({ + exists: provisioned, + isTrusted: provisioned, + thumbprint: provisioned ? thumbprint : null, + notBefore: null, + notAfter: null, + version: 1, + }); + const trustSpy = vi.fn(async () => { + provisioned = true; + }); + const checkSpy = vi.fn(async () => status()); + const manager = { + check: checkSpy, + trust: trustSpy, + exportCert: vi.fn( + async (format: "pfx" | "pem" | "root-pfx", outputDir: string) => { + fs.mkdirSync(outputDir, { recursive: true }); + if (format === "pem") { + fs.writeFileSync(path.join(outputDir, "aspnetcore-dev.pem"), cert.pem); + fs.writeFileSync(path.join(outputDir, "aspnetcore-dev.key"), key.pem); + } else if (format === "pfx") { + fs.writeFileSync( + path.join(outputDir, "aspnetcore-dev.pfx"), + Buffer.from("fake-pfx") + ); + } else { + fs.writeFileSync( + path.join(outputDir, "aspnetcore-dev-root.pfx"), + Buffer.from("fake-root") + ); + } + } + ), + trustExternalCertificate: vi.fn(async () => {}), + } as unknown as CertManager; + return { manager, trustSpy, checkSpy }; +} + +/** + * Construct a fake Backend that records every call to its `generate` and + * flips the supplied "is now trusted" state via the manager mock so the + * downstream export path sees a populated platform store. + */ +function fakeBackend( + kind: "native" | "dotnet", + onGenerate: () => void +): Backend { + return { + kind, + isAvailable: vi.fn(() => Promise.resolve(true)), + generate: vi.fn(async () => { + onGenerate(); + return { + pfxPath: "/dev/null/pfx", + pemPath: "/dev/null/pem", + pemKeyPath: null, + thumbprint: "FAKE", + trusted: true, + backendUsed: kind, + }; + }), + }; +} + +beforeEach(() => { + __resetConfig(); + mockedSelectBackend.mockReset(); +}); + +describe("CertProvider.provisionViaConfiguredBackend", () => { + it("uses the in-process CertManager.trust() when hostCertGenerator is 'native'", async () => { + const { cert, key, thumbprint } = await makeValidCert(); + const { manager, trustSpy } = buildManagerMock(cert, key, thumbprint); + + // `selectBackend("native")` returns a native backend; the provider + // sees `kind: "native"` and routes to the pre-configured + // CertManager rather than calling `backend.generate()`. + mockedSelectBackend.mockResolvedValue(fakeBackend("native", () => { + throw new Error("native backend.generate should not run; native path defers to certManager.trust()"); + })); + + __setConfig("devcontainerDevCerts", { + generateDotNetCert: true, + hostCertGenerator: "native", + }); + + const provider = new CertProvider(manager); + await provider.getCertMaterial(true); + + expect(mockedSelectBackend).toHaveBeenCalledWith("native"); + expect(trustSpy).toHaveBeenCalledTimes(1); + }); + + it("defers to selectBackend('auto') when hostCertGenerator is unset", async () => { + const { cert, key, thumbprint } = await makeValidCert(); + const { manager, trustSpy } = buildManagerMock(cert, key, thumbprint); + + // 'auto' resolved to native — selectBackend returned a native backend. + // CertProvider should use the configured manager.trust() rather than + // the bare CertManager inside the shared NativeBackend so the + // extension's l10n + Linux NSS reporter wiring is preserved. + mockedSelectBackend.mockResolvedValue(fakeBackend("native", () => { + throw new Error("native backend.generate should not run for 'auto' → native"); + })); + + __setConfig("devcontainerDevCerts", { + generateDotNetCert: true, + // hostCertGenerator intentionally absent — default is 'auto'. + }); + + const provider = new CertProvider(manager); + await provider.getCertMaterial(true); + + expect(mockedSelectBackend).toHaveBeenCalledWith("auto"); + expect(trustSpy).toHaveBeenCalledTimes(1); + }); + + it("dispatches to backend.generate() when selectBackend returns a dotnet backend", async () => { + const { cert, key, thumbprint } = await makeValidCert(); + const { manager, trustSpy } = buildManagerMock(cert, key, thumbprint); + + // Simulate dotnet's side effect: after `generate()` runs, the + // platform store has a trusted cert. We bridge to the same state + // flip the native trustSpy would have produced by calling trustSpy + // ourselves from within the fake generate. + let backendCalled = false; + mockedSelectBackend.mockResolvedValue( + fakeBackend("dotnet", () => { + backendCalled = true; + void trustSpy(); + }) + ); + + __setConfig("devcontainerDevCerts", { + generateDotNetCert: true, + hostCertGenerator: "dotnet", + }); + + const provider = new CertProvider(manager); + await provider.getCertMaterial(true); + + expect(mockedSelectBackend).toHaveBeenCalledWith("dotnet"); + expect(backendCalled).toBe(true); + // CertManager.trust() must NOT have been called directly by + // CertProvider when the dotnet backend runs — provisioning is the + // backend's job, not the manager's. + expect(trustSpy).toHaveBeenCalledTimes(1); + // (The one call we saw was from the fake generate itself, simulating + // the dotnet side effect.) + }); + + it("propagates selectBackend errors so the user sees the failure", async () => { + const { cert, key, thumbprint } = await makeValidCert(); + const { manager } = buildManagerMock(cert, key, thumbprint); + + mockedSelectBackend.mockRejectedValue( + new Error("Requested --backend dotnet but the `dotnet` CLI was not found on PATH.") + ); + + __setConfig("devcontainerDevCerts", { + generateDotNetCert: true, + hostCertGenerator: "dotnet", + }); + + const provider = new CertProvider(manager); + await expect(provider.getCertMaterial(true)).rejects.toThrow( + /dotnet.*not found on PATH/ + ); + }); + + it("forwards the linuxNssTrustReporter to the dotnet backend", async () => { + const { cert, key, thumbprint } = await makeValidCert(); + const { manager, trustSpy } = buildManagerMock(cert, key, thumbprint); + + let receivedReporter: unknown = "uncalled"; + mockedSelectBackend.mockResolvedValue({ + kind: "dotnet", + isAvailable: () => Promise.resolve(true), + generate: vi.fn(async (opts: Shared.GenerateOptions) => { + receivedReporter = opts.linuxNssTrustReporter; + await trustSpy(); + return { + pfxPath: "/dev/null/pfx", + pemPath: "/dev/null/pem", + pemKeyPath: null, + thumbprint: "FAKE", + trusted: true, + backendUsed: "dotnet" as const, + }; + }), + }); + + __setConfig("devcontainerDevCerts", { + generateDotNetCert: true, + hostCertGenerator: "dotnet", + }); + + const sentinelReporter: Shared.LinuxNssTrustReporter = () => { + /* test-supplied reporter, identity matters for the assertion */ + }; + const provider = new CertProvider(manager, sentinelReporter); + await provider.getCertMaterial(true); + + // Regression guard for the gap where the dotnet branch silently + // dropped the reporter on Linux. The exact identity must round-trip + // — substituting a different no-op reporter would silently break + // the host extension's NSS-failure toast. + expect(receivedReporter).toBe(sentinelReporter); + }); + + it("cleans up the per-provisioning tmp dir even when the backend throws", async () => { + const { cert, key, thumbprint } = await makeValidCert(); + const { manager } = buildManagerMock(cert, key, thumbprint); + + let createdDir: string | null = null; + mockedSelectBackend.mockResolvedValue({ + kind: "dotnet", + isAvailable: () => Promise.resolve(true), + generate: vi.fn(async (opts: Shared.GenerateOptions) => { + // The provisioning tmp dir exists at the point generate runs. + // We capture it so we can assert it gets cleaned up on the + // exception path below. + createdDir = opts.outDir; + expect(fs.existsSync(opts.outDir)).toBe(true); + throw new Error("simulated dotnet failure mid-generation"); + }), + }); + + __setConfig("devcontainerDevCerts", { + generateDotNetCert: true, + hostCertGenerator: "dotnet", + }); + + const provider = new CertProvider(manager); + await expect(provider.getCertMaterial(true)).rejects.toThrow(/simulated dotnet failure/); + expect(createdDir).not.toBeNull(); + expect(fs.existsSync(createdDir!)).toBe(false); + // Sanity: the tmp dir really did live under os.tmpdir(). + expect(createdDir!.startsWith(os.tmpdir())).toBe(true); + }); +}); diff --git a/src/vscode-ui-extension/tests/linuxStore.test.ts b/src/vscode-ui-extension/tests/linuxStore.test.ts index c3b554a..5ccdd76 100644 --- a/src/vscode-ui-extension/tests/linuxStore.test.ts +++ b/src/vscode-ui-extension/tests/linuxStore.test.ts @@ -2,8 +2,8 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import * as fs from "fs"; import * as path from "path"; import * as os from "os"; -import type * as Shared from "@devcontainer-dev-certs/shared"; -import { initLogger } from "@devcontainer-dev-certs/shared"; +import type * as SharedPaths from "@devcontainer-dev-certs/shared/src/paths"; +import { initLogger } from "@devcontainer-dev-certs/shared/src/loggerVscode"; import { generateCertificate } from "../src/cert/generator"; import { VALIDITY_DAYS } from "../src/cert/properties"; import { buildPfx, parsePfx } from "../src/cert/pfx"; @@ -11,8 +11,13 @@ import { logMessages } from "./__mocks__/vscode"; initLogger("test"); -// Mock runProcess so tests don't need an actual openssl binary. -vi.mock("../src/platform/processUtil", () => ({ +// Mock runProcess so tests don't need an actual openssl binary. After the +// platform-layer move into the shared package, the LinuxCertificateStore +// imports `runProcess` from the shared internal path (`./processUtil`), so +// the mock target has to be that same shared file — mocking the extension's +// thin re-export shim won't intercept the import the implementation actually +// uses. +vi.mock("@devcontainer-dev-certs/shared/src/platform/processUtil", () => ({ runProcess: vi.fn().mockResolvedValue({ exitCode: 0, stdout: "abcd1234\n", @@ -24,17 +29,19 @@ vi.mock("../src/platform/processUtil", () => ({ // Tests that exercise the NSS step explicitly construct a store with a // reporter; the default tests construct it without one, in which case the // step is skipped entirely and this mock is never invoked. -vi.mock("../src/platform/nssTrust", () => ({ +vi.mock("@devcontainer-dev-certs/shared/src/platform/nssTrust", () => ({ trustInNss: vi.fn(), })); -// Override the shared paths to point at temp directories. +// Override the shared paths to point at temp directories. The +// LinuxCertificateStore imports these from the shared internal `paths` +// module, so the mock has to target that same file. let testStoreDir: string; let testRootStoreDir: string; let testTrustDir: string; -vi.mock("@devcontainer-dev-certs/shared", async (importOriginal) => { - const original = await importOriginal(); +vi.mock("@devcontainer-dev-certs/shared/src/paths", async (importOriginal) => { + const original = await importOriginal(); return { ...original, getDotNetStorePath: () => testStoreDir, @@ -44,8 +51,8 @@ vi.mock("@devcontainer-dev-certs/shared", async (importOriginal) => { }); import { LinuxCertificateStore } from "../src/platform/linuxStore"; -import { runProcess } from "../src/platform/processUtil"; -import { trustInNss } from "../src/platform/nssTrust"; +import { runProcess } from "@devcontainer-dev-certs/shared/src/platform/processUtil"; +import { trustInNss } from "@devcontainer-dev-certs/shared/src/platform/nssTrust"; const mockedRunProcess = vi.mocked(runProcess); const mockedTrustInNss = vi.mocked(trustInNss); diff --git a/src/vscode-ui-extension/tests/macStore.test.ts b/src/vscode-ui-extension/tests/macStore.test.ts index cb6593c..9b948b7 100644 --- a/src/vscode-ui-extension/tests/macStore.test.ts +++ b/src/vscode-ui-extension/tests/macStore.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import * as fs from "fs"; import * as os from "os"; import * as path from "path"; -import { initLogger } from "@devcontainer-dev-certs/shared"; +import { initLogger } from "@devcontainer-dev-certs/shared/src/loggerVscode"; import { logMessages } from "./__mocks__/vscode"; import { generateCertificate } from "../src/cert/generator"; import { VALIDITY_DAYS } from "../src/cert/properties"; @@ -16,12 +16,14 @@ vi.mock("os", async (importOriginal) => { }); // Mock runProcess so tests don't shell out to the real `security` CLI. -vi.mock("../src/platform/processUtil", () => ({ +// Target the shared internal module since MacCertificateStore lives there +// and imports its runProcess via the local `./processUtil`. +vi.mock("@devcontainer-dev-certs/shared/src/platform/processUtil", () => ({ runProcess: vi.fn(), })); import { MacCertificateStore } from "../src/platform/macStore"; -import { runProcess } from "../src/platform/processUtil"; +import { runProcess } from "@devcontainer-dev-certs/shared/src/platform/processUtil"; const mockedRunProcess = vi.mocked(runProcess); @@ -229,3 +231,131 @@ describe("MacCertificateStore.findExistingDevCert", () => { expect(warn).toBeDefined(); }); }); + +describe("MacCertificateStore.removeCertificates", () => { + let store: MacCertificateStore; + + beforeEach(() => { + vi.clearAllMocks(); + logMessages.length = 0; + testHomeDir = fs.mkdtempSync(path.join(os.tmpdir(), "devcerts-mac-rm-")); + fs.mkdirSync(devCertsDir(), { recursive: true }); + store = new MacCertificateStore(); + }); + + afterEach(() => { + fs.rmSync(testHomeDir, { recursive: true, force: true }); + }); + + it("calls `security remove-trusted-cert ` for each dev cert (no `-d`, no keychain positional)", async () => { + const { cert, key, thumbprint } = await makeTestCert(); + const pfxBytes = await buildPfx({ cert, key }); + fs.writeFileSync( + path.join(devCertsDir(), `aspnetcore-localhost-${thumbprint}.pfx`), + pfxBytes + ); + + const sec = setupSecurityMock(); + await store.removeCertificates(); + + const untrust = sec.calls.filter( + (c) => c.cmd === "security" && c.args[0] === "remove-trusted-cert" + ); + expect(untrust).toHaveLength(1); + // No `-d` (we trusted to user domain, not admin) — using -d here + // would look in the wrong trust-settings file and silently miss + // our entry. + expect(untrust[0].args).not.toContain("-d"); + // The positional must be a cert file path under os.tmpdir() — NOT + // the keychain path. Past bug: we were passing the keychain path + // here, which made the command a no-op (or worse, errored). + const tmpDir = os.tmpdir(); + const positional = untrust[0].args[untrust[0].args.length - 1]; + expect(positional.startsWith(tmpDir)).toBe(true); + expect(positional).toMatch(/devcert-untrust-.*\.cer$/); + }); + + it("calls untrust BEFORE delete-certificate (so trust-settings entries aren't orphaned)", async () => { + const { cert, key, thumbprint } = await makeTestCert(); + const pfxBytes = await buildPfx({ cert, key }); + fs.writeFileSync( + path.join(devCertsDir(), `aspnetcore-localhost-${thumbprint}.pfx`), + pfxBytes + ); + + const sec = setupSecurityMock(); + await store.removeCertificates(); + + const untrustIdx = sec.calls.findIndex( + (c) => c.cmd === "security" && c.args[0] === "remove-trusted-cert" + ); + const deleteIdx = sec.calls.findIndex( + (c) => c.cmd === "security" && c.args[0] === "delete-certificate" + ); + expect(untrustIdx).toBeGreaterThanOrEqual(0); + expect(deleteIdx).toBeGreaterThan(untrustIdx); + }); + + it("unlinks the PFX from disk after the keychain teardown", async () => { + const { cert, key, thumbprint } = await makeTestCert(); + const pfxBytes = await buildPfx({ cert, key }); + const pfxPath = path.join( + devCertsDir(), + `aspnetcore-localhost-${thumbprint}.pfx` + ); + fs.writeFileSync(pfxPath, pfxBytes); + + setupSecurityMock(); + await store.removeCertificates(); + + expect(fs.existsSync(pfxPath)).toBe(false); + }); + + it("regression: never calls `security remove-trusted-cert -d `", async () => { + const { cert, key, thumbprint } = await makeTestCert(); + const pfxBytes = await buildPfx({ cert, key }); + fs.writeFileSync( + path.join(devCertsDir(), `aspnetcore-localhost-${thumbprint}.pfx`), + pfxBytes + ); + + const sec = setupSecurityMock(); + await store.removeCertificates(); + + const bad = sec.calls.find( + (c) => + c.cmd === "security" && + c.args[0] === "remove-trusted-cert" && + c.args.includes("-d") + ); + expect(bad).toBeUndefined(); + }); + + it("processes multiple dev cert PFXes independently", async () => { + const a = await makeTestCert(); + const b = await makeTestCert(); + fs.writeFileSync( + path.join(devCertsDir(), `aspnetcore-localhost-${a.thumbprint}.pfx`), + await buildPfx({ cert: a.cert, key: a.key }) + ); + fs.writeFileSync( + path.join(devCertsDir(), `aspnetcore-localhost-${b.thumbprint}.pfx`), + await buildPfx({ cert: b.cert, key: b.key }) + ); + + const sec = setupSecurityMock(); + await store.removeCertificates(); + + const untrustCount = sec.calls.filter( + (c) => c.cmd === "security" && c.args[0] === "remove-trusted-cert" + ).length; + expect(untrustCount).toBe(2); + }); + + it("no-ops cleanly when the devCertsDir doesn't exist", async () => { + fs.rmSync(devCertsDir(), { recursive: true, force: true }); + const sec = setupSecurityMock(); + await store.removeCertificates(); + expect(sec.calls).toHaveLength(0); + }); +}); diff --git a/src/vscode-ui-extension/tests/manager.test.ts b/src/vscode-ui-extension/tests/manager.test.ts index c0c52a4..d8a28f7 100644 --- a/src/vscode-ui-extension/tests/manager.test.ts +++ b/src/vscode-ui-extension/tests/manager.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import type * as PlatformTypes from "../src/platform/types"; +import type * as PlatformTypes from "@devcontainer-dev-certs/shared/src/platform/types"; import { type PlatformCertificateStore, type CertificateStatus, @@ -7,8 +7,11 @@ import { import { generateCertificate } from "../src/cert/generator"; import { VALIDITY_DAYS } from "../src/cert/properties"; -// Mock createPlatformStore so the CertManager uses our fake store. -vi.mock("../src/platform/types", async (importOriginal) => { +// Mock createPlatformStore so the CertManager uses our fake store. The +// CertManager now lives in `@devcontainer-dev-certs/shared` and imports +// `createPlatformStore` from the sibling `../platform/types`; the mock has +// to target that exact module path so the shared CertManager sees it too. +vi.mock("@devcontainer-dev-certs/shared/src/platform/types", async (importOriginal) => { const original = await importOriginal(); return { ...original, @@ -17,7 +20,7 @@ vi.mock("../src/platform/types", async (importOriginal) => { }); import { CertManager } from "../src/cert/manager"; -import { createPlatformStore } from "../src/platform/types"; +import { createPlatformStore } from "@devcontainer-dev-certs/shared/src/platform/types"; const mockedCreateStore = vi.mocked(createPlatformStore); diff --git a/src/vscode-ui-extension/tests/nativeBackend.test.ts b/src/vscode-ui-extension/tests/nativeBackend.test.ts new file mode 100644 index 0000000..b041c87 --- /dev/null +++ b/src/vscode-ui-extension/tests/nativeBackend.test.ts @@ -0,0 +1,110 @@ +import { + describe, + it, + expect, + beforeEach, + afterEach, +} from "vitest"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { NativeBackend, loadPfx } from "@devcontainer-dev-certs/shared"; + +/** + * NativeBackend has two distinct code paths picked by `noTrust`. + * + * - `noTrust: true`: generate purely in memory, write only to `outDir`. + * The platform store (`~/.dotnet/corefx/cryptography/x509stores/my/` + * on Linux/macOS) must NOT be touched — the caller has explicitly + * opted out of host-side cert installation. + * - `noTrust: false`: drive `CertManager` end-to-end, including writing + * to the platform store. That's the host-trust contract; we don't + * test that path here because it requires interactive trust prompts + * on macOS/Windows and root NSS hooks on Linux. + * + * Both paths are exercised on a redirected `$HOME` so any accidental + * store write would land in our tmpdir where we can audit it. + */ +describe("NativeBackend.generate with --no-trust", () => { + let originalHome: string | undefined; + let fakeHome: string; + let outDir: string; + let backend: NativeBackend; + + beforeEach(() => { + originalHome = process.env.HOME; + fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), "devcerts-nativebackend-home-")); + process.env.HOME = fakeHome; + outDir = fs.mkdtempSync(path.join(os.tmpdir(), "devcerts-nativebackend-out-")); + backend = new NativeBackend(); + }); + + afterEach(() => { + fs.rmSync(fakeHome, { recursive: true, force: true }); + fs.rmSync(outDir, { recursive: true, force: true }); + if (originalHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = originalHome; + } + }); + + it("writes pfx + pem + key into outDir and reports trusted=false", async () => { + const result = await backend.generate({ outDir, noTrust: true }); + + expect(result.trusted).toBe(false); + expect(result.backendUsed).toBe("native"); + expect(fs.existsSync(result.pfxPath)).toBe(true); + expect(fs.existsSync(result.pemPath)).toBe(true); + expect(result.pemKeyPath).not.toBeNull(); + expect(fs.existsSync(result.pemKeyPath!)).toBe(true); + expect(result.pfxPath).toBe(path.join(outDir, "aspnetcore-dev.pfx")); + expect(result.pemPath).toBe(path.join(outDir, "aspnetcore-dev.pem")); + expect(result.pemKeyPath).toBe(path.join(outDir, "aspnetcore-dev.key")); + }); + + it("reports a SHA-1 thumbprint that matches the reparsed PFX", async () => { + const result = await backend.generate({ outDir, noTrust: true }); + + const loaded = await loadPfx(result.pfxPath); + expect(loaded.cert.thumbprintSha1).toBe(result.thumbprint); + // SHA-1 is 40 hex chars; uppercase per shared cert primitives. + expect(result.thumbprint).toMatch(/^[0-9A-F]{40}$/); + }); + + it("does NOT write into the platform store directory under HOME", async () => { + await backend.generate({ outDir, noTrust: true }); + + // The .NET X509Store on Linux/macOS lives under + // ~/.dotnet/corefx/cryptography/x509stores/my/. With our redirected + // $HOME, that translates to fakeHome/.dotnet/... — and since the + // noTrust path bypasses CertManager entirely, nothing under fakeHome + // should have been created. + const storeDir = path.join( + fakeHome, + ".dotnet", + "corefx", + "cryptography", + "x509stores", + "my" + ); + expect(fs.existsSync(storeDir)).toBe(false); + + // Belt-and-suspenders: nothing at all under fakeHome. + const homeEntries = fs.readdirSync(fakeHome); + expect(homeEntries).toEqual([]); + }); + + it("produces a fresh cert on every invocation (no store-based reuse)", async () => { + const first = await backend.generate({ outDir, noTrust: true }); + fs.rmSync(outDir, { recursive: true }); + fs.mkdirSync(outDir); + const second = await backend.generate({ outDir, noTrust: true }); + + // Two separate generations → two distinct serial numbers / thumbprints. + // The noTrust path is intentionally not idempotent: with no store + // residency, there's no "existing cert" for the backend to find and + // reuse. + expect(second.thumbprint).not.toBe(first.thumbprint); + }); +}); diff --git a/src/vscode-ui-extension/tests/nssTrust.test.ts b/src/vscode-ui-extension/tests/nssTrust.test.ts index 88c8d65..fe1b55c 100644 --- a/src/vscode-ui-extension/tests/nssTrust.test.ts +++ b/src/vscode-ui-extension/tests/nssTrust.test.ts @@ -3,8 +3,11 @@ import * as fs from "fs"; import * as path from "path"; import * as os from "os"; -// Mock runProcess to simulate certutil and which commands -vi.mock("../src/platform/processUtil", () => ({ +// Mock runProcess to simulate certutil and which commands. Target the +// shared internal module that `trustInNss` actually imports from — mocking +// the extension's re-export shim would leave the implementation calling +// the real runProcess. +vi.mock("@devcontainer-dev-certs/shared/src/platform/processUtil", () => ({ runProcess: vi.fn(), })); @@ -19,7 +22,7 @@ vi.mock("os", async (importOriginal) => { }); import { trustInNss } from "../src/platform/nssTrust"; -import { runProcess } from "../src/platform/processUtil"; +import { runProcess } from "@devcontainer-dev-certs/shared/src/platform/processUtil"; const mockedRunProcess = vi.mocked(runProcess); diff --git a/src/vscode-ui-extension/tests/pkcs12LegacyPbe.test.ts b/src/vscode-ui-extension/tests/pkcs12LegacyPbe.test.ts new file mode 100644 index 0000000..73574c8 --- /dev/null +++ b/src/vscode-ui-extension/tests/pkcs12LegacyPbe.test.ts @@ -0,0 +1,140 @@ +import { describe, it, expect } from "vitest"; +import * as fs from "fs"; +import * as path from "path"; +import { loadPfx } from "@devcontainer-dev-certs/shared"; +import { + SUPPORTED_LEGACY_PBE_OID, + decryptLegacyPbe, + isSupportedLegacyPbe, + pkcs12Kdf, +} from "@devcontainer-dev-certs/shared/src/cert/pkcs12LegacyPbe"; + +/** + * Lifecycle: delete this file when the parent module is removed. See + * `src/shared/src/cert/pkcs12LegacyPbe.ts` for the removal checklist. + */ + +// Pre-generated 3DES-encrypted PKCS#12 fixture. Built via: +// openssl req -newkey rsa:2048 -nodes -keyout key -x509 \ +// -days 30 -subj /CN=localhost -out crt +// openssl pkcs12 -legacy -export -inkey key -in crt -passout pass: \ +// -keypbe PBE-SHA1-3DES -certpbe PBE-SHA1-3DES -macalg sha1 \ +// -iter 2000 -out test/fixtures/pkcs12-legacy-3des.pfx +// +// `openssl pkcs12 -info` reports the same algorithm aspnetcore's +// MacOSCertificateManager.SaveCertificateCore writes (`pbeWithSHA1And3- +// KeyTripleDES-CBC`), so loading this fixture exercises the same path +// the macOS dev cert disk cache flows through. +const FIXTURE = path.resolve( + __dirname, + "../../../test/fixtures/pkcs12-legacy-3des.pfx" +); + +describe("pkcs12LegacyPbe", () => { + it("SUPPORTED_LEGACY_PBE_OID names exactly the 3DES algorithm dotnet emits", () => { + expect(SUPPORTED_LEGACY_PBE_OID).toBe("1.2.840.113549.1.12.1.3"); + }); + + it("isSupportedLegacyPbe accepts the 3DES OID and rejects the others in the legacy family", () => { + expect(isSupportedLegacyPbe("1.2.840.113549.1.12.1.3")).toBe(true); + // Rest of the legacy PBE family stays rejected at the parsePfx layer. + expect(isSupportedLegacyPbe("1.2.840.113549.1.12.1.1")).toBe(false); + expect(isSupportedLegacyPbe("1.2.840.113549.1.12.1.2")).toBe(false); + expect(isSupportedLegacyPbe("1.2.840.113549.1.12.1.4")).toBe(false); + expect(isSupportedLegacyPbe("1.2.840.113549.1.12.1.5")).toBe(false); + expect(isSupportedLegacyPbe("1.2.840.113549.1.12.1.6")).toBe(false); + // PBES2 is not handled here — it goes through pkijs. + expect(isSupportedLegacyPbe("1.2.840.113549.1.5.13")).toBe(false); + }); + + it("decryptLegacyPbe refuses OIDs it doesn't support (defensive)", () => { + expect(() => + decryptLegacyPbe( + "1.2.840.113549.1.12.1.1", + { salt: Buffer.alloc(8), iterations: 1 }, + Buffer.alloc(0), + "" + ) + ).toThrow(/unsupported OID/); + }); +}); + +describe("pkcs12Kdf — empty-password convention", () => { + // For empty password, the bytes fed into I are the UTF-16BE null + // terminator: two zero bytes. Two distinct diversifiers + same + // salt/iterations must produce distinct outputs (sanity: the + // diversifier is what makes the key and IV derivations different). + it("derives distinct key and IV from the same salt with empty password", () => { + const salt = Buffer.from([1, 2, 3, 4, 5, 6, 7, 8]); + const key = pkcs12Kdf("", salt, 2000, 1, 24); + const iv = pkcs12Kdf("", salt, 2000, 2, 8); + expect(key.length).toBe(24); + expect(iv.length).toBe(8); + // 64 bits of overlap shouldn't be zero — vanishingly unlikely + // unless the diversifier byte was ignored. + expect(key.slice(0, 8).equals(iv)).toBe(false); + }); + + it("produces deterministic output for the same inputs", () => { + const salt = Buffer.from([1, 2, 3, 4, 5, 6, 7, 8]); + const a = pkcs12Kdf("", salt, 2000, 1, 24); + const b = pkcs12Kdf("", salt, 2000, 1, 24); + expect(a.equals(b)).toBe(true); + }); + + it("changes output when the iteration count changes", () => { + const salt = Buffer.from([1, 2, 3, 4, 5, 6, 7, 8]); + const a = pkcs12Kdf("", salt, 2000, 1, 24); + const b = pkcs12Kdf("", salt, 1, 1, 24); + expect(a.equals(b)).toBe(false); + }); + + it("handles non-empty passwords (UTF-16BE encoding) without throwing", () => { + // We don't have a published RFC 7292 test vector with the + // always-null-terminator convention to assert exact bytes, but + // the function must accept a non-empty input without error and + // produce a different output than the empty-password case. + const salt = Buffer.from([0xaa, 0xbb, 0xcc, 0xdd]); + const emptyPw = pkcs12Kdf("", salt, 100, 1, 24); + const realPw = pkcs12Kdf("password", salt, 100, 1, 24); + expect(emptyPw.equals(realPw)).toBe(false); + }); + + it("produces different output for empty password under the two terminator conventions", () => { + // The whole point of the includeNullTerminator flag: when password + // is empty, the two conventions feed different bytes (\x00\x00 vs + // nothing) into the KDF. Output bytes MUST differ — otherwise the + // .NET-vs-OpenSSL fallback in decryptLegacyPbe is a no-op. + const salt = Buffer.from([0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88]); + const withNul = pkcs12Kdf("", salt, 2000, 1, 24, true); + const noNul = pkcs12Kdf("", salt, 2000, 1, 24, false); + expect(withNul.equals(noNul)).toBe(false); + }); + + it("ignores includeNullTerminator for non-empty passwords (both conventions agree)", () => { + const salt = Buffer.from([0xab, 0xcd, 0xef, 0x01]); + const withNul = pkcs12Kdf("password", salt, 100, 1, 24, true); + const noNul = pkcs12Kdf("password", salt, 100, 1, 24, false); + // Non-empty passwords always include the terminator under every + // real-world PKCS#12 implementation; the flag is honored only for + // empty passwords. If a future refactor accidentally threads the + // flag through the non-empty branch, this test catches it. + expect(withNul.equals(noNul)).toBe(true); + }); +}); + +describe("parsePfx — legacy 3DES PFX round-trip", () => { + it("loads the empty-password 3DES fixture and recovers cert + key", async () => { + expect(fs.existsSync(FIXTURE)).toBe(true); + const loaded = await loadPfx(FIXTURE); + expect(loaded.thumbprint).toMatch(/^[0-9A-F]{40}$/); + expect(loaded.key).not.toBeNull(); + // Cert subject CN was set to localhost when the fixture was built. + expect(loaded.cert.subjectCN).toBe("localhost"); + }); + + it("reports `hasKey: true` so consumers don't treat it as a CA-only PFX", async () => { + const loaded = await loadPfx(FIXTURE); + expect(loaded.key).not.toBeNull(); + }); +}); diff --git a/src/vscode-ui-extension/tests/resolveSafeExecPath.test.ts b/src/vscode-ui-extension/tests/resolveSafeExecPath.test.ts new file mode 100644 index 0000000..23edd07 --- /dev/null +++ b/src/vscode-ui-extension/tests/resolveSafeExecPath.test.ts @@ -0,0 +1,151 @@ +import { describe, it, expect } from "vitest"; +import { resolveSafeExecPath } from "@devcontainer-dev-certs/shared"; + +/** + * The resolver is Windows-specific defense against `CreateProcess`'s + * cwd-first executable lookup. These tests drive the Windows branch + * from any host by passing `platform: "win32"` plus an injected + * `fileExists` probe and synthetic PATH / PATHEXT — no fixture files + * on disk, no real environment dependencies. + */ +describe("resolveSafeExecPath", () => { + it("returns the command unchanged on Linux", () => { + const result = resolveSafeExecPath("dotnet", { + platform: "linux", + searchPath: "/usr/bin", + fileExists: () => false, + }); + expect(result).toBe("dotnet"); + }); + + it("returns the command unchanged on macOS", () => { + const result = resolveSafeExecPath("dotnet", { + platform: "darwin", + searchPath: "/usr/local/bin", + fileExists: () => false, + }); + expect(result).toBe("dotnet"); + }); + + it("passes through absolute Windows paths verbatim", () => { + const explicit = "C:\\Program Files\\dotnet\\dotnet.exe"; + const result = resolveSafeExecPath(explicit, { + platform: "win32", + searchPath: "C:\\Windows\\System32", + fileExists: () => true, + }); + expect(result).toBe(explicit); + }); + + it("passes through Windows paths with a separator verbatim", () => { + const result = resolveSafeExecPath("bin\\dotnet.exe", { + platform: "win32", + searchPath: "C:\\Windows\\System32", + fileExists: () => true, + }); + expect(result).toBe("bin\\dotnet.exe"); + }); + + it("scans PATH and appends PATHEXT entries for bare names on Windows", () => { + const fileExists = (candidate: string): boolean => + candidate === "C:\\Program Files\\dotnet\\dotnet.exe"; + + const result = resolveSafeExecPath("dotnet", { + platform: "win32", + searchPath: [ + "C:\\Windows\\System32", + "C:\\Program Files\\dotnet", + ].join(";"), + pathExt: ".COM;.EXE;.BAT;.CMD", + fileExists, + }); + + expect(result).toBe("C:\\Program Files\\dotnet\\dotnet.exe"); + }); + + it("returns null when the command isn't found anywhere on Windows PATH", () => { + const result = resolveSafeExecPath("nonexistent-tool", { + platform: "win32", + searchPath: "C:\\Windows\\System32;C:\\Program Files\\dotnet", + pathExt: ".EXE;.CMD", + fileExists: () => false, + }); + expect(result).toBeNull(); + }); + + it("does not append an extension to a command that already has one", () => { + const seen: string[] = []; + const fileExists = (candidate: string): boolean => { + seen.push(candidate); + return candidate === "C:\\Windows\\System32\\certutil.exe"; + }; + + const result = resolveSafeExecPath("certutil.exe", { + platform: "win32", + searchPath: "C:\\Windows\\System32", + pathExt: ".EXE;.CMD;.BAT", + fileExists, + }); + + expect(result).toBe("C:\\Windows\\System32\\certutil.exe"); + // None of the probed candidates should be `certutil.exe.cmd`, + // `certutil.exe.bat`, etc. — the extension matcher must short-circuit. + expect(seen.every((p) => !/\.exe\.[a-z]+$/i.test(p))).toBe(true); + }); + + it("skips relative PATH entries to defeat cwd-equivalent hijacks", () => { + const fileExists = (candidate: string): boolean => { + // Anything probed from a relative entry would be 'bad-dir\dotnet.exe'. + // Our resolver should never even check it. + if (candidate.startsWith("bad-dir")) { + throw new Error( + `Resolver probed a relative PATH entry: ${candidate} — that's exactly the hijack vector we're defending against.` + ); + } + return candidate === "C:\\Program Files\\dotnet\\dotnet.exe"; + }; + + const result = resolveSafeExecPath("dotnet", { + platform: "win32", + searchPath: ["bad-dir", ".", "C:\\Program Files\\dotnet"].join(";"), + pathExt: ".EXE", + fileExists, + }); + + expect(result).toBe("C:\\Program Files\\dotnet\\dotnet.exe"); + }); + + it("treats PATHEXT case-insensitively (handles upper/mixed case extensions)", () => { + // Real-world Windows defaults to upper-case `.COM;.EXE;.BAT;.CMD`, but the + // matcher must work whether the user has rewritten it or not — and whether + // the command was passed with a `.EXE` or `.exe` suffix. The filesystem + // itself is case-insensitive on Windows, mirrored here in the probe. + const fileExists = (candidate: string): boolean => + candidate.toLowerCase() === "c:\\windows\\system32\\certutil.exe"; + + const result = resolveSafeExecPath("certutil.EXE", { + platform: "win32", + searchPath: "C:\\Windows\\System32", + pathExt: ".COM;.EXE;.BAT;.CMD", + fileExists, + }); + + expect(result).toBe("C:\\Windows\\System32\\certutil.EXE"); + }); + + it("returns the first match wins (PATH order preserved)", () => { + // Two candidate matches; the earlier PATH entry must win. + const fileExists = (candidate: string): boolean => + candidate === "C:\\first\\dotnet.exe" || + candidate === "C:\\second\\dotnet.exe"; + + const result = resolveSafeExecPath("dotnet", { + platform: "win32", + searchPath: ["C:\\first", "C:\\second"].join(";"), + pathExt: ".EXE", + fileExists, + }); + + expect(result).toBe("C:\\first\\dotnet.exe"); + }); +}); diff --git a/src/vscode-ui-extension/tests/selectBestDevCert.test.ts b/src/vscode-ui-extension/tests/selectBestDevCert.test.ts index 21d9c97..5b75abf 100644 --- a/src/vscode-ui-extension/tests/selectBestDevCert.test.ts +++ b/src/vscode-ui-extension/tests/selectBestDevCert.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach } from "vitest"; -import { initLogger } from "@devcontainer-dev-certs/shared"; +import { initLogger } from "@devcontainer-dev-certs/shared/src/loggerVscode"; import { logMessages } from "./__mocks__/vscode"; import { selectBestDevCert, type UsableDevCert } from "../src/platform/baseStore"; import { generateCertificate } from "../src/cert/generator"; diff --git a/src/vscode-ui-extension/tests/windowsStore.test.ts b/src/vscode-ui-extension/tests/windowsStore.test.ts index 2188592..ad41aff 100644 --- a/src/vscode-ui-extension/tests/windowsStore.test.ts +++ b/src/vscode-ui-extension/tests/windowsStore.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import * as fs from "fs"; import * as os from "os"; import * as path from "path"; -import { initLogger } from "@devcontainer-dev-certs/shared"; +import { initLogger } from "@devcontainer-dev-certs/shared/src/loggerVscode"; import { logMessages } from "./__mocks__/vscode"; import { generateCertificate } from "../src/cert/generator"; import { VALIDITY_DAYS } from "../src/cert/properties"; @@ -10,7 +10,11 @@ import { buildPfx } from "../src/cert/pfx"; import { type DevCert, type DevKey } from "../src/cert/types"; -vi.mock("../src/platform/processUtil", () => ({ +// Mock runProcess at the shared internal path — WindowsCertificateStore +// lives in `@devcontainer-dev-certs/shared/src/platform/windowsStore` after +// the platform-layer move and imports `runProcess` from the sibling +// `./processUtil`, so the mock must target that file. +vi.mock("@devcontainer-dev-certs/shared/src/platform/processUtil", () => ({ runProcess: vi.fn(), })); @@ -19,7 +23,7 @@ import { type PsCandidate, type PsSkipped, } from "../src/platform/windowsStore"; -import { runProcess } from "../src/platform/processUtil"; +import { runProcess } from "@devcontainer-dev-certs/shared/src/platform/processUtil"; const mockedRunProcess = vi.mocked(runProcess); diff --git a/src/vscode-workspace-extension/package.json b/src/vscode-workspace-extension/package.json index 5c81333..cd3e1e8 100644 --- a/src/vscode-workspace-extension/package.json +++ b/src/vscode-workspace-extension/package.json @@ -3,7 +3,7 @@ "displayName": "Dev Container Dev Certificates (Remote)", "description": "Receives and installs ASP.NET and Aspire compatible HTTPS development certificates inside Dev Containers and remote environments.", "icon": "images/dn_workspace_extension_icon_256.png", - "version": "1.3.2-pre", + "version": "1.4.0-pre.1", "publisher": "dnegstad", "license": "MIT", "repository": { diff --git a/src/vscode-workspace-extension/src/extension.ts b/src/vscode-workspace-extension/src/extension.ts index e1980eb..e0d7108 100644 --- a/src/vscode-workspace-extension/src/extension.ts +++ b/src/vscode-workspace-extension/src/extension.ts @@ -31,7 +31,8 @@ import { } from "./defaultKestrelDebugProvider"; import { parseExtraCertDestinations } from "./util/destinations"; import { upmapV1ToV3, upmapV2ToV3 } from "./util/upmap"; -import { initLogger, log, revealLogger } from "@devcontainer-dev-certs/shared"; +import { log, revealLogger } from "@devcontainer-dev-certs/shared"; +import { initLogger } from "@devcontainer-dev-certs/shared/src/loggerVscode"; import type { CertBundle, CertBundleV3, diff --git a/src/vscode-workspace-extension/tests/containerCertPush.test.ts b/src/vscode-workspace-extension/tests/containerCertPush.test.ts index 6fd3b08..8525aa0 100644 --- a/src/vscode-workspace-extension/tests/containerCertPush.test.ts +++ b/src/vscode-workspace-extension/tests/containerCertPush.test.ts @@ -19,10 +19,10 @@ import { ASPNET_HTTPS_OID, CURRENT_CERTIFICATE_VERSION, buildPfx, - initLogger, SAN_DNS_NAMES, SAN_IP_ADDRESSES, } from "@devcontainer-dev-certs/shared"; +import { initLogger } from "@devcontainer-dev-certs/shared/src/loggerVscode"; import * as vscode from "vscode"; import { findBestContainerDevCert, diff --git a/test/fixtures/pkcs12-legacy-3des.pfx b/test/fixtures/pkcs12-legacy-3des.pfx new file mode 100644 index 0000000..86518c8 Binary files /dev/null and b/test/fixtures/pkcs12-legacy-3des.pfx differ