This is a monorepo containing three components that together provide automatic HTTPS certificate management for .NET workloads in VS Code devcontainers and remote environments.
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-incryptoplus@peculiar/x509(X.509) andpkijs(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-preservingpfxBase64and per-certinstallToDotNetStoreflag.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. Returnsnullwhen 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 intosyncContainerCert, independently re-validates (isValidDevCert+validateLeafTrustShape+validateLocalSans), prompts for one-time consent (containerCertProvisionConsentedglobal 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 toCurrentUser/My, the keychain identity slot, or the .NETmy/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.generateDotNetCertanddevcontainer-dev-certs.autoProvision. SAN-local restriction has an opt-out viadevcontainerDevCerts.allowNonLocalContainerCertSans. Idempotent on repeat pushes, but by an explicit check rather than a platform guarantee:CertManager.trustExternalCertificatecallsstore.isCertTrusted(cert)and returns before invokingtrustCertificate. Do not remove that short-circuit on the assumption that re-trusting is free — it is not on macOS, wheresecurity add-trusted-certre-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
securityCLI (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). CallsgetAllCertMaterialV3first, falling back togetAllCertMaterialthengetCertMaterialif the UI extension is older. ParsesDEVCONTAINER_DEV_CERTS_EXTRA_DESTINATIONSinto anExtraDestination[]viasrc/util/destinations.ts, then for each cert in the bundle:- Installs it to the canonical .NET + OpenSSL locations (
installDotNetDevCert/installUserCertincertInstaller.ts). - Writes each cert to every configured extra destination via
writeExtraDestination. - Runs a single rehash per directory destination after all writes.
Additionally, when
DEVCONTAINER_DEV_CERTS_SYNC_FROM_CONTAINER=true(set by thesyncContainerCertfeature option), the workspace extension runspushContainerCertToHost()before the standard pull. That scans~/.dotnet/corefx/cryptography/x509stores/my/*.pfxusing the same sharedclassifyCandidate+selectBestDevCertrules the host uses on its own platform stores, pre-validates the winning candidate, and pushes it to the host viaacceptContainerDevCert. 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). - Installs it to the canonical .NET + OpenSSL locations (
-
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), andcert/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 passingonSkipped/onMultipleCandidatescallbacks. Both extensions independently localize via their ownvscode.l10n.tbundles. -
Devcontainer feature (
src/devcontainer-feature/) —install.shwrites feature options andSSL_CERT_DIRinto 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'suserEnvProbe, integrated terminals, and anything else that goes through/etc/profile), (2)/etc/environment(read bypam_envon PAM-based logins — sshd, console), and (3) the system-wide interactive bashrc —/etc/bash.bashrcon Debian/Ubuntu, else/etc/bashrcon the RPM/SUSE family — which sources sink (1) for interactive non-login shells. Sink (3) exists because a plaindocker 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 noSSL_CERT_DIRat all. The bridge is marker-guarded (idempotent across feature reinstalls) and sources profile.d rather than duplicating the exports, so$HOMEstill expands per-user.docker exec(how VS Code attaches in the typical devcontainer flow) does NOT go through PAM, so/etc/environmentalone also leavesDEVCONTAINER_DEV_CERTS_SYNC_FROM_CONTAINERand friends invisible to the extension host.SSL_CERT_DIRlives in profile.d with$HOMEleft unexpanded (per-user expansion at login) and in/etc/environmentwith a resolved_REMOTE_USER_HOME(pam_env doesn't expand$HOME). The manifest can't carrySSL_CERT_DIRbecause${containerEnv:HOME}doesn't resolve insidecontainerEnvandremoteEnvisn't allowed in features under strict-schema validation.install.shcreates.dotnet/corefx/cryptography/x509stores/my/and.aspnet/dev-certs/trust/directories, requests both extensions viacustomizations.vscode.extensions.install.shalso pre-creates any directories named inextraCertDestinationswithvscodeownership so the remote extension can write without privileged escalation.
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/__Passwordfor it. The single exception isdevcontainerDevCerts.defaultKestrelCertificate: when the host resolves it to a qualifying user cert, it attaches a bundle-leveldefaultKestrelCert: {name, password?}pointer onCertBundleV3, 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 viacontext.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". Thepasswordfield mirrorsuserCertificates[].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/environmentor/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 viaSSL_CERT_DIRpointing 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'scertInstallerand the host'sLinuxCertificateStore.trustViaOpenSsl. The host previously shelled out toopenssl 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 hashesX509_NAME_canonoutput — 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 RDNSET OFencodings concatenated without the Name's outerSEQUENCE. Two details the OpenSSL source reads as if it does otherwise, both settled againstopenssl x509 -hashon real certs: the transcode genuinely happens (a T61String holding UTF-8 bytes of日本語hashes to02c4fa54, not thee2c402e4you get from re-tagging those bytes), and folding covers every ASCII whitespace byte rather than just0x20("a\tb","a\nb","a b"and"A B"all hash alike). Fixtures for each live intests/rehash.test.ts; add to them rather than re-deriving from the source. Getting that wrong produces a{hash}.Nnothing ever opens, which disablesSSL_CERT_DIRtrust while looking healthy on disk.tests/rehash.test.tspins the values againstopenssl x509 -hash, andtests/linuxStore.integration.test.tsproves the result withopenssl 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 .NETCertificateManagerbehavior. -
install.shsetsDOTNET_GENERATE_ASPNET_CERTIFICATE=falseonly when the host is the dev cert source. The race: on firstdotnet run/dotnet new webapi/dotnet buildof an HTTPS-enabled project, dotnet's implicitCertificateManagerflow 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; explicitdotnet dev-certs httpscommands still work. Gating logic: suppress only whengenerateDotNetCert: trueANDsyncContainerCert: false(the default — host is the source). WhensyncContainerCert: true, the container is the source and the container-side "generate" step might literally BE dotnet's implicit auto-gen (adotnet runsomewhere 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.shand/etc/environment(gated identically in both sinks) so it's visible regardless of session type. Do NOT make this unconditional —syncContainerCertflows in particular can rely on dotnet's auto-gen as their source. -
install.shtreats every feature-option value as untrusted. Values fromdevcontainer.jsonflow into two privileged sinks:/etc/profile.d/devcontainer-dev-certs.sh(sourced by every login shell, root-owned) and/etc/environment(read bypam_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/*.shviaappend_profile(and the inlineSSL_CERT_DIRline) go through theshell_single_quotehelper — single quotes prevent every form of bash expansion ($VAR,$(...), backticks) with'itself escaped via the'\''pattern. Do NOT switchappend_profileto double-quoted emission "for readability" — the input is untrusted. (2) values written into/etc/environmentviaappend_envescape\,",$, 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 ofinstall.share 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_DIRmust 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 exposessslCertDirsas an option for user override. Non-existent dirs are pruned at install time, gated on thepruneMissingCertDirsoption (defaulttrue): 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'sopenssl-probe/rustls-native-certs, whichread_direach entry) error on one. Pruning is a dedicated boolean toggle, NOT inferred from whethersslCertDirswas overridden. The devcontainer CLI exports a feature option's env var set to the declared default even when the user didn't specify it, soinstall.shgenuinely cannot distinguish "omitted" from "explicitly set to the default value" — any inference (an earlier[ -z "$SSLCERTDIRS" ]check, then a compare-to-DEFAULT_SSL_CERT_DIRSheuristic) is guesswork and was either a silent no-op or wrong for a user who sets the default verbatim. So pruning applies to whateversslCertDirsresolves to (default or explicit) whenpruneMissingCertDirsis true; set it false to use the list verbatim (e.g. a dir created after install but before it's needed).SSL_CERT_DIRis owned exclusively by the feature'sinstall.sh; the VS Code extensions do not configure it — the workspace extension's oldensureSslCertDir+devcontainer-dev-certs.sslCertDirs/ensureSslCertDirsettings were a perpetual no-op in devcontainers (install.sh got there first) and confusing dead weight, so they andutil/sslCertDir.tswere removed. The UI extension's separateensureTerminalSslCertDir(host-side local terminals) is unrelated and stays. Because pruning can empty the system-dir list, all sinks composeSSL_CERT_DIRempty-safe (trust dir alone, no trailing colon) — the same dangling-empty-element artifact the pruning avoids.test/install-sh.test.mjsexercises this hermetically (runsinstall.shunder a tempDEVCERTS_SYSROOTand asserts the produced profile.d //etc/environment/ bashrc contents, including the toggle on/off). -
runProcesscaps captured output at 32 MiB and reports truncation. Node'sexecFiledefault is 1 MiB, whichsecurity find-certificate -aon 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 stringerror.code(ERR_CHILD_PROCESS_STDIO_MAXBUFFER), sorunProcess'stypeof error.code === "number" ? … : 1folded it intoexitCode: 1with an empty stderr — indistinguishable from a real failure.isCertInKeychainread that as "not in the keychain", force-skipped the on-disk PFX as an orphaned cache file, emptiedfindExistingDevCert, and sentCertManager.trust()down thegenerate()branch: a new cert plus anadd-trusted-certpassword 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, andProcessResult.truncatedexists so callers can tell "no" from "couldn't tell".isCertInKeychaindeliberately 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, sincecheckStatusestablishes trust separately viasecurity verify-cert. Any new caller that readsexitCode !== 0as a decision MUST checktruncatedfirst. -
Follow-up (needs a macOS host to verify): narrow the keychain queries. Both
security find-certificate -acalls enumerate the entire login keychain and then filter toCN=localhostin 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 becausesecurity's-cmatching 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 thetruncatedflag stay regardless — they cover the other call sites. -
Follow-up: evaluate
@azure/core-processin place of directexecFile. Real package (MIT, Azure SDK, "Secure, cross-platform process launching for Node.js") exposingexecFile/spawn/resolveExecutablewithshell?: never, amaxBufferoption, and PATH resolution — the surfaceplatform/processUtil.tshand-rolls. It also does something we do not: refuses.cmd/.baton Windows by default (opt-in viaallowWindowsBatchFiles), the batch-argument-injection class. Before adopting, confirm (a) that its resolver skips relative PATH entries, which is a documented property of ourresolveSafeExecPathand the reason the cwd-firstCreateProcesshijack is closed, (b) that overflow remains distinguishable from failure soProcessResult.truncatedsurvives, 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.0from 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.commandsholds 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 insidetrustCertificatedidn't complete, anddevcontainer-dev-certs.resetContainerCertConsent("Dev Certs: Reset Container Certificate Consent"), which clears the recorded container-cert decision. Everything elseactivate()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.trustInBrowsersresolves its target throughcertManager.check(), which reads the host's own platform store, so it can only re-import the host-generated cert. A cert accepted viasyncContainerCertis public-cert-only and never written tomy/(see the reverse-sync decision below), socheck()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 nowhenclause, so it is still visible in the palette on those platforms. -
Container-cert consent is a tri-state, host-wide, and reversible.
containerCertProvisionConsentedholds"granted"/"denied"/ absent, read throughnormalizeContainerCertConsent(which maps the historicaltrueto"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 editingdevcontainer.jsonand 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 failedadd-trusted-certleaves consent unpersisted and the next push re-prompts); Never recordsdeniedimmediately, 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 standingdeniedshort-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-certsmints 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.resetContainerCertConsentexists 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.jsonsettingsyncContainerCert: truehas 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.ensureHashSymlinkcapped 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 -CApathpassed 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.tsdrives twelve rotations through realopenssl 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 —ensureHashSymlinkis a no-op for it, so demanding a link would loop forever without converging. Note the corollary for any future pruning:by_dirstops at the first missing slot, so entries must be re-densified withrehashDirectory, never unlinked in place. -
Open: nothing prunes superseded certs, on any platform. Under rebuild-rotation the host accrues one trusted
CN=localhostleaf per rebuild inCurrentUser\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 therunProcessoutput-cap decision), and trust surfaces fill with entries whose private keys live in containers that no longer exist. Closing this needs a targeted per-platformuntrustCertificateplus 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 whytrustViaOpenSslwas 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
syncContainerCertfeature option defaults tofalseand is the only opt-in toggle. Host-side gating reuses the existingdevcontainerDevCerts.generateDotNetCert+devcontainer-dev-certs.autoProvisionsettings — 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 viaacceptContainerDevCert; do not skip theisValidDevCert+validateLeafTrustShape+validateLocalSanschecks even if the workspace asserts the cert is valid. -
syncContainerCert: trueoverrides the per-containergenerateDotNetCertfeature 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 inmy/— confusing for .NET / Aspire's selection logic. The workspace extension's pull-from-host request therefore forcesincludeDotNetDev=falsewheneverDEVCONTAINER_DEV_CERTS_SYNC_FROM_CONTAINER=true, regardless ofDEVCONTAINER_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 whensyncContainerCertis on, so the sweep doesn't classify the source cert as stale. Do NOT add a separate per-containergenerateDotNetCert: falserequirement — 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) carriespemCertBase64and a thumbprint — never a PFX, never a private key. The host's accept handler callscertManager.trustExternalCertificate(cert)which invokes the platform store'strustCertificate(cert)and deliberately skipssaveCertificate(nomy/write, no keychain import, noCurrentUser/Myregister). 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 inmy/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.
trustExternalCertificateinvokes the SAMEstore.trustCertificate(cert)method the host-generation flow's final step uses. That means every trust surface the platform store wires intotrustCertificateruns for both flows — on Linux specifically that includes NSS browser DBs (thelinuxNssTrustReporteris set once onCertManagerconstruction and covers both paths). "Trusted on the host" is defined bystore.trustCertificate; if you ever add a new trust surface (say, a new browser store handler), wire it intotrustCertificateon the platform store, not into the accept handler — that keeps the two flows in lockstep automatically.tests/manager.test.tspins the contract:trustExternalCertificateMUST callstore.trustCertificateand MUST NOT callstore.saveCertificate/store.findExistingDevCert. -
NSS trust flags are per browser family, and the split is deliberate.
trustFlagsForinnssTrust.tssendsP,,to Chromium-family databases andC,,to Firefox profiles. Do not collapse these to one value. The dev cert is a self-signed end entity (generateCertificateemitscA=FALSE), so the correct encoding isP—CERTDB_TRUSTED, "trusted peer", the bit consulted when the cert is what's being validated;CisCERTDB_TRUSTED_CA, consulted only in an issuer position our cert never occupies. Firefox is an empirical exception: it ignoresPfor server certs, soCis what actually produces trust there. This mirrorsdotnet dev-certs https --trust(UnixCertificateManager.TryAddCertificateToNssDb:usage = nssDb.IsFirefox ? "C" : "P"), validated by Microsoft against real browsers. We previously sent a blanketCT,,everywhere, which is right for Firefox and wrong for Chromium —certutil -V -u Vrejects such an entry with "Issuer certificate is invalid", pinned by an integration test. That asymmetry is also why dotnet verifies Chromium with-V -u Vbut Firefox with only-L:-Vcannot pass underC. Migration is handled by the existing delete-then-add, sincecertutil -Awill not rewrite an existing nickname's trust string. -
A container-pushed cert must be a server-auth LEAF before its SANs mean anything.
validateLeafTrustShapegatesvalidateLocalSansinacceptContainerDevCert, and the ordering is the whole point.trustCertificateputs the cert inCurrentUser\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 explicitC"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 carryinglocalhostSANs would pass a SAN-only gate and then sign a leaf for any name at all. So: basicConstraints must be present withcA=FALSE(absent leaves the question to each validator's historical quirks), and EKU must be present, includeid-kp-serverAuth, and not includeanyExtendedKeyUsage(absent EKU reads as "any purpose", and Windows-addstore Rootapplies no policy constraint of its own). Extra concrete usages likeclientAuthare tolerated. Every genuine dev cert — ours viagenerateCertificate, .NET's viaCertificateManager— carries both extensions in exactly this shape, whichtests/containerCertAccept.test.tspins 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.
validateLocalSansrejects dNSName / iPAddress entries outside well-known local scopes (loopback, RFC1918 private IP, localhost / docker host names,*.dev.localhost,*.dev.internal).devcontainerDevCerts.allowNonLocalContainerCertSansis the explicit opt-out for that — and only that: it overridesreason: "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 asmalformed-sansand 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/x509parses extensions lazily and throws fromgetExtensionon bad DER — that used to escape into the accept handler's blankettry/catchand land as a generic parse failure, making fail-closed an accident of the call site.scanSanEntriesnow catches it and names the reason, so adding atry/catchinside the scanner can't silently invert the behavior.
- Extensions: TypeScript, esbuild bundler,
@types/vscode ^1.100.0. The UI extension bundles@peculiar/x509,pkijs, andasn1jsas 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.
- Extension testing: F5 launches an Extension Development Host. The
build-extensionstask hydrates a test project at.out/test-project/from the template attest/sample-project/. The workspace extension VSIX is staged in.out/test-project/.devcontainer/and referenced via${containerWorkspaceFolder}incustomizations.vscode.extensions. - The
trustoperation generates the cert if it doesn't exist. This is intentional — it's the single entry point for provisioning.
@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
executeCommandpasses objects by reference. In production the payload is serialized. This is the approach's central fidelity gap, andsrc/wireGuard.tsis the compensation: every cross-host payload is walked for non-JSON values (Buffer, Date, Map, class instances, functions, bigint, non-finite numbers, cycles, owntoJSON) and round-tripped throughstructuredCloneand 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);undefinedinside an array is still rejected, because it becomesnull. - 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
generateDotNetCertis off for the run. Reverse-sync (acceptContainerDevCert) is likewise uncovered.
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.
| 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. |
Must match ASP.NET's CertificateManager exactly for the auto-generated dev cert:
- Subject:
CN=localhost - RSA 2048-bit, SHA-256, PKCS1 padding (default;
generateCertificatealso 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.1with version byte0x06, 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).