Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 33 additions & 7 deletions AGENTS.md

Large diffs are not rendered by default.

8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -203,11 +203,12 @@ Set in your workspace settings or user settings inside the Dev Container / remot

### Commands

All three are available from the Command Palette (`F1`). The host command runs on your local machine; the remote commands run inside the Dev Container.
All four are available from the Command Palette (`F1`). The host commands run on your local machine; the remote commands run inside the Dev Container.

| Command | Runs on | Command ID | Description |
|---------|---------|------------|-------------|
| **Dev Certs: Trust Certificate in Browsers** | Host | `devcontainer-dev-certs.trustInBrowsers` | Retry the Firefox / Chromium NSS import for the **host-generated** dev cert after the automatic attempt couldn't complete. Linux hosts only — on Windows and macOS browser trust follows the OS store automatically. Can't target a cert accepted via `syncContainerCert`. See "[Linux hosts: browser trust](#linux-hosts-browser-trust)". |
| **Dev Certs: Reset Container Certificate Consent** | Host | `devcontainer-dev-certs.resetContainerCertConsent` | Clear the recorded answer to the [`syncContainerCert`](#syncing-a-certificate-from-the-container-to-the-host) consent prompt, so the next container push asks again. Use it to undo either a **Never** (start accepting again) or a previous **Trust** (stop accepting silently). Does not untrust certificates already added to the host store. |
| **Dev Certs: Inject Certificate into Remote** | Remote | `devcontainer-dev-certs.injectCert` | Re-run the certificate injection flow manually. Normally automatic on activation; needed when `autoInject` is `false`, or to retry after a failure. |
| **Dev Certs: Clean Up Other Dev Certificates in Dev Container** | Remote | `devcontainer-dev-certs.cleanupStaleDevCerts` | Remove dev cert artifacts in the container's .NET stores that aren't the extension-managed one, then rehash the OpenSSL trust directory. Refuses to run when no managed dev cert is known, so it can't delete every dev cert on disk. |

Expand Down Expand Up @@ -375,7 +376,7 @@ With `syncContainerCert` enabled:
- If a usable cert is found, the workspace extension pushes **just the public certificate** (PEM-encoded) to the host via a new IPC command. The private key never leaves the container — Kestrel keeps using its own copy of the key inside the container, and the host's job in this flow is to act purely as a trust anchor (so forwarded HTTPS ports and browser-side validation work on the host) rather than as a cert distribution point. If no usable cert is found, the push is a no-op — there's no fallback to host generation.
- **`syncContainerCert: true` overrides the `generateDotNetCert` feature option for this container.** You don't need to also set `generateDotNetCert: false` to opt out of host generation — when the container is pushing its own cert to the host, the workspace extension drops the dotnet dev cert from its pull-from-host request automatically. (Otherwise the container would end up with both its own cert AND a different host-generated cert in its .NET store.) User-managed certificates configured via `userCertificates` are unaffected — those still flow normally.
- The host extension independently re-validates the cert (same `isValidDevCert` rules; matches dev-cert OID, version, validity window). It then restricts SAN entries to local-only scopes by default — `localhost`, `*.localhost`, `*.dev.localhost`, `*.dev.internal`, `host.docker.internal`, `host.containers.internal`, IPv4 loopback / RFC1918 / link-local, IPv6 loopback / unique-local / link-local. A cert with SAN entries outside that set is rejected.
- If validation passes, the host shows a modal consent prompt before adding the cert to the platform trust store. That consent is recorded once per host, not per certificate — accepting it covers subsequent container pushes too. Any OS-level authorization the platform requires still applies on top: on macOS the keychain may prompt when trust settings change, while on Windows and Linux the import is non-interactive. The cert lands in the OS trust surfaces only — the .NET root store on Linux, the login keychain's policy settings on macOS, CurrentUser/Root on Windows — never in `CurrentUser/My`, the keychain's identity slot, or the .NET store's `my/` directory. The host has nothing keyed by this thumbprint that contains a private key.
- If validation passes, the host shows a modal consent prompt before adding the cert to the platform trust store. It has three outcomes: **Trust** accepts and is recorded once per host, not per certificate, so subsequent container pushes are covered too; **Never Trust Container Certificates** records a refusal for this host and stops the prompting entirely; **Cancel** (or Escape) skips this one certificate and asks again next time, recording nothing — so a stray keystroke can't switch the feature off for good. Either recorded answer can be undone with **Dev Certs: Reset Container Certificate Consent**. The host-wide granularity is deliberate: unless a container bakes its dev cert into the image or mounts a volume for `~/.dotnet/corefx/cryptography/x509stores/my/`, it mints a fresh certificate on every rebuild, so a per-certificate prompt would fire on every rebuild. Any OS-level authorization the platform requires still applies on top: on macOS the keychain may prompt when trust settings change, while on Windows and Linux the import is non-interactive. The cert lands in the OS trust surfaces only — the .NET root store on Linux, the login keychain's policy settings on macOS, CurrentUser/Root on Windows — never in `CurrentUser/My`, the keychain's identity slot, or the .NET store's `my/` directory. The host has nothing keyed by this thumbprint that contains a private key.

To allow SAN entries that aren't local (rare; security-sensitive — the cert will be trusted by your host browser for the listed names), opt in explicitly:

Expand Down Expand Up @@ -520,11 +521,11 @@ src/
primitives) and dotnet (dev-certs pass-through)
paths.ts .NET store and OpenSSL trust directory paths
certName.ts userCertificates[].name pattern and guard
cert/rehash.ts Pure TypeScript c_rehash (OpenSSL canonical subject hash)
logger.ts Pluggable logging (loggerVscode.ts binds the output channel)

vscode-ui-extension/ VS Code host extension (extensionKind: ui)
src/
cert/, platform/ Re-export shims over the canonical copies in shared/
certProvider.ts Serves cert material to the workspace extension
containerCertAccept.ts Validates and trusts container-pushed certs

Expand All @@ -535,7 +536,6 @@ src/
containerCertPush.ts Reverse sync: scans for and pushes the container's cert
defaultKestrelDebugProvider.ts Injects the Kestrel default-cert env vars into
resolved coreclr debug configurations
util/rehash.ts Pure TypeScript c_rehash (OpenSSL subject hash computation)
util/destinations.ts extraCertDestinations parsing
util/upmap.ts V2 -> V3 cert material wire-contract upmap

Expand Down
18 changes: 1 addition & 17 deletions src/shared/src/backends/select.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { DotnetBackend } from "./dotnet";
import { NativeBackend } from "./native";
import type { Backend, BackendKind, BackendMode } from "./types";
import type { Backend, BackendMode } from "./types";

/**
* Resolve a `hostCertGenerator` choice (possibly `auto`) into a concrete
Expand Down Expand Up @@ -30,19 +30,3 @@ async function autoSelect(): Promise<Backend> {
}
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<BackendKind> {
if (process.platform !== "darwin") return "native";
const available =
dotnetAvailable ?? (await new DotnetBackend().isAvailable());
return available ? "dotnet" : "native";
}
21 changes: 19 additions & 2 deletions src/shared/src/cert/generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,12 +210,29 @@ function defaultEcHash(curve: string): string {
}
}

function generateSerialNumber(): string {
/**
* 16-byte positive serial number, hex-encoded.
*
* Exported for testing: the guarantee below is probabilistic (a bad leading
* byte turns up about once in 128), so pinning it needs thousands of samples,
* and routing those through `generateCertificate` would mean thousands of RSA
* keygens. The production caller is `generateCertificate`, just below.
*/
export 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)) {
// Reject a zero leading byte, not just an all-zero serial. Clearing the
// high bit keeps the DER INTEGER positive, but a resulting 0x00 leading
// byte is retained on the wire as sign padding (`02 10 00 b5 ...`) —
// correct, yet every textual readback drops it, so the serial then looks
// like a 15-byte value starting at or above 0x80. Requiring 0x01..0x7f
// yields a serial that is positive, non-zero, and minimally encoded, with
// no padding byte for downstream code to reason about. Rejection (rather
// than masking a 1 in) keeps the remaining bits uniform; five attempts
// leaves a (1/128)^5 failure chance.
if (bytes[0] !== 0) {
return bytes.toString("hex");
}
}
Expand Down
24 changes: 7 additions & 17 deletions src/shared/src/cert/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,16 +47,16 @@ export class CertManager {

/**
* Generate a new dev cert and save it to the platform store.
* If force is true, removes existing certs first.
*
* Additive by design: a pre-existing dev cert in the store is left alone.
* `findExistingDevCert` / `selectBestDevCert` pick the winner by version
* then expiry, so a superseded cert stops being selected without anyone
* having to delete it — and nothing here can revoke a cert some other
* flow (or the user) deliberately trusted.
*/
async generate(force: boolean = false): Promise<void> {
async generate(): Promise<void> {
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(
Expand Down Expand Up @@ -227,16 +227,6 @@ export class CertManager {
this.currentCert = null;
}

/**
* Remove all dev certificates from the platform store.
*/
async clean(): Promise<void> {
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).
*/
Expand Down
Loading
Loading