Skip to content

Latest commit

 

History

History
148 lines (88 loc) · 41.7 KB

File metadata and controls

148 lines (88 loc) · 41.7 KB

Agent Instructions

This is a monorepo containing three components that together provide automatic HTTPS certificate management for .NET workloads in VS Code devcontainers and remote environments.

Architecture

The system uses the VS Code companion extension pattern: two extensions communicate via cross-host executeCommand() routing.

  • UI extension (src/vscode-ui-extension/) — extensionKind: ["ui"], runs on the host machine. Uses Node's built-in crypto plus @peculiar/x509 (X.509) and pkijs (PKCS#12) for certificate generation, loading, and PFX I/O — supporting RSA, ECDSA, and Ed25519 keys for user-managed certs. Trust store management is handled by platform-specific mechanisms. Registers four cross-host commands:

    • devcontainer-dev-certs.getAllCertMaterialV3({ includeDotNetDev, includeUserCerts }) — current multi-cert pull entry point with password-preserving pfxBase64 and per-cert installToDotNetStore flag.
    • devcontainer-dev-certs.getAllCertMaterial({ includeDotNetDev, includeUserCerts }) — v2 multi-cert pull entry point. Kept for workspace extensions pinned to the V2 wire contract.
    • devcontainer-dev-certs.getCertMaterial(autoProvision) — legacy single-cert pull entry point. Returns null when the host has disabled dotnet cert generation.
    • devcontainer-dev-certs.acceptContainerDevCert({ thumbprint, pemCertBase64 }) — reverse-sync push entry point (issue #63). Takes a public-cert-only PEM pushed from a Dev Container that opted into syncContainerCert, independently re-validates (isValidDevCert + validateLeafTrustShape + validateLocalSans), prompts for one-time consent (containerCertProvisionConsented global state — distinct from the host-generation consent because the user is approving trust of a cert that came from a container they may or may not control), and only trusts the cert in the host's OS trust surfaces (Root store / OpenSSL trust dir / NSS / keychain trust). Does NOT save to CurrentUser/My, the keychain identity slot, or the .NET my/ dir; the host doesn't need the private key (Kestrel runs in the container with its own copy). Gated on the SAME host settings as the generation flow: devcontainerDevCerts.generateDotNetCert and devcontainer-dev-certs.autoProvision. SAN-local restriction has an opt-out via devcontainerDevCerts.allowNonLocalContainerCertSans. Idempotent on repeat pushes, but by an explicit check rather than a platform guarantee: CertManager.trustExternalCertificate calls store.isCertTrusted(cert) and returns before invoking trustCertificate. Do not remove that short-circuit on the assumption that re-trusting is free — it is not on macOS, where security add-trusted-cert re-touches the trust-settings record and can re-prompt for the keychain password.

    Platform trust for the auto-generated cert is handled via PowerShell (Windows), the security CLI (macOS), and file-based stores with OpenSSL rehash (Linux). User-managed certs are never added to the host OS trust store. Container-pushed dev certs (when both opt-ins are on) ARE added to the host OS trust store via the same platform path as the auto-generated cert.

  • Workspace extension (src/vscode-workspace-extension/) — extensionKind: ["workspace"], runs in the remote (container/SSH/WSL). Calls getAllCertMaterialV3 first, falling back to getAllCertMaterial then getCertMaterial if the UI extension is older. Parses DEVCONTAINER_DEV_CERTS_EXTRA_DESTINATIONS into an ExtraDestination[] via src/util/destinations.ts, then for each cert in the bundle:

    1. Installs it to the canonical .NET + OpenSSL locations (installDotNetDevCert / installUserCert in certInstaller.ts).
    2. Writes each cert to every configured extra destination via writeExtraDestination.
    3. Runs a single rehash per directory destination after all writes.

    Additionally, when DEVCONTAINER_DEV_CERTS_SYNC_FROM_CONTAINER=true (set by the syncContainerCert feature option), the workspace extension runs pushContainerCertToHost() before the standard pull. That scans ~/.dotnet/corefx/cryptography/x509stores/my/*.pfx using the same shared classifyCandidate + selectBestDevCert rules the host uses on its own platform stores, pre-validates the winning candidate, and pushes it to the host via acceptContainerDevCert. The standard pull still runs afterwards (V3 pulls naturally return whatever cert the host now has trusted, so the container side ends up with the same cert it pushed).

  • Shared package (src/shared/) — TypeScript-only, no vscode dependency. Houses the cert primitives both extensions need: cert/types.ts (DevCert/DevKey wrappers around @peculiar/x509), cert/properties.ts (OIDs, version constants, default SANs), cert/pfx.ts (PKCS#12 build/parse), cert/loader.ts (PFX + PEM file loading), cert/validation.ts (isValidDevCert, getCertificateVersion, validateLocalSans), cert/classify.ts (classifyCandidate, selectBestDevCert, extractThumbprintHintFromFilename), and cert/rehash.ts (pure-TypeScript c_rehash, used by both the host's Linux store and the workspace installer). It also owns the platform trust-store layer (platform/*) and the generator backends (backends/*). Both extensions import it directly — there are no per-extension re-export shims; if you move something into shared, update the call sites rather than leaving a forwarding module behind. The classifier is side-effect-free — callers in vscode contexts opt into a localized log line by passing onSkipped / onMultipleCandidates callbacks. Both extensions independently localize via their own vscode.l10n.t bundles.

  • Devcontainer feature (src/devcontainer-feature/) — install.sh writes feature options and SSL_CERT_DIR into three sinks, because no single one reaches every kind of shell: (1) /etc/profile.d/devcontainer-dev-certs.sh (sourced by login shells; reached by VS Code's userEnvProbe, integrated terminals, and anything else that goes through /etc/profile), (2) /etc/environment (read by pam_env on PAM-based logins — sshd, console), and (3) the system-wide interactive bashrc — /etc/bash.bashrc on Debian/Ubuntu, else /etc/bashrc on the RPM/SUSE family — which sources sink (1) for interactive non-login shells. Sink (3) exists because a plain docker exec -it <container> bash (the most common way to poke at a running container, as root or the remote user) starts an interactive non-login shell that reads neither profile.d nor /etc/environment — without it, exec'ing a bash shell saw no SSL_CERT_DIR at all. The bridge is marker-guarded (idempotent across feature reinstalls) and sources profile.d rather than duplicating the exports, so $HOME still expands per-user. docker exec (how VS Code attaches in the typical devcontainer flow) does NOT go through PAM, so /etc/environment alone also leaves DEVCONTAINER_DEV_CERTS_SYNC_FROM_CONTAINER and friends invisible to the extension host. SSL_CERT_DIR lives in profile.d with $HOME left unexpanded (per-user expansion at login) and in /etc/environment with a resolved _REMOTE_USER_HOME (pam_env doesn't expand $HOME). The manifest can't carry SSL_CERT_DIR because ${containerEnv:HOME} doesn't resolve inside containerEnv and remoteEnv isn't allowed in features under strict-schema validation. install.sh creates .dotnet/corefx/cryptography/x509stores/my/ and .aspnet/dev-certs/trust/ directories, requests both extensions via customizations.vscode.extensions. install.sh also pre-creates any directories named in extraCertDestinations with vscode ownership so the remote extension can write without privileged escalation.

Key Design Decisions

These decisions were made deliberately. Do not change them without discussion.

  • Kestrel environment variables are opt-in only. Kestrel discovers the auto-generated dev cert via X509Store fallback — the workspace extension does NOT set ASPNETCORE_Kestrel__Certificates__Default__Path/__Password for it. The single exception is devcontainerDevCerts.defaultKestrelCertificate: when the host resolves it to a qualifying user cert, it attaches a bundle-level defaultKestrelCert: {name, password?} pointer on CertBundleV3, and the workspace extension looks up the matching cert by name, writes its PFX to ~/.aspnet/dev-certs/https/kestrel-default.pfx, and sets the env vars via context.environmentVariableCollection. The pointer lives on the bundle rather than on a per-cert flag so the workspace's only validation is "does this name match one of the certs"; it never has to enforce "exactly one cert is marked". The password field mirrors userCertificates[].pfxPassword (single source of truth); the new VS Code option itself carries only the cert name. The env vars apply only to processes launched from VS Code (terminals, debug, tasks), not to /etc/environment or /etc/profile.d — deliberate, because non-VS-Code processes shouldn't pick up an extension-scoped selection. Only one cert can be the default; the setting must reference a user-managed entry with a private key.

  • No update-ca-certificates. OpenSSL trust is handled via SSL_CERT_DIR pointing to a directory with c_rehash hash symlinks. No system CA bundle modification.

  • No openssl binary dependency — on the host OR in the container. c_rehash is implemented in pure TypeScript in src/shared/src/cert/rehash.ts (ASN.1 DER parsing + canonical-name construction + SHA-1 subject hash), and BOTH sides use it: the workspace extension's certInstaller and the host's LinuxCertificateStore.trustViaOpenSsl. The host previously shelled out to openssl x509 -hash, which meant OpenSSL trust silently no-opped on any host without the binary — the host is a developer machine we don't control, so it must not be a runtime dependency. Note the hash is NOT SHA-1 over the raw subject DER: OpenSSL hashes X509_NAME_canon output — values transcoded to UTF-8 (T61String from Latin-1, BMPString from UTF-16BE, UniversalString from UTF-32BE) and only then re-tagged UTF8String, ASCII-lowercased, and whitespace-folded, with RDN SET OF encodings concatenated without the Name's outer SEQUENCE. Two details the OpenSSL source reads as if it does otherwise, both settled against openssl x509 -hash on real certs: the transcode genuinely happens (a T61String holding UTF-8 bytes of 日本語 hashes to 02c4fa54, not the e2c402e4 you get from re-tagging those bytes), and folding covers every ASCII whitespace byte rather than just 0x20 ("a\tb", "a\nb", "a b" and "A B" all hash alike). Fixtures for each live in tests/rehash.test.ts; add to them rather than re-deriving from the source. Getting that wrong produces a {hash}.N nothing ever opens, which disables SSL_CERT_DIR trust while looking healthy on disk. tests/rehash.test.ts pins the values against openssl x509 -hash, and tests/linuxStore.integration.test.ts proves the result with openssl verify -CApath.

  • No docker exec/cp. Certificate material is transferred via VS Code's cross-host command routing, making the solution remote-transport-agnostic. Do not introduce Docker-specific commands.

  • Honor DOTNET_DEV_CERTS_OPENSSL_CERTIFICATE_DIRECTORY. Both the UI extension's Linux store (src/shared/src/platform/linuxStore.ts) and the workspace extension (util/paths.ts) respect this override, matching the official .NET CertificateManager behavior.

  • install.sh sets DOTNET_GENERATE_ASPNET_CERTIFICATE=false only when the host is the dev cert source. The race: on first dotnet run / dotnet new webapi / dotnet build of an HTTPS-enabled project, dotnet's implicit CertificateManager flow writes a self-signed cert into ~/.dotnet/corefx/cryptography/x509stores/my/. When the workspace extension is concurrently writing OUR host-generated cert there, whichever write lands last wins on disk, but the OS trust + .NET Root-store state may have been driven by the other side — yielding a half-trusted, half-orphaned cert combo (the "partially valid certificate on first run" symptom). Setting the env var to false suppresses dotnet's IMPLICIT path only; explicit dotnet dev-certs https commands still work. Gating logic: suppress only when generateDotNetCert: true AND syncContainerCert: false (the default — host is the source). When syncContainerCert: true, the container is the source and the container-side "generate" step might literally BE dotnet's implicit auto-gen (a dotnet run somewhere bootstrapping the cert we then push to the host); suppressing it would break the source. When neither managed flow is on, there's nothing to race with, so leave dotnet alone. The variable is written to both /etc/profile.d/devcontainer-dev-certs.sh and /etc/environment (gated identically in both sinks) so it's visible regardless of session type. Do NOT make this unconditional — syncContainerCert flows in particular can rely on dotnet's auto-gen as their source.

  • install.sh treats every feature-option value as untrusted. Values from devcontainer.json flow into two privileged sinks: /etc/profile.d/devcontainer-dev-certs.sh (sourced by every login shell, root-owned) and /etc/environment (read by pam_env, and on some distros sourced by /etc/profile). Both sinks must be inert under shell expansion. Defense-in-depth rules: (1) values written into /etc/profile.d/*.sh via append_profile (and the inline SSL_CERT_DIR line) go through the shell_single_quote helper — single quotes prevent every form of bash expansion ($VAR, $(...), backticks) with ' itself escaped via the '\'' pattern. Do NOT switch append_profile to double-quoted emission "for readability" — the input is untrusted. (2) values written into /etc/environment via append_env escape \, ", $, AND backticks — pam_env's own escape rules cover the first two; the latter two protect against the case where the file is later sourced as shell. (3) newlines are rejected outright (no safe escape for them in either file's line-oriented format). The regex validations at the top of install.sh are the primary defense, but the quoting at the write site MUST stay paranoid so a future regression in those allowlists can't become an RCE.

  • SSL_CERT_DIR must include system CA paths. Setting it overrides the system default entirely. The devcontainer feature includes all common distro paths (/etc/ssl/certs, /usr/lib/ssl/certs, /etc/pki/tls/certs, /var/lib/ca-certificates/openssl) and exposes sslCertDirs as an option for user override. Non-existent dirs are pruned at install time, gated on the pruneMissingCertDirs option (default true): the default list spans several distros and only a subset exists on any given image, and while OpenSSL ignores a missing dir, some consumers (Rust's openssl-probe / rustls-native-certs, which read_dir each entry) error on one. Pruning is a dedicated boolean toggle, NOT inferred from whether sslCertDirs was overridden. The devcontainer CLI exports a feature option's env var set to the declared default even when the user didn't specify it, so install.sh genuinely cannot distinguish "omitted" from "explicitly set to the default value" — any inference (an earlier [ -z "$SSLCERTDIRS" ] check, then a compare-to-DEFAULT_SSL_CERT_DIRS heuristic) is guesswork and was either a silent no-op or wrong for a user who sets the default verbatim. So pruning applies to whatever sslCertDirs resolves to (default or explicit) when pruneMissingCertDirs is true; set it false to use the list verbatim (e.g. a dir created after install but before it's needed). SSL_CERT_DIR is owned exclusively by the feature's install.sh; the VS Code extensions do not configure it — the workspace extension's old ensureSslCertDir + devcontainer-dev-certs.sslCertDirs/ensureSslCertDir settings were a perpetual no-op in devcontainers (install.sh got there first) and confusing dead weight, so they and util/sslCertDir.ts were removed. The UI extension's separate ensureTerminalSslCertDir (host-side local terminals) is unrelated and stays. Because pruning can empty the system-dir list, all sinks compose SSL_CERT_DIR empty-safe (trust dir alone, no trailing colon) — the same dangling-empty-element artifact the pruning avoids. test/install-sh.test.mjs exercises this hermetically (runs install.sh under a temp DEVCERTS_SYSROOT and asserts the produced profile.d / /etc/environment / bashrc contents, including the toggle on/off).

  • runProcess caps captured output at 32 MiB and reports truncation. Node's execFile default is 1 MiB, which security find-certificate -a on macOS can exceed: it dumps every certificate in the login keychain, roughly 2 KB per entry for the attribute form and 1.5 KB for the PEM form, so ~500-700 certs. Uncommon, but MDM-pushed user certs and years of accumulated dev certs get there. The failure was silent and self-feeding: Node reports the overflow with a string error.code (ERR_CHILD_PROCESS_STDIO_MAXBUFFER), so runProcess's typeof error.code === "number" ? … : 1 folded it into exitCode: 1 with an empty stderr — indistinguishable from a real failure. isCertInKeychain read that as "not in the keychain", force-skipped the on-disk PFX as an orphaned cache file, emptied findExistingDevCert, and sent CertManager.trust() down the generate() branch: a new cert plus an add-trusted-cert password prompt, on every request — and each new cert landed in the same keychain, so the next call truncated sooner. Hence two things: the cap is raised, and ProcessResult.truncated exists so callers can tell "no" from "couldn't tell". isCertInKeychain deliberately fails OPEN on truncation (assumes the cert IS present), inverting the usual instinct because here the closed direction is the destructive one; the open direction costs at most one redundant re-trust, since checkStatus establishes trust separately via security verify-cert. Any new caller that reads exitCode !== 0 as a decision MUST check truncated first.

  • Follow-up (needs a macOS host to verify): narrow the keychain queries. Both security find-certificate -a calls enumerate the entire login keychain and then filter to CN=localhost in TypeScript. Pushing that filter to the CLI — -a -Z -c localhost — would drop the output to single digits and make the cap unreachable rather than merely distant. Not done yet because security's -c matching semantics (exact vs. substring, case sensitivity, and its non-zero exit when nothing matches) can't be verified from a Linux dev box, and a false negative lands in the destructive direction described above. The cap and the truncated flag stay regardless — they cover the other call sites.

  • Follow-up: evaluate @azure/core-process in place of direct execFile. Real package (MIT, Azure SDK, "Secure, cross-platform process launching for Node.js") exposing execFile/spawn/resolveExecutable with shell?: never, a maxBuffer option, and PATH resolution — the surface platform/processUtil.ts hand-rolls. It also does something we do not: refuses .cmd/.bat on Windows by default (opt-in via allowWindowsBatchFiles), the batch-argument-injection class. Before adopting, confirm (a) that its resolver skips relative PATH entries, which is a documented property of our resolveSafeExecPath and the reason the cwd-first CreateProcess hijack is closed, (b) that overflow remains distinguishable from failure so ProcessResult.truncated survives, and (c) the bundle-size cost — the UI extension esbuilds its runtime deps into the VSIX. Note the caution: as of this writing the package has exactly one published version, 1.0.0 from 2026-08-13, which is very new for a security-sensitive dependency.

  • The UI extension's user-facing commands are recovery affordances, never provisioning. contributes.commands holds two: devcontainer-dev-certs.trustInBrowsers ("Dev Certs: Trust Certificate in Browsers"), a Linux-only retry for the Firefox / Chromium NSS import when the automatic attempt inside trustCertificate didn't complete, and devcontainer-dev-certs.resetContainerCertConsent ("Dev Certs: Reset Container Certificate Consent"), which clears the recorded container-cert decision. Everything else activate() registers is a cross-host IPC entry point the workspace extension calls — getCertMaterial, getAllCertMaterial, getAllCertMaterialV3, acceptContainerDevCert (listed in full in the Architecture section above) — and none of them appear in the palette. Certificate generation and trust happen automatically in response to a workspace request; there is deliberately no "generate a dev cert now" command, because provisioning is gated on consent plus host settings and a palette entry would be a second, unreviewed way in.

    trustInBrowsers resolves its target through certManager.check(), which reads the host's own platform store, so it can only re-import the host-generated cert. A cert accepted via syncContainerCert is public-cert-only and never written to my/ (see the reverse-sync decision below), so check() cannot see it and the command reports "No development certificate found" — a consequence of that security boundary rather than an oversight, and documented for users in the README's command table. On Windows and macOS the command is registered but no-ops with an informational message, since browser trust follows the OS store there; it has no when clause, so it is still visible in the palette on those platforms.

  • Container-cert consent is a tri-state, host-wide, and reversible. containerCertProvisionConsented holds "granted" / "denied" / absent, read through normalizeContainerCertConsent (which maps the historical true to "granted" so existing consenters are never re-prompted). It replaced a boolean that could only ever record yes: an accept persisted forever while a decline persisted nothing, so the prompt returned on every container activation and the only durable state a security prompt could reach was the permissive one — a one-way ratchet whose only escape was editing devcontainer.json and rebuilding, or disabling host-side generation too.

    Three modal outcomes, and the distinction between the last two is the point: Trust records granted (after the trust step succeeds, so a failed add-trusted-cert leaves consent unpersisted and the next push re-prompts); Never records denied immediately, since there is no trust step that could fail; Cancel / Escape declines this push and records NOTHING, so a stray keystroke can't disable the feature permanently. A standing denied short-circuits before the prompt — that is what makes declining actually stop the asking.

    Consent is host-wide rather than per-thumbprint because container dev certs rotate on rebuild. Unless a container bakes the cert into its image or mounts a volume for ~/.dotnet/corefx/cryptography/x509stores/my/, dotnet dev-certs mints a fresh one each rebuild, so a per-certificate memory would prompt on every rebuild — the shape of consent people learn to click through without reading. Do not "improve" this to per-thumbprint without changing that assumption first.

    resetContainerCertConsent exists so Never isn't itself a one-way ratchet — otherwise the fix would reintroduce the same defect pointing the other way. It clears the key (either direction) and deliberately does NOT untrust certificates already in the host store; there is no untrust path today (see the accumulation note below).

  • Rebuild-rotation is the worst case, and it is the DEFAULT. Most dotnet devcontainer base images mint a fresh dev cert on the container's first HTTPS build, and nothing lets this extension verify that a devcontainer.json setting syncContainerCert: true has actually persisted ~/.dotnet/corefx/cryptography/x509stores/my/ (via a baked-in cert or a volume). Persisting it is the sensible configuration, but it is unenforceable — so every design here has to assume a NEW certificate arrives on every rebuild, forever, and that the host's trust surfaces accumulate one entry per rebuild with nothing pruning them.

    This already broke once. Every dev cert is CN=localhost, so they all share one OpenSSL subject hash (ce275665) and consume {hash}.{n} slots by cert COUNT rather than by genuine collision. ensureHashSymlink capped at ten and then returned silently, so the eleventh rotation produced no symlink for the live cert while ten dead ones kept theirs: openssl verify -CApath passed for the oldest cert and failed for the current one. The cap is now 256, with a warning past ten and a thrown error at the bound — returning quietly would let an install report success for a certificate OpenSSL cannot find, which is the same failure merely delayed, and under rebuild-rotation the bound is reachable. tests/linuxStore.integration.test.ts drives twelve rotations through real openssl verify. Relatedly, "installed" and "trusted" both now require a resolvable hash symlink (hasHashSymlink), not just the PEM on disk: an install or host trusted before the hash was computed canonically has its link under the wrong name, and a files-only check would report health, skip the repair, and strand precisely the users the fix exists for. A PEM whose subject cannot be hashed is exempt — ensureHashSymlink is a no-op for it, so demanding a link would loop forever without converging. Note the corollary for any future pruning: by_dir stops at the first missing slot, so entries must be re-densified with rehashDirectory, never unlinked in place.

  • Open: nothing prunes superseded certs, on any platform. Under rebuild-rotation the host accrues one trusted CN=localhost leaf per rebuild in CurrentUser\Root, the login keychain, the .NET root store + OpenSSL trust dir, and (since NSS nicknames became per-thumbprint) the browser NSS databases. The 365-day validity window bounds the security exposure — accumulated certs expire and stop validating — but not the clutter, and not the operational effects: keychain enumeration output grows (see the runProcess output-cap decision), and trust surfaces fill with entries whose private keys live in containers that no longer exist. Closing this needs a targeted per-platform untrustCertificate plus a supersede policy, and the policy is the hard part: "untrust the previous container cert on accept" ping-pongs when two containers are open at once, which is precisely why trustViaOpenSsl was made additive in the first place. Whatever lands, it needs a real entry point — do not reintroduce unreferenced removal surface.

  • Container-to-host reverse-sync is off by default per-container. The syncContainerCert feature option defaults to false and is the only opt-in toggle. Host-side gating reuses the existing devcontainerDevCerts.generateDotNetCert + devcontainer-dev-certs.autoProvision settings — there is no separate "accept container certs" host setting (a user disabling managed dev certs via those existing settings implicitly disables container-pushed acceptance too). The host independently re-validates anything pushed via acceptContainerDevCert; do not skip the isValidDevCert + validateLeafTrustShape + validateLocalSans checks even if the workspace asserts the cert is valid.

  • syncContainerCert: true overrides the per-container generateDotNetCert feature option. When the container is pushing its own dev cert to the host, asking the host to ALSO generate and send back a different dotnet dev cert would leave the container with two trusted dev certs in my/ — confusing for .NET / Aspire's selection logic. The workspace extension's pull-from-host request therefore forces includeDotNetDev=false whenever DEVCONTAINER_DEV_CERTS_SYNC_FROM_CONTAINER=true, regardless of DEVCONTAINER_DEV_CERTS_GENERATE_DOTNET. User certs (syncUserCertificates) are unaffected. The cleanup path (detectStaleAndPromptCleanup / cleanupCommand) augments the bundle's managed-thumbprint set with the container's own dev cert thumbprint when syncContainerCert is on, so the sweep doesn't classify the source cert as stale. Do NOT add a separate per-container generateDotNetCert: false requirement — making the user set both for the same intent is a footgun.

  • Reverse-sync is public-cert-only end-to-end. The wire payload (AcceptContainerCertPayload) carries pemCertBase64 and a thumbprint — never a PFX, never a private key. The host's accept handler calls certManager.trustExternalCertificate(cert) which invokes the platform store's trustCertificate(cert) and deliberately skips saveCertificate (no my/ write, no keychain import, no CurrentUser/My register). The host needs nothing the private key would unlock: Kestrel runs in the container with its own key copy, and the host's only role in this flow is being a trust anchor so browsers and forwarded ports validate the cert. Treat this as a security boundary — DO NOT extend the accept path to land the cert in my/ or persist a key copy on the host; if a future flow needs the key on the host, design a new explicit path with its own consent and don't reuse this one.

  • Trust is platform-uniform across the two flows. trustExternalCertificate invokes the SAME store.trustCertificate(cert) method the host-generation flow's final step uses. That means every trust surface the platform store wires into trustCertificate runs for both flows — on Linux specifically that includes NSS browser DBs (the linuxNssTrustReporter is set once on CertManager construction and covers both paths). "Trusted on the host" is defined by store.trustCertificate; if you ever add a new trust surface (say, a new browser store handler), wire it into trustCertificate on the platform store, not into the accept handler — that keeps the two flows in lockstep automatically. tests/manager.test.ts pins the contract: trustExternalCertificate MUST call store.trustCertificate and MUST NOT call store.saveCertificate / store.findExistingDevCert.

  • NSS trust flags are per browser family, and the split is deliberate. trustFlagsFor in nssTrust.ts sends P,, to Chromium-family databases and C,, to Firefox profiles. Do not collapse these to one value. The dev cert is a self-signed end entity (generateCertificate emits cA=FALSE), so the correct encoding is P — CERTDB_TRUSTED, "trusted peer", the bit consulted when the cert is what's being validated; C is CERTDB_TRUSTED_CA, consulted only in an issuer position our cert never occupies. Firefox is an empirical exception: it ignores P for server certs, so C is what actually produces trust there. This mirrors dotnet dev-certs https --trust (UnixCertificateManager.TryAddCertificateToNssDb: usage = nssDb.IsFirefox ? "C" : "P"), validated by Microsoft against real browsers. We previously sent a blanket CT,, everywhere, which is right for Firefox and wrong for Chromium — certutil -V -u V rejects such an entry with "Issuer certificate is invalid", pinned by an integration test. That asymmetry is also why dotnet verifies Chromium with -V -u V but Firefox with only -L: -V cannot pass under C. Migration is handled by the existing delete-then-add, since certutil -A will not rewrite an existing nickname's trust string.

  • A container-pushed cert must be a server-auth LEAF before its SANs mean anything. validateLeafTrustShape gates validateLocalSans in acceptContainerDevCert, and the ordering is the whole point. trustCertificate puts the cert in CurrentUser\Root (Windows), the login keychain's SSL trust settings (macOS), and the .NET Root store + OpenSSL CApath + browser NSS databases (Linux) — anchor positions, and in the Firefox NSS case an explicit C "trusted CA" flag. Whether it can actually act as a CA from there comes down to basicConstraints. A SAN check cannot substitute: a CA's own SANs place no limit on what it may issue, so a CA carrying localhost SANs would pass a SAN-only gate and then sign a leaf for any name at all. So: basicConstraints must be present with cA=FALSE (absent leaves the question to each validator's historical quirks), and EKU must be present, include id-kp-serverAuth, and not include anyExtendedKeyUsage (absent EKU reads as "any purpose", and Windows -addstore Root applies no policy constraint of its own). Extra concrete usages like clientAuth are tolerated. Every genuine dev cert — ours via generateCertificate, .NET's via CertificateManager — carries both extensions in exactly this shape, which tests/containerCertAccept.test.ts pins by driving the real generator through the accept path. Do NOT relax these to "check only if present".

  • SAN-local restriction is the default on container-pushed certs, and structural SAN failures are not overridable. validateLocalSans rejects dNSName / iPAddress entries outside well-known local scopes (loopback, RFC1918 private IP, localhost / docker host names, *.dev.localhost, *.dev.internal). devcontainerDevCerts.allowNonLocalContainerCertSans is the explicit opt-out for that — and only that: it overrides reason: "non-local" and nothing else. The scanner (scanSanEntries) separately rejects a SAN set that is absent, undecodable, empty, or carrying a GeneralName type other than dNSName / iPAddress, all of which surface as malformed-sans and are never overridable. The reasoning: the override lets a user say "yes, I really do mean to trust this cert for that name", which is meaningless for a cert whose names we could not read. Reporting "SANs are local-only" after silently dropping the entries we didn't recognize was vouching for a cert we had only partially inspected. Note also that @peculiar/x509 parses extensions lazily and throws from getExtension on bad DER — that used to escape into the accept handler's blanket try/catch and land as a generic parse failure, making fail-closed an accident of the call site. scanSanEntries now catches it and names the reason, so adding a try/catch inside the scanner can't silently invert the behavior.

Build System

  • Extensions: TypeScript, esbuild bundler, @types/vscode ^1.100.0. The UI extension bundles @peculiar/x509, pkijs, and asn1js as runtime dependencies (all bundled by esbuild into the output).
  • CI: GitHub Actions. The UI extension is packaged as a single universal VSIX (no per-platform binaries). The workspace extension is also a single universal VSIX.

Testing

  • Extension testing: F5 launches an Extension Development Host. The build-extensions task hydrates a test project at .out/test-project/ from the template at test/sample-project/. The workspace extension VSIX is staged in .out/test-project/.devcontainer/ and referenced via ${containerWorkspaceFolder} in customizations.vscode.extensions.
  • The trust operation generates the cert if it doesn't exist. This is intentional — it's the single entry point for provisioning.

VS Code E2E harness (test/vscode-e2e/) — spike status

@vscode/test-electron launches a real VS Code with both extensions loaded into one window (--extensionDevelopmentPath twice) and runs the suite in the extension host. Scripts, from the repo root:

npm run typecheck:e2e     # tsc pass; esbuild and the runner never typecheck
npm run build:e2e         # bundles the suite to .out/vscode-e2e/suite.cjs
npm run check:e2e-load    # ~200ms: does the bundle even load? (see below)
npm run test:e2e          # on Linux: xvfb-run -a npm run test:e2e

Run check:e2e-load before test:e2e, and keep it ahead of the download step in CI. It stubs vscode and require()s the built bundle, which is where module-init bugs surface. That is not hypothetical: the suite bundles the shared package, which pulls in @peculiar/x509 → tsyringe, and a missing import "reflect-metadata" in the entry point threw inside the extension host before a single test ran. The load check reproduces that in ~200ms; discovering it the other way costs npm ci, two extension builds, a ~110MB VS Code download and an Electron launch. If you add an import to the suite that reaches new runtime machinery, this is the check that tells you cheaply.

It needs dist/extension.js for both extensions and the .out/test-fixtures/ cert (the launcher runs gen:test-cert itself if it's missing). CI runs it as the separate vscode-e2e job in build-extensions.yml — separate so a VS Code download failure can't be confused with a unit-test regression.

What it covers: activation of both extensions without throwing, command registration on both sides, and one vertical slice — getAllCertMaterialV3 driven from the workspace extension to the UI extension, with the container-side install asserted on disk (PEM present and matching, {hash}.N symlink resolving, .NET Root store PFX written, My-store PFX correctly absent). All writes are redirected into a mkdtemp sandbox via HOME and DOTNET_DEV_CERTS_OPENSSL_CERTIFICATE_DIRECTORY, so the runner's real ~/.aspnet and ~/.dotnet are untouched.

What it does NOT cover — do not oversell this as full E2E:

  • The actual host↔server hop. Both extensions share one extension host, so executeCommand passes objects by reference. In production the payload is serialized. This is the approach's central fidelity gap, and src/wireGuard.ts is the compensation: every cross-host payload is walked for non-JSON values (Buffer, Date, Map, class instances, functions, bigint, non-finite numbers, cycles, own toJSON) and round-tripped through structuredClone and JSON. Its negative self-tests are not optional decoration — without them a guard that silently accepted everything would produce an identical green run. undefined-valued object properties are the one tolerated difference (VS Code's RPC drops them exactly as JSON does); undefined inside an array is still rejected, because it becomes null.
  • The container filesystem. The "container" is a temp dir on the same machine. Nothing exercises a real container's users, permissions, mounts, or a genuinely separate $HOME.
  • The dotnet dev-cert path. The slice runs a user certificate. Generating the dev cert would write to the runner's real OS trust store and raise a modal consent dialog nothing headless can dismiss, so generateDotNetCert is off for the run. Reverse-sync (acceptContainerDevCert) is likewise uncovered.

The remote-gate seam (DEVCONTAINER_DEV_CERTS_TEST_REMOTE) — test-only

src/vscode-workspace-extension/src/extension.ts no-ops unless vscode.env.remoteName is set. That property is read-only and is populated only by a resolver extension that has claimed an authority, so nothing inside a test can set it — hence isRemoteContext(), which also accepts DEVCONTAINER_DEV_CERTS_TEST_REMOTE=1.

This is a test-only code path in production code, and the gating is the whole reason it's acceptable. The env var alone does nothing. It is honored only when context.extensionMode !== vscode.ExtensionMode.Production, and Production is what VS Code assigns to every installed extension — the marketplace VSIX, a sideloaded VSIX, a --install-extension copy. Reaching the override requires launching VS Code with --extensionDevelopmentPath or --extensionTestsPath pointed at a source checkout. So the branch is not merely unlikely in a shipped build, it is unreachable: someone who can set environment variables still cannot flip it on.

If you change this, keep both halves. An env-var-only check would be a genuine escape hatch in shipped code and should be rejected in review. tests/remoteGate.test.ts pins exactly that — it asserts the override is refused under ExtensionMode.Production for every truthy spelling of the variable — and it runs in the fast vitest suite, not only in the E2E job, so the guarantee doesn't depend on a VS Code download succeeding.

The alternative that avoids a product-code seam entirely is a resolver extension implementing resolveAuthority to populate remoteName for real. It is more faithful and would also unlock testing against a real container — but it rides a proposed API, so it needs --enable-proposed-api and can break between VS Code releases. Choosing between the two is the open question this spike exists to inform.

File Paths That Matter

Path (in container) Purpose
~/.dotnet/corefx/cryptography/x509stores/my/{thumbprint}.pfx .NET X509Store — Kestrel reads from here. One per cert (dotnet-dev or user) that has a private key.
~/.dotnet/corefx/cryptography/x509stores/root/{thumbprint}.pfx .NET Root store — public-cert-only PFX for trust reporting.
~/.aspnet/dev-certs/trust/aspnetcore-localhost-{thumbprint}.pem OpenSSL trust — PEM for the auto-generated dotnet dev cert.
~/.aspnet/dev-certs/trust/{userCert.name}.pem OpenSSL trust — PEM for a user-managed cert, keyed by the user-supplied name (stable, predictable filename).
~/.aspnet/dev-certs/trust/{hash}.0 OpenSSL trust — hash symlink (c_rehash) pointing at either of the above.
{extraCertDestinations entry}/{certName}.{pem,key,pfx} or {certName}-bundle.pem Additional per-destination files. certName is aspnetcore-dev for the dotnet dev cert, or the userCertificates[].name for user certs. This is a stable contract; downstream configs may rely on it.

Certificate Properties

Must match ASP.NET's CertificateManager exactly for the auto-generated dev cert:

  • Subject: CN=localhost
  • RSA 2048-bit, SHA-256, PKCS1 padding (default; generateCertificate also accepts ECDSA / Ed25519 algorithm overrides for non-dotnet flows)
  • 365-day validity
  • Extensions: Basic Constraints (critical, not CA), Key Usage (critical, KeyEncipherment|DigitalSignature), EKU (critical, Server Auth), SAN (critical, 7 entries), custom OID 1.3.6.1.4.1.311.84.1.1 with version byte 0x06, SKI, AKI

User-managed certificates (devcontainerDevCerts.userCertificates) are loaded as-is from PEM or PKCS#12 inputs and may use any algorithm Node's crypto.createPrivateKey understands (RSA, EC P-256/P-384/P-521, Ed25519, Ed448).