fix(serve): let channel workers reach a concrete bind, and drive the trust

diagnostic off measured OpenSSL behaviour (R2-4, R2-21)

R2-4 — a daemon bound to a concrete interface handed every worker a
`QWEN_DAEMON_URL` the worker's own validator rejected, so the documented
LAN flow passed every boot check and then failed in each worker. The
review's first option (rewrite the URL to loopback) does not hold:
`server.listen(port, hostname)` binds that interface ONLY, and dialling
127.0.0.1 against such a bind is ECONNREFUSED (measured on this host).
The worker therefore keeps dialling the bound address, and the validator
widens from "loopback" to "loopback or an address on this host" — which
keeps the daemon token off the wire exactly as the loopback rule did,
since traffic to an own interface address never leaves the machine. A
bind we cannot certify as local (a DNS name, a literal on no interface)
is now refused at boot by `assertChannelWorkerDaemonUrlIsLocal` instead
of restart-looping every worker with /health green.

R2-21 — the five measured entrances, each closed locally (the structural
rewrite is a separate change):

1. The SAN check and leaf identity now read the loader-framed
   `servingChain[0]`, not the loose parser's `chain[0]`; the two
   disagree when a predecessor's BEGIN marker is absorbed into a
   comment, and the diagnostic judged a certificate never served.
2. A depth-0 server-purpose check on the serving leaf: keyUsage without
   digitalSignature/keyEncipherment/keyAgreement, or an EKU without
   serverAuth, both fail INVALID_PURPOSE (both measured).
3. The issuer-capability check reads extendedKeyUsage too — an EKU on a
   chain member constrains everything below it (measured with a control
   arm: the EKU-free twin authorizes).
4. `pathLenConstraint` is modelled: a pathlen:0 root over an
   intermediate fails PATH_LENGTH_EXCEEDED (measured).
5. The issuer walk prefers a date-valid candidate over an expired
   same-subject twin, killing a false CERT_HAS_EXPIRED alarm against a
   bundle that authorizes (measured).

Suggestions in the same pass: name the issuer's missing keyCertSign
instead of the generic unanchored advice that sends operators to
re-point a CA already provided (measured: checkIssued false, verify
true, handshake "key usage does not include certificate signing"); pin
the PEM scan continuing past a non-certificate block, the operator-
supplied expired chain member, and the v3-no-basicConstraints
intermediate; pin the minted-bundle set's lifetime through a named
`cleanupMintedWorkerCaBundleDirs`; and correct the rotation overclaim in
docs and the `tlsCaCertPath` JSDoc — a fullchain renewed under the same
carried root still verifies (measured), only an anchor that rotates out
breaks the workers.

Every fix is mutation-verified: reverting each one turns its own test
red. Affected suites 516/516 green; the single `tsc -p packages/cli`
error is pre-existing (workspace-service/index.ts, identical before and
after).

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
qqqysandClaude Opus 5 committed 2026-08-19 20:24:57 +08:00
1 parent c9d3ed4880
commit 22ca724ee3
9 files changed
+1095 -18

No files matched your search

+1 -1
View File
@@ -378,7 +378,7 @@ Notes:
- **TLS is orthogonal to auth** — HTTPS encrypts the transport; the bearer token still gates every API route. Non-loopback binds require a token with or without TLS.
- **Scope is TLS termination only** — no auto-generation, no ACME / Let's Encrypt. This is a LAN / dev convenience; for internet-facing deployments terminate TLS at a reverse proxy (see the threat model below).
- **Channel workers dial the daemon back over `https://`** — so they need to trust the serving certificate too. A self-signed cert (or a fullchain that carries its own root) needs nothing: the daemon injects it into each worker's `NODE_EXTRA_CA_CERTS`. The mkcert flow above is **CA-issued**, so the leaf alone cannot anchor the chain — export `NODE_EXTRA_CA_CERTS="$(mkcert -CAROOT)/rootCA.pem"` in the daemon's launch environment before starting with `--channel`. An operator-set value is _merged_ with the daemon cert, not replaced. Without it the daemon boots green while every channel worker restart-loops on `UNABLE_TO_VERIFY_LEAF_SIGNATURE`; the daemon log names the gap at boot.
- **Rotating `--tls-cert` in place needs a daemon restart** — the daemon serves the bytes it read at boot, so until it restarts, respawned workers can load newer contents than the daemon presents. Their handshakes then fail **when the daemon cert is its own trust anchor** (self-signed, or a fullchain carrying its own root). With an operator `NODE_EXTRA_CA_CERTS` CA set — the mkcert flow above — that CA anchors both generations, so respawned workers still verify the old leaf the daemon is still serving; only the serving side lags until the restart.
- **Rotating `--tls-cert` in place needs a daemon restart** — the daemon serves the bytes it read at boot, so until it restarts, respawned workers can load newer contents than the daemon presents. Their handshakes fail exactly when **the trust anchor rotates out with the certificate** — always for a self-signed cert, since the leaf is its own anchor (`DEPTH_ZERO_SELF_SIGNED_CERT`), and for a fullchain only if its root is replaced too (`SELF_SIGNED_CERT_IN_CHAIN`). Rotating a fullchain to a renewed leaf under the **same** carried root keeps verifying: that root still anchors the old leaf the daemon is still serving. With an operator `NODE_EXTRA_CA_CERTS` CA set — the mkcert flow above — that CA anchors both generations for the same reason; only the serving side lags until the restart.
## CLI flags
@@ -1,4 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { networkInterfaces } from 'node:os';
const mockCanonicalizeWorkspace = vi.hoisted(() => vi.fn((p: string) => p));
const mockLoadChannelsConfig = vi.hoisted(() => vi.fn());
@@ -1395,6 +1396,56 @@ describe('runChannelDaemonWorker', () => {
});
});
// R2-4: a daemon bound to a concrete interface listens on that socket only
// — loopback is NOT bound, so rewriting the worker URL to `127.0.0.1` would
// trade this validator's rejection for `ECONNREFUSED`. The worker dials the
// bound address instead, and an own-interface address keeps the daemon
// token on this host exactly as loopback does. Without this the documented
// LAN flow (`qwen serve --hostname <lan-ip> --tls-cert … --channel …`)
// passes every boot check and then throws in every worker: the first one
// exits the daemon, later ones restart-loop with /health green.
it("accepts a daemon URL bound to one of this host's own interfaces", async () => {
const ownAddress = Object.values(networkInterfaces())
.flatMap((entries) => entries ?? [])
.find((entry) => entry.family === 'IPv4' && !entry.internal)?.address;
// A machine with no non-loopback IPv4 interface cannot exercise this.
if (!ownAddress) return;
const sdk = createSdk();
mockLoadChannelsConfig.mockReturnValueOnce({
telegram: { type: 'telegram' },
});
mockParseConfiguredChannels.mockResolvedValueOnce([parsedTelegram]);
await runChannelDaemonWorker({
daemonUrl: `https://${ownAddress}:4170`,
workspace: '/workspace',
selection: { mode: 'all' },
loadDaemonSdk: async () => sdk,
});
expect(sdk.DaemonClient).toHaveBeenCalledWith({
baseUrl: `https://${ownAddress}:4170`,
});
});
// The widening is to THIS host's addresses, not to routable addresses in
// general: a literal that belongs to no local interface stays rejected, so
// the daemon token still cannot be aimed off-box.
it('still rejects a routable address that is not on this host', async () => {
const sdk = createSdk();
await expect(
runChannelDaemonWorker({
daemonUrl: 'https://203.0.113.7:4170',
workspace: '/workspace',
selection: { mode: 'names', names: ['telegram'] },
loadDaemonSdk: async () => sdk,
}),
).rejects.toThrow('QWEN_DAEMON_URL must use an http(s) loopback URL.');
expect(sdk.DaemonClient).not.toHaveBeenCalled();
});
it('fails fast when no channels are configured', async () => {
const sdk = createSdk();
mockLoadChannelsConfig.mockReturnValueOnce({});
@@ -65,6 +65,7 @@ import {
type ChannelStartupReportMessage,
} from '../../serve/channel-worker-startup-ipc.js';
import { isLoopbackBind } from '../../serve/loopback-binds.js';
import { isOwnInterfaceAddress } from '../../serve/local-bind-addresses.js';
import { ChannelLoopMcpWorkerHost } from '../../serve/channel-loop-mcp-ipc.js';
import { writeStderrLine, writeStdoutLine } from '../../utils/stdioHelpers.js';
import { resolveProxyUrl } from './proxy.js';
@@ -324,9 +325,20 @@ function validateDaemonWorkerUrl(daemonUrl: string): void {
} catch {
throw new Error(`${QWEN_DAEMON_URL_ENV} must be a valid URL.`);
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
throw new Error(`${QWEN_DAEMON_URL_ENV} must use an http(s) loopback URL.`);
}
// Loopback is the common case and the cheapest to certify. A daemon bound
// to a concrete interface (`--hostname 192.168.1.100`) does NOT listen on
// loopback, so its workers must dial the bound address itself — rewriting
// to `127.0.0.1` would only trade this rejection for `ECONNREFUSED`. An
// own-interface address keeps the daemon token on this host just as
// loopback does, which is the property the rule protects; anything else
// (a routable third-party host, a DNS name we would have to resolve to
// find out) is still refused.
if (
(parsed.protocol !== 'http:' && parsed.protocol !== 'https:') ||
!isLoopbackBind(parsed.hostname)
!isLoopbackBind(parsed.hostname) &&
!isOwnInterfaceAddress(parsed.hostname)
) {
throw new Error(`${QWEN_DAEMON_URL_ENV} must use an http(s) loopback URL.`);
}
@@ -8,6 +8,7 @@ import {
ChannelWorkerStartupError,
createChannelWorkerSupervisor,
type ChannelWorkerChild,
cleanupMintedWorkerCaBundleDirs,
} from './channel-worker-supervisor.js';
import { isChannelWorkerPromptAuthorized } from './channel-worker-prompt-authorization.js';
import { CHANNEL_WORKER_HEARTBEAT_INTERVAL_MS } from './channel-worker-env.js';
@@ -782,6 +783,56 @@ describe('createChannelWorkerSupervisor', () => {
fs.rmSync(dir, { recursive: true, force: true });
});
// The exit hook and the supersede path each carry one bookkeeping statement
// that no test reached: deleting `mintedWorkerCaBundleDirs.delete(dir)` on
// supersede, or the hook's `.clear()`, both shipped green. Their cost is a
// 0700 directory leaked per rotation for the daemon's whole lifetime, so
// pin the set's lifetime through what the hook actually cleans.
it('cleans only the live bundle dir at exit, then forgets every dir', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'qwen-ca-minted-'));
const operatorCa = path.join(dir, 'operator.pem');
const daemonCa = path.join(dir, 'daemon.pem');
fs.writeFileSync(operatorCa, OPERATOR_CA_PEM);
fs.writeFileSync(daemonCa, DAEMON_CERT_PEM);
const bundlePaths: string[] = [];
for (let round = 0; round < 3; round += 1) {
fs.writeFileSync(
operatorCa,
round % 2 === 0 ? DAEMON_CERT_PEM : OPERATOR_CA_PEM,
);
const { env } = await startWorkerWithCaPaths(daemonCa, operatorCa);
bundlePaths.push(env['NODE_EXTRA_CA_CERTS']!);
}
const bundleDirs = bundlePaths.map((bundle) => path.dirname(bundle));
// The superseded dirs were already removed on supersede. Re-create them
// as sentinels: if the supersede path had stopped UNTRACKING them, the
// hook below would delete these again.
for (const superseded of bundleDirs.slice(0, -1)) {
fs.mkdirSync(superseded, { recursive: true });
fs.writeFileSync(path.join(superseded, 'sentinel'), 'x');
}
cleanupMintedWorkerCaBundleDirs();
for (const superseded of bundleDirs.slice(0, -1)) {
expect(fs.existsSync(superseded)).toBe(true);
}
expect(fs.existsSync(bundleDirs.at(-1)!)).toBe(false);
// And the set is emptied: a second run has nothing left to clean, so a
// re-created live dir survives it.
fs.mkdirSync(bundleDirs.at(-1)!, { recursive: true });
cleanupMintedWorkerCaBundleDirs();
expect(fs.existsSync(bundleDirs.at(-1)!)).toBe(true);
for (const bundleDir of bundleDirs) {
fs.rmSync(bundleDir, { recursive: true, force: true });
}
fs.rmSync(dir, { recursive: true, force: true });
});
it('warns again when the SAME path pair fails a different way', async () => {
// R4-5(a): keying the dedup on the paths alone meant the first reason was
// the only one ever printed. An operator CA that is missing before a mount
@@ -234,11 +234,16 @@ export interface CreateChannelWorkerSupervisorOptions {
* it at every (re)spawn — while the daemon keeps serving the bytes it read
* at boot. Rotating this file in place without restarting the daemon
* therefore leaves respawned workers trusting the NEW contents against the
* OLD served cert, and they restart-loop until the daemon restarts. An
* operator CA gives no cover: `resolveWorkerCaCertPath` stamps BOTH sources,
* so rotating this file in place invalidates the merged bundle and rebuilds
* it from the NEW contents — the same restart loop. Either way, rotating
* `--tls-cert` requires a daemon restart; see the HTTPS / TLS notes in
* OLD served cert. Whether that breaks them depends on what rotated with
* it: the workers restart-loop exactly when the TRUST ANCHOR rotated out
* too — always for a self-signed cert, whose leaf is its own anchor
* (DEPTH_ZERO_SELF_SIGNED_CERT), and for a fullchain only when its root was
* replaced as well (SELF_SIGNED_CERT_IN_CHAIN). A fullchain rotated to a
* renewed leaf under the SAME carried root still verifies (measured), as
* does an operator CA that anchors both generations — though
* `resolveWorkerCaCertPath` stamps BOTH sources, so that rotation does
* invalidate and rebuild the merged bundle. Either way the serving side
* lags until the daemon restarts; see the HTTPS / TLS notes in
* docs/users/qwen-serve.md.
*/
tlsCaCertPath?: string;
@@ -477,6 +482,22 @@ function warnWorkerCaMergeFallback(
const mintedWorkerCaBundleDirs = new Set<string>();
let workerCaBundleExitHookRegistered = false;
/**
* Remove every minted worker CA bundle directory this process still owns, and
* forget them.
*
* Registered as the `exit` hook, and named so the set's lifetime is testable:
* without the forget, a regression that stops untracking superseded bundles
* (or that drops this clear) leaks a 0700 directory per rotation for the
* daemon's whole life, and nothing observable turns red.
*/
export function cleanupMintedWorkerCaBundleDirs(): void {
for (const minted of mintedWorkerCaBundleDirs) {
cleanupWorkerCaBundleDir(minted);
}
mintedWorkerCaBundleDirs.clear();
}
function cleanupWorkerCaBundleDir(dir: string): void {
try {
fs.rmSync(dir, { recursive: true, force: true });
@@ -499,12 +520,7 @@ function writeMergedWorkerCaBundle(contents: string): string {
mintedWorkerCaBundleDirs.add(dir);
if (!workerCaBundleExitHookRegistered) {
workerCaBundleExitHookRegistered = true;
process.once('exit', () => {
for (const minted of mintedWorkerCaBundleDirs) {
cleanupWorkerCaBundleDir(minted);
}
mintedWorkerCaBundleDirs.clear();
});
process.once('exit', cleanupMintedWorkerCaBundleDirs);
}
return bundlePath;
}
@@ -0,0 +1,53 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { networkInterfaces } from 'node:os';
/**
* Strip the URL brackets and the RFC 6874 zone identifier from an IPv6
* literal so it can be compared against `os.networkInterfaces()`, which
* reports the bare address and carries the scope separately.
*/
function bareAddress(hostname: string): string {
const unbracketed =
hostname.startsWith('[') && hostname.endsWith(']')
? hostname.slice(1, -1)
: hostname;
const decoded = unbracketed.replace(/%25/gi, '%');
const zoneAt = decoded.indexOf('%');
return (zoneAt === -1 ? decoded : decoded.slice(0, zoneAt)).toLowerCase();
}
/**
* Whether `hostname` is an IP literal assigned to one of this machine's own
* interfaces — that is, an address the local host answers on.
*
* Channel workers are always spawned on the daemon's own machine, but a
* daemon bound to a concrete interface (`--hostname 192.168.1.100`) listens
* on that socket ONLY: loopback is not bound, so rewriting the worker's URL
* to `127.0.0.1` would trade a rejected URL for `ECONNREFUSED`. The worker
* therefore dials the address the daemon actually bound, and this is how it
* certifies that doing so keeps the daemon token on the local host — the
* property the loopback rule existed to guarantee. Traffic to an own
* interface address never reaches the wire; the kernel routes it back up the
* stack.
*
* Literals only, on purpose. Resolving a DNS name here would put a lookup —
* and whatever answers it — on the worker's startup path, so a non-literal
* bind is refused at boot instead (see the channel bind guard in
* `run-qwen-serve`), which fails loudly once rather than restart-looping
* every worker.
*/
export function isOwnInterfaceAddress(hostname: string): boolean {
const target = bareAddress(hostname);
if (target === '') return false;
for (const entries of Object.values(networkInterfaces())) {
for (const entry of entries ?? []) {
if (entry.address.toLowerCase() === target) return true;
}
}
return false;
}
@@ -150,6 +150,21 @@ describe('extractCertificateBlocks', () => {
expect(extractCertificateBlocks(combined)).toEqual([ROOT_PEM.trim()]);
});
// The key block above is LAST in every fixture that carries one — here and
// in the supervisor's combined-PEM test — so replacing the scan's `continue`
// with a `break` shipped green while silently dropping every certificate
// behind the key. Node's loader keeps them: measured, `rootA + PRIVATE KEY
// + rootB` as NODE_EXTRA_CA_CERTS authorizes where `rootA` alone fails
// UNABLE_TO_VERIFY_LEAF_SIGNATURE. cert+key+chain is an ordinary operator
// file shape, so the dropped certs would be the ones anchoring the workers.
it('keeps certificates that follow a non-certificate block', () => {
expect(
extractCertificateBlocks(
`${ROOT_PEM}-----BEGIN PRIVATE KEY-----\nQUJD\n-----END PRIVATE KEY-----\n${LEAF_PEM}`,
),
).toEqual([ROOT_PEM.trim(), LEAF_PEM.trim()]);
});
it('normalizes CRLF and marker/body padding to canonical PEM', () => {
// Oracle: authorized=true for both. The bundle this feeds is written to
// disk, so the output has to be canonical whatever the input looked like.
@@ -8,6 +8,7 @@ import * as os from 'node:os';
import * as path from 'node:path';
import * as fs from 'node:fs';
import { X509Certificate } from 'node:crypto';
import { extractCertificateBlocks } from './pem-certificate-blocks.js';
import { createServer } from 'node:http';
import * as https from 'node:https';
import type { AddressInfo } from 'node:net';
@@ -16,6 +17,7 @@ import express from 'express';
import {
createLazyBridgeProxy,
extractContextFilename,
assertChannelWorkerDaemonUrlIsLocal,
formatChannelWorkerDaemonUrl,
describeWorkerTlsTrustGaps,
InvalidPolicyConfigError,
@@ -1146,6 +1148,58 @@ describe('formatChannelWorkerDaemonUrl', () => {
});
});
// R2-4: a concrete non-loopback bind listens on THAT socket only, so the
// worker URL keeps the bound address and loopback is not a fallback —
// measured, `dial 127.0.0.1` against such a bind is `ECONNREFUSED`. What the
// worker cannot do is reach a host that is not this one, and a bind we cannot
// certify as local (a DNS name; a literal on no interface) used to pass every
// boot check and then throw inside each worker — the first failure exits the
// daemon, later channels restart-loop with /health green.
describe('assertChannelWorkerDaemonUrlIsLocal', () => {
it('accepts loopback and wildcard-rewritten worker URLs', () => {
for (const host of ['', '0.0.0.0', '::', '[::]', '127.0.0.1', '::1']) {
expect(() =>
assertChannelWorkerDaemonUrlIsLocal(
formatChannelWorkerDaemonUrl(host, 4170, true),
host,
),
).not.toThrow();
}
});
it("accepts a concrete bind on one of this host's own interfaces", () => {
const ownAddress = Object.values(os.networkInterfaces())
.flatMap((entries) => entries ?? [])
.find((entry) => entry.family === 'IPv4' && !entry.internal)?.address;
// A machine with no non-loopback IPv4 interface cannot exercise this.
if (!ownAddress) return;
expect(() =>
assertChannelWorkerDaemonUrlIsLocal(
formatChannelWorkerDaemonUrl(ownAddress, 4170, true),
ownAddress,
),
).not.toThrow();
});
it('refuses a bind this host cannot answer, naming the hostname', () => {
expect(() =>
assertChannelWorkerDaemonUrlIsLocal(
formatChannelWorkerDaemonUrl('203.0.113.7', 4170, true),
'203.0.113.7',
),
).toThrow(/Channels cannot start: --hostname "203\.0\.113\.7"/);
});
it('refuses a DNS-name bind — resolving it is not on the worker startup path', () => {
expect(() =>
assertChannelWorkerDaemonUrlIsLocal(
formatChannelWorkerDaemonUrl('daemon.internal', 4170, true),
'daemon.internal',
),
).toThrow(/does not name an address on this host/);
});
});
// A CA-issued leaf, the shape the documented `mkcert` flow produces: usable
// as a serving cert, useless as a trust anchor. Not a real secret.
const TEST_TLS_CERT_CA_ISSUED = `-----BEGIN CERTIFICATE-----
@@ -1511,6 +1565,368 @@ E6mwm4nEjVj2B5cT62Pv
-----END CERTIFICATE-----
`;
// R2-21 entrance 2: a self-signed serving certificate whose keyUsage carries
// keyCertSign ONLY — no digitalSignature/keyEncipherment, so OpenSSL's
// depth-0 server-purpose check rejects it. Measured with a real handshake on
// Node 22 / OpenSSL 3: INVALID_PURPOSE, while the daemon boots and /health
// answers. 100-year validity, matching the other fixtures here.
const TEST_TLS_CERT_LEAF_KEY_CERT_SIGN_ONLY = `-----BEGIN CERTIFICATE-----
MIIDNzCCAh+gAwIBAgIUNIJ2wV/ZsEa8sTC6MCel5gZJUscwDQYJKoZIhvcNAQEL
BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MCAXDTI2MDgxOTEyMDYxN1oYDzIxMjYw
NzI2MTIwNjE3WjAUMRIwEAYDVQQDDAlsb2NhbGhvc3QwggEiMA0GCSqGSIb3DQEB
AQUAA4IBDwAwggEKAoIBAQDAlFV1sU+lJzmi8yGqnb9ZNiIDo3hawb9O9AjAvH35
T1HL6DUIp7MKQdtdbiiPaR424Fv6cE9hhFQEQ35jr7XRDLNhEUdlS6Eg/YI4f+WY
jvsAyKJhivILD7sqSFuRjSn7l5KDq2eupV8zbSF2plGSvRS28TapvQ1xt6QF0S/P
rpNmtAkfr43s4HBcAJpFwwW8nVAgCKf0LpYusNxMNBOYOt6JfZPNangkJUnHiw9+
6x94x0lgnfLbXb7Ou/y9tazpklKugYLTso6ziR7lqk4KOpu+KgJkOK3sN2A3cYKi
7STqRoTEbux+iBkIOLL301oC67KXevP5mvZo5ePdlRRFAgMBAAGjfzB9MB0GA1Ud
DgQWBBS2r0YTbKq0V1LRNWMTY9o5Nj8HFDAfBgNVHSMEGDAWgBS2r0YTbKq0V1LR
NWMTY9o5Nj8HFDAPBgNVHRMBAf8EBTADAQH/MBoGA1UdEQQTMBGHBH8AAAGCCWxv
Y2FsaG9zdDAOBgNVHQ8BAf8EBAMCAgQwDQYJKoZIhvcNAQELBQADggEBAIKyoAw6
RQD4rf4lDuKb4NnbEseyWKq2KinL1K/B7AFcMU9HT+pA8i8J4AZyT9sO6BLGS8jC
DpiUj7JHbwVtsRnU2YbbIwfPUBAQpKaa72a8TLjR7QnLOi7V7EFKma666DbgDILr
II4ZNeKywmlVtSzBhXgJ0+l+WFTHKLGj0fGioIGz+XVft2bFacsHwCLeJ7t2ZIEr
t4BmR6pm5o1HgTncnctrhRv6Y0g34HUo+5AVrUzOLwzZbuAiqHKiMj0j/WuqW4sG
IGojgA5tGBNUZs0PN8krIr/Yl18AMqmhesqwLWC3I7fCePWIl5QEhvMwhc7XmG2m
9GoQNohERjArW0Q=
-----END CERTIFICATE-----
`;
// R2-21 entrance 2: the same shape via extendedKeyUsage — `clientAuth` only.
// Measured: INVALID_PURPOSE; the serverAuth twin authorizes.
const TEST_TLS_CERT_LEAF_EKU_CLIENT_ONLY = `-----BEGIN CERTIFICATE-----
MIIDPjCCAiagAwIBAgIUc/xqdx2K3+AzsAX4bQw4u8DeSo0wDQYJKoZIhvcNAQEL
BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MCAXDTI2MDgxOTEyMDYxN1oYDzIxMjYw
NzI2MTIwNjE3WjAUMRIwEAYDVQQDDAlsb2NhbGhvc3QwggEiMA0GCSqGSIb3DQEB
AQUAA4IBDwAwggEKAoIBAQC5tQXe8Jg6y7KQd49lub0DB5X+V299mwDnkuhKib9b
RLas1kkz2oXlohK07b6Z0LA9LYs2Z3KFm5T4Y8BV2Vro7iXwBmLmdsRk6UbCkuSO
UyAgnJ/Tp5+UbhSySyZOaaPyJPY71BWcTMIBs7kpTc0mkaz+zu95/GuGboVybnPm
vYZFSkagVJwU8ft5U67l3sSEsG22letoC4ZbSAD37L+dvG8xvDJNDQFYfLAUuHuA
8uJfxwUBU9EsLNdZn5xc7HK3z/axo/mEJ5xr1WQQ/uGEaj7waL3i7g2Z33noRbsN
/hel0v/vg618f8IcKhHlN8ZehiZSDfkNtnscQ94hO9ylAgMBAAGjgYUwgYIwHQYD
VR0OBBYEFAvo9mUzTr04YDMVBEASwoxWwm+EMB8GA1UdIwQYMBaAFAvo9mUzTr04
YDMVBEASwoxWwm+EMA8GA1UdEwEB/wQFMAMBAf8wGgYDVR0RBBMwEYcEfwAAAYIJ
bG9jYWxob3N0MBMGA1UdJQQMMAoGCCsGAQUFBwMCMA0GCSqGSIb3DQEBCwUAA4IB
AQCc6jfV2II8aRHHkuA188GNvd9vcWxDJnT17pMA7Yym4Z1RKm1Wc82SZSX8lXaN
yTTRzJHX8y1w1ced+DCEuPsCgW4MdpBH0PDloEAm4prHYfvsx6e41PGuJFmD+4SN
YEACj5vI+GYNH7IaZbDQZihCS5dOWh5EPC/X6609eFJIpJpovVBlbtMe0EZvEwEE
JfJACGTTaiIx/glNGFaSGnrEIpNSEfnskn048voo2n1eiL9JoZSpDNt1KxQHHW6i
IzBDXqVVk0ZWG8rQ6Mx0/rPKxexDPcb+AvnCK9XYHGFLQhKXQmgm4BXmHHLRO8HZ
TQXj2eK21IeHSS1xct8ttacZ
-----END CERTIFICATE-----
`;
// R2-21 entrance 3: leaf + intermediate + root, where the intermediate is a
// real CA (CA:TRUE, keyCertSign) but carries extendedKeyUsage=clientAuth. An
// EKU on a chain member constrains everything below it. Measured with a
// control arm: this chain fails INVALID_PURPOSE, the same chain re-issued
// without the intermediate EKU authorizes.
const TEST_TLS_CERT_FULLCHAIN_EKU_INTERMEDIATE = `-----BEGIN CERTIFICATE-----
MIIDNTCCAh2gAwIBAgIUYECIQwBb5CeB1p0J5CMpI1/239EwDQYJKoZIhvcNAQEL
BQAwJTEjMCEGA1UEAwwacXdlbiBla3UgdGVzdCBpbnRlcm1lZGlhdGUwIBcNMjYw
ODE5MTIwNjE4WhgPMjEyNjA3MjYxMjA2MThaMBQxEjAQBgNVBAMMCWxvY2FsaG9z
dDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAOT/f5K/xvkl7GtFlT9S
Pe1kS3x+JRQ3TlWZbMzUDDd5LAsdvesL2ZhZqiFuxWsfROwZm1s4XmBGjR5xpBa8
OBQXMtF+lbLAzU/GEUn4jOcX65J2AhGXlInyAbCnh4RspeqkbP1JAH5kpx0YoKK9
G0Tzstsv2bFmsnWWtpMNgDob6RpuU3yGSTYt28wwAOmqQ5yGh/mAH4kOYCa2V6H6
BsbSXgvH2U81o53D2xwCqcWBVJZgiSCzTHwEVT8IeOHor/q8/cx+/31u/go+z2Wp
XFu4IY0WlKCH3RIJU6HVX3Qa7mQJq3AVDE3RIf3LYoK1CIBqP+P9ZfMNhZZHJFpY
XBkCAwEAAaNsMGowGgYDVR0RBBMwEYcEfwAAAYIJbG9jYWxob3N0MAwGA1UdEwEB
/wQCMAAwHQYDVR0OBBYEFI7k1v0l0DYZsA0LPu/+VdOlrhy1MB8GA1UdIwQYMBaA
FBB74Tc7zsGfycsrI9Qi7tOn3Qi1MA0GCSqGSIb3DQEBCwUAA4IBAQCgFx82YTgJ
1FOokobK9nPx1zTjNDVAxGLlFVwyODD5u5mWwNd/Kx/5V9OTN4rqJJDsIdPPwv+D
cmC6BYtDcBuqjhU7eMByv3vJMpeiJpCBSLrhFr8PJZ0lwovQ+hQ+1cl0fW1n7AX5
nHPj4+p9sOFbTvzxXKrC7CluZyp8uhvZK8U2fr3J9HEphMOHIPfvZVQAKDKXwo1E
aGQM3UfyoHWOCXlPXVEERVH1ib8GDSLyakhgjgojVSNrF52QcqXU64HFv7ZOMlm2
NVAXZ2flyHROV0IT0VRBL87UXOKI42VZNv5FEZFOdkK+ngmbykQCXUu/nSnkSq9h
r/l85JOTG0PA
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIDTTCCAjWgAwIBAgIUauW/jXR3S/dqSAmBGesaIar31uowDQYJKoZIhvcNAQEL
BQAwIDEeMBwGA1UEAwwVcXdlbiBla3UgdGVzdCByb290IENBMCAXDTI2MDgxOTEy
MDYxOFoYDzIxMjYwNzI2MTIwNjE4WjAlMSMwIQYDVQQDDBpxd2VuIGVrdSB0ZXN0
IGludGVybWVkaWF0ZTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAKfb
T1DxEdyJkY8QqE8VdjQlgMfeyIhxsWP0Qr44xlcPsRfAG4v+M1n0DIUM/I39V6Wo
A+SWkiMbS8Fp6q1II6+AJqBvX6gKJP+F8IsFQPg3HPoI0XypclzBofPZP6cic9Q1
OzeojHriZygV2RxN70FCLQ6NTQ5kXDSqzMguj0ESC47nBiKuaqk46F0y6Oa4pvnx
rLOABZIQMoWquD4FcBC8AaJub7S65eU40j7VbZ3J7PNl14eIxHQOfcwQMNVQXXI9
7VAygcYIYNa2jvCozauvlkxZ0t1rNxICt/Xzpo6BbUDYkRzHXlHqOj4Mdfb8HcP2
VDut7AIgN3gWbsaIL4sCAwEAAaN4MHYwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8B
Af8EBAMCAQYwEwYDVR0lBAwwCgYIKwYBBQUHAwIwHQYDVR0OBBYEFBB74Tc7zsGf
ycsrI9Qi7tOn3Qi1MB8GA1UdIwQYMBaAFBNw53hrfts4nPWzZJuL8rhNc02GMA0G
CSqGSIb3DQEBCwUAA4IBAQB+3fxqNQfhbvMcYejhVzVQd2MKd1SKH0fs8uiwthFz
UAIl7Yo3mT7hMNqItJZ2vjKLKSNQGyCgRTHBUKUBl3l0teNEpO/SnRYbSc8sljlV
pVXFIGDXUt6vrgsYlV6ZcMyZrarwor0fFb3UZ2uCM/V3s8sR15X4Of1IebWUbYuH
CM0YcTszCIZxWKcrVVA5Sh1HULTZYqM/aCQpc/aXJ0r3FGC4m4jptkzE5hWtkqE5
ypxjbizYBVF3kNElgu7LJnaB6pZ3OHmyyUnKwlHZhDvv5PBuMr3cakWhJhCcGkqS
m+fXym2jVUnI7dzkhFzd97eoG8K1uFIQ9U9MH5kqRUKE
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIDMzCCAhugAwIBAgIUQHNfFXjAx71yIHnh8JsC8e83U1EwDQYJKoZIhvcNAQEL
BQAwIDEeMBwGA1UEAwwVcXdlbiBla3UgdGVzdCByb290IENBMCAXDTI2MDgxOTEy
MDYxOFoYDzIxMjYwNzI2MTIwNjE4WjAgMR4wHAYDVQQDDBVxd2VuIGVrdSB0ZXN0
IHJvb3QgQ0EwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDct8anKcXO
8SItawnPIMjnodOkADdjFVoPrRZ5T3Yi23Wg8CgzexxREucwi8QKIt2Puugt+hyq
UXVXTkHwT++Bs4H7QPoPc3k89s32m++U0rjUes3L9MVMwN7ofkJjJmaLfdGNeGrQ
thpt6yAr8WSSEYwRAH1kKap7uwFV9r20ukWgQY015saFhT4zjzz22m5XAJpZCCaF
9Fo3x/r6emYEfoq4QUYFD/20CSjGH3pTk9hzPPnmrFjZWV/irusuxiVESf62E5ia
lCajIwmgxlaNdw5UcgAGxII09QIadPBMUjL60+IsmIDsz+AK0wctnLQkYO8jeRK6
P0LbwFPgXoq9AgMBAAGjYzBhMB0GA1UdDgQWBBQTcOd4a37bOJz1s2Sbi/K4TXNN
hjAfBgNVHSMEGDAWgBQTcOd4a37bOJz1s2Sbi/K4TXNNhjAPBgNVHRMBAf8EBTAD
AQH/MA4GA1UdDwEB/wQEAwIBBjANBgkqhkiG9w0BAQsFAAOCAQEAHiCvBvMhlEzz
ioHqkHfW/O1EY9O4Y6mxzD0pSqm3883PYU2gIbayBjKkAjfo1Rftce1wCvKoPmsk
bAqzTTtQRK65iWNrcbQgK3swtm0+/G2zKYQwELoAIsVubNKvkJQ6vpJQ/XpPSVyx
2WkisoL+8fw+1UdhW/sFTSwS93oO7tt2euN9XbNvpWjanUdfAi92xuCoTqpi5eZ2
3p7XeGQ2jp4KIhsXmtiEwY+Ra0TGucs4PoeaTfxcQItZFe3KnssALgDaUHjXxBWy
Hsm5754C76bXDiTSjh1Ouaene5sRGp3C9Pq7PgKeZC9a9zuqlTCOx9b+hEBkoeTO
X4161EJQYg==
-----END CERTIFICATE-----
`;
// R2-21 entrance 4: leaf + intermediate + a root asserting pathlen:0, which
// admits no CA below it. Measured with a real handshake: PATH_LENGTH_EXCEEDED,
// while the pathlen-free control authorizes.
const TEST_TLS_CERT_FULLCHAIN_PATHLEN0_ROOT = `-----BEGIN CERTIFICATE-----
MIIDOTCCAiGgAwIBAgIUaIEQ1LOqkGGz6D9RZaR7CWCNxNEwDQYJKoZIhvcNAQEL
BQAwKTEnMCUGA1UEAwwecXdlbiBwYXRobGVuIHRlc3QgaW50ZXJtZWRpYXRlMCAX
DTI2MDgxOTEyMTAwNloYDzIxMjYwNzI2MTIxMDA2WjAUMRIwEAYDVQQDDAlsb2Nh
bGhvc3QwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCdEoNo95RI6Spw
ZrnR266aDI53l/vXQHr4VktDJlEvga6On8a6Fq0YMMw8QSGk3DxT/dwyFuhr7kCb
jU+/qMWDR0VxVY2vxlFfxldZU67j7oXHNQeAaugUTx69ioShSti0UK3L6Ifpuclv
TWPVInKqcn4DolOGR6mxLj17sCF7PLSc0HUaMsx2uRUt1snQQNduPQ9eqy8wLBhz
zjXHFksnCMop+h8xrc1oQNJNUL0BW6LyGeVbFgUvCr2RBYDCDstRFZ5O9ctIy4Nq
ghrCjgsJZ+SQkxi30ft8QUOZybjizB2hfJPqPXpBtmk6Tctze8H446gumuXuHHWd
a6oFgSC9AgMBAAGjbDBqMBoGA1UdEQQTMBGHBH8AAAGCCWxvY2FsaG9zdDAMBgNV
HRMBAf8EAjAAMB0GA1UdDgQWBBSA7HyWNZNFna7ZJuW8sZOxUzUYazAfBgNVHSME
GDAWgBTMbwpwxfFZ2jW78cE5NSeCJU7BBTANBgkqhkiG9w0BAQsFAAOCAQEAZDku
vTmtGialqr7iOs3oCRzcG8T8UuzzPcgiKVUbtAJYVXSITVYlzmSu+fjP3ELIRKUU
pQ1vqWWipREo+TouY9241oetH80bx1bHHQfqclhpGhO33UrfMZ3DwpdaU76WQY2v
ANpXahyREK/tsViIqDluK9qcZChQkl07B9E8QdD+ejB67vRmSagdXKHWqdNieAY/
ujypArWZ6tyZPyscdriH9ERLLPFycTkVRgElKqNZL5GiD/xk4CKUGoW30oFYPSm/
GO9nMGgtd2PHHJcUfr4LGgXNq1FOt6NedgBGjPVhYLiBvQ/Yvr4kK29+EblFCn4s
OCL8PSADHc9GZKN+wg==
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIDQDCCAiigAwIBAgIUZ/0NwowgG2FivD/5cBFuIhCarDgwDQYJKoZIhvcNAQEL
BQAwJDEiMCAGA1UEAwwZcXdlbiBwYXRobGVuIHRlc3Qgcm9vdCBDQTAgFw0yNjA4
MTkxMjEwMDZaGA8yMTI2MDcyNjEyMTAwNlowKTEnMCUGA1UEAwwecXdlbiBwYXRo
bGVuIHRlc3QgaW50ZXJtZWRpYXRlMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB
CgKCAQEA/m1EwXKszdhbrd5bfJ4ObnxOyl0VXxKMoSNZ0WXYizMR/FfXeQtrP9gL
Lj3XEraAJav+jn2YgNWvtWij1zikTdlaxZj9pk64X/RyVROtD9xvMu6WSoGpeege
qNqE5Cre+3Z4su5NpmM3p6wpucti0VJ17X4iMLdU7D40Jlf2XxN8u52ByVxmtfMR
heWXlFmEai1sU21viLss8LBnzldj5Ot2dkNrtXaBpDxZU25Z3JBMk+Ff6J/R/iTa
mAycoC/Wf1fjLvv3GXpy4M1qj0W1dBAAYIZAm/9rtHHgKtDS/Lz2KcTsIT4fnOwK
ABpcNVB0eRmWElZ2e6O9O9rXRiejcQIDAQABo2MwYTAPBgNVHRMBAf8EBTADAQH/
MA4GA1UdDwEB/wQEAwIBBjAdBgNVHQ4EFgQUzG8KcMXxWdo1u/HBOTUngiVOwQUw
HwYDVR0jBBgwFoAU8MftL8wo3H1MZO6fmW/PGruEZUEwDQYJKoZIhvcNAQELBQAD
ggEBALD0xSkKdq481iUWKKtYNpfvdmlhQS3xQPySNfdmOnNo8pw1aV5A+2x0SKkm
BwaEdfIxlkb2HnuPEZQiKYpQ+B+uLSRuHzVAtsT+WyYa0n0wrC4vqVM2OXZSDovl
XvtupnfQo2R7VJs70B794dcdsCOVhVLXSoAb5RQNDpFrNL6KAkGlNOyg/Smsgk+B
4lUHN4/6urtoSuXma8bDKDULcVd4c4KDJt1p+wF2w1NsT99584FEXL02qeI++0i0
kJ8khxwRykJq8C/GGegRaEjkx/fesKM8/5rxGgw7WnjfP/4pSl4PCSnhpwYPqQId
NDCi+A5qc3qs5ed8HAEitMQoHOk=
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIDPjCCAiagAwIBAgIUSerSo/i63+VVMK/0mzvQSze5WzIwDQYJKoZIhvcNAQEL
BQAwJDEiMCAGA1UEAwwZcXdlbiBwYXRobGVuIHRlc3Qgcm9vdCBDQTAgFw0yNjA4
MTkxMjEwMDZaGA8yMTI2MDcyNjEyMTAwNlowJDEiMCAGA1UEAwwZcXdlbiBwYXRo
bGVuIHRlc3Qgcm9vdCBDQTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEB
ANSY3Wl2OHHFEK0Ryja3vONiVCj4lWRTU6p1SjvyWsI4fLxDhJEUh9BHWzm6BUku
ojfTGDVdPWU5I/iauY6oUiO1XacKq78sTMQrzJTGsQBn3QRB4IxKV4KsHkYfqry/
ijjJkopRJywYDGcieMeSLfibYqosLI9O2H382mhDhx0oe1mCGT+iNP82rs5dITJ+
FMzBTr3BSnWZ26FZ53YRYir/g7GWHQR3M3hrHa5avBTdvZphUo0WO4iLVIRKoPQ0
khNgLFTDyzzn1tz8WGgcJYm7jF8ZAcNs+ktTuJNUpPr1lLlhen4yeyD98bgVXuDT
W7yyZFzLwcDvKe160ad5KfkCAwEAAaNmMGQwHQYDVR0OBBYEFPDH7S/MKNx9TGTu
n5lvzxq7hGVBMB8GA1UdIwQYMBaAFPDH7S/MKNx9TGTun5lvzxq7hGVBMBIGA1Ud
EwEB/wQIMAYBAf8CAQAwDgYDVR0PAQH/BAQDAgEGMA0GCSqGSIb3DQEBCwUAA4IB
AQAkXJTB1eXI9bq7wTOQ4ONfTj0e5c8j0XsrStFYBsiIjyXw8HuA0nbzSh7mKRp9
A9yWUia+/iWBgX4q8MH0NrgZeC/G2EoChOswhufi4JikUwu87++w/9qHvbb6vJD9
ZlPoYAQxyrUKHVWZ7dlR/kIr64tbtlHbDA6LnUj7KptIgcnls9H86bQfkjsIgtYo
Cj8Nd/PPalwKY6taKzl3YxxYMtDHGYu9Ii3oOC7noP4Vfe0FlzgJDyS2vONHzpWJ
A8hd5POti1+al9KsyPDeINwK4+IIHsgmhyKprMK/unxE/VDT9E2DI1+hF/SUnVd3
sTeZY2cNQdSzUHwhKBoeQt1j
-----END CERTIFICATE-----
`;
// R2-21 entrance 5: a renewed root — two self-signed certificates sharing a
// subject AND a key, so both verify the leaf — with the SHORT-lived copy
// first in the bundle. The greedy first-match walk picked the expired copy
// and claimed every handshake fails CERT_HAS_EXPIRED while the merged bundle
// authorizes through the renewed one.
const TEST_TLS_CERT_FULLCHAIN_RENEWED_ROOT = `-----BEGIN CERTIFICATE-----
MIIDNDCCAhygAwIBAgIUO1dPcT+RFrKO9eAvR7RprjWBRZswDQYJKoZIhvcNAQEL
BQAwJDEiMCAGA1UEAwwZcXdlbiByZW5ld2VkIHRlc3Qgcm9vdCBDQTAgFw0yNjA4
MTkxMjEyMjBaGA8yMTI2MDcyNjEyMTIyMFowFDESMBAGA1UEAwwJbG9jYWxob3N0
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAvXioBNIbk+BxfkQYG51E
4VpH2dEvI7CSVtDb9hCr6ejdrsOmIMuHgQoFNrj33WGVovXwqoUsy74j666mXwIU
E4uXM2kObg5rgmdT010CtqVk10NQ2zVP8HfocwvhfsGc+pXxIG3llLsEfEE4Sz6H
opapxI0J0KVngEiJtQ1pQ7ETmowE1ox9pHXg+uWNiWOfdSOffoEBdlbckl/LpPHq
JMzB7bDvKKl7Oj1/0mQjO/vVkPeFsyO0wUSzbo7RrEYfCq9ehZIHHkhTCDLZyy5x
DtPrvyq31LqQYdfFpM2r9hjIrAn4y9SjbrudLY7/mLAQ90gIO8/++CYfEUR7OOTY
gQIDAQABo2wwajAaBgNVHREEEzARhwR/AAABgglsb2NhbGhvc3QwDAYDVR0TAQH/
BAIwADAdBgNVHQ4EFgQUkteOCy+vH/p/7mLACkjh+sYr+YswHwYDVR0jBBgwFoAU
dNjaHx6CZDrwtW6UMTPqfrILgpAwDQYJKoZIhvcNAQELBQADggEBAGPSIBGUmoNm
ofVSh+2KNJhc5RzePoGUCBJa9wKa8RIpNGhIlKyD5UqvDXuQIK5GxpQ2B/B2T1zq
oyQFT1cvAedW4FbgvdF45IUAsuQmtArTduL7vawgAOW3NAZl8Ib6D8UM/bMZuMZl
78Nu8Amzuti1e4hIYceAFfs8HxgHPOhjA9O4TMr/A6hwHKsnR8CQTGE06lDkRa6p
R3EjMxkYWwzZm+3jaJlpkmxc4u2ouW5Ve7BAyCRb5afl9HD7TA/gLnPk6Z+EKbS9
avt2q89TDSohaR3fNGbSxWmyq6+gl/8c12kQZABY6FvvUAgfxMURCIoNhgTqJCNk
SJzaW9y9p4M=
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIDOTCCAiGgAwIBAgIUFK4nnxLsa266COSbMQBio3y2to0wDQYJKoZIhvcNAQEL
BQAwJDEiMCAGA1UEAwwZcXdlbiByZW5ld2VkIHRlc3Qgcm9vdCBDQTAeFw0yNjA4
MTkxMjEyMjBaFw0yNjA4MjIxMjEyMjBaMCQxIjAgBgNVBAMMGXF3ZW4gcmVuZXdl
ZCB0ZXN0IHJvb3QgQ0EwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC1
WejFH2aVeBrJU/U9sMpdNxYDKB267SM7NckZwvukM/yw8uNTBnFTFmK0MMb18TVA
uxp12+gN1EYxgSHLQQbTHy6sGk8g8XOhbJmL5CM0OSSQ3o7lVI14hbMSLa4IoTpw
snTU/xH6FbT4F7MZc3rtKLLheymaZGaNvGGwZ6hQYSdjQxge1RLtUHqwsKR9utwF
FNxm7EmRrDRHuoxJofQGZORyUHwLZ4X0gLjO1BHNp3qcLfMXh3NM5zmgwwi0rO/u
7M3dv5MPz+EokG1ZdMywdOjYeMZMHKO8id75mmH7sKtePbsMyKbyNK+WtYqdsCSe
mhmwKLUwLPsXprwngzRTAgMBAAGjYzBhMB0GA1UdDgQWBBR02NofHoJkOvC1bpQx
M+p+sguCkDAfBgNVHSMEGDAWgBR02NofHoJkOvC1bpQxM+p+sguCkDAPBgNVHRMB
Af8EBTADAQH/MA4GA1UdDwEB/wQEAwIBBjANBgkqhkiG9w0BAQsFAAOCAQEAryag
ROvm/WygCrsuMoeZvzGM+hIwYpq1nuOGput0zQUAqFQhyUistLCNBQ3zGxdRFEV9
FWIjBO3XdJArzc/34OWH28OOrf5OD24aXg3aK97NHk8RWCkx9AEU6mP3IAjgGnGy
Qnj3CXnEAXRkDG7iCgM0jrW6gjgXvt4Ytb5WqxuqkUUOjboa5Ib01mI1QUobSqA5
q3YiuRtwWCjJN5AyTHQnczRQU9GMcCaBd6d1Hs5DHx1dqYj5Vd6SDoUJojkydPiB
gux1cYPewfQh1p2SX6YLKRHheooVp1mgXju1x81nPl80cXsErrlFYLo7rVhjabNR
uhzf6T5xCyhBjj2XWA==
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIDOzCCAiOgAwIBAgIURuDBy7kKe3JAEkySJTgKilHa8ecwDQYJKoZIhvcNAQEL
BQAwJDEiMCAGA1UEAwwZcXdlbiByZW5ld2VkIHRlc3Qgcm9vdCBDQTAgFw0yNjA4
MTkxMjEyMjBaGA8yMTI2MDcyNjEyMTIyMFowJDEiMCAGA1UEAwwZcXdlbiByZW5l
d2VkIHRlc3Qgcm9vdCBDQTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEB
ALVZ6MUfZpV4GslT9T2wyl03FgMoHbrtIzs1yRnC+6Qz/LDy41MGcVMWYrQwxvXx
NUC7GnXb6A3URjGBIctBBtMfLqwaTyDxc6FsmYvkIzQ5JJDejuVUjXiFsxItrgih
OnCydNT/EfoVtPgXsxlzeu0osuF7KZpkZo28YbBnqFBhJ2NDGB7VEu1QerCwpH26
3AUU3GbsSZGsNEe6jEmh9AZk5HJQfAtnhfSAuM7UEc2nepwt8xeHc0znOaDDCLSs
7+7szd2/kw/P4SiQbVl0zLB06Nh4xkwco7yJ3vmaYfuwq149uwzIpvI0r5a1ip2w
JJ6aGbAotTAs+xemvCeDNFMCAwEAAaNjMGEwHQYDVR0OBBYEFHTY2h8egmQ68LVu
lDEz6n6yC4KQMB8GA1UdIwQYMBaAFHTY2h8egmQ68LVulDEz6n6yC4KQMA8GA1Ud
EwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgEGMA0GCSqGSIb3DQEBCwUAA4IBAQCj
1C2V2wPvByLc9hmmx247jZs+kKiNmVW6HIEieTbRAgEr2X+sE3DGDdvxs9N7cBKW
hrrxW0DKfi86M3cqM3Z4+hpOyPukRpGF6MSuK+Z09gN/Mr8fHcpGqY37GdcREEbt
eSR8whZQy7mxoNBgPgDUa5dusMsemhkvQtT0kJNBo3mrVdxMrbT/iMqPHDnfUB5P
JuqlSTd6kmII8MLMkOPnlrBLE/IiMnpeq5bPbNqWJ3jSOWB0Y5urevXlhLvhhkZi
mfaYUhXXxhzIHbSGiqWkLbZH0m1OB7jZXhlee02W/9i/vmdFNBIAxTAfshB0X9d+
CLo3gjYAPFzEeGqHNP3E
-----END CERTIFICATE-----
`;
// A leaf under a root that IS basicConstraints CA:TRUE but whose keyUsage
// omits keyCertSign. Measured: `checkIssued` false while `verify(publicKey)`
// is true, `X509Certificate.ca` false, and the real handshake fails with
// "key usage does not include certificate signing".
const TEST_TLS_CERT_FULLCHAIN_NO_KEY_CERT_SIGN_ROOT = `-----BEGIN CERTIFICATE-----
MIIDOjCCAiKgAwIBAgIUKi3c/T5nw8gASNMWOKw2Q3tpUxEwDQYJKoZIhvcNAQEL
BQAwKjEoMCYGA1UEAwwfcXdlbiBub2tleWNlcnRzaWduIHRlc3Qgcm9vdCBDQTAg
Fw0yNjA4MTkxMjE5MzlaGA8yMTI2MDcyNjEyMTkzOVowFDESMBAGA1UEAwwJbG9j
YWxob3N0MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0JejMX6sGYJ2
t6elMJNcqsxf/yZYEj+BCkPuzkIQykFv9wUlJKGnL2or5q1qeMWbHMk/ZRg3jfOx
DSEjKKs6ie+A1ZkoMBOSqi1gjvLqRqjMMTJxIn9WOcj+akHh2c7ZDYu8cVSOPO32
zc6M+qBx9ssvzvrCerb5e6o4SfR0aZ7M+DK2G1LwXN5+sWD4DFPlk2gZwvGLyNDZ
Rh/vtuyGGx7aebxo3pjkCoTyVDdvNz+yt9nwBeuNH58dcq9FEYLKes2JoBq32sDH
IrlLXjwknu7dPtxwuMZc3nsPYb8/21iWaUd56blvgjkEwRKVftcR1LpC5JeIuYoZ
A25hXF2XBwIDAQABo2wwajAaBgNVHREEEzARhwR/AAABgglsb2NhbGhvc3QwDAYD
VR0TAQH/BAIwADAdBgNVHQ4EFgQU9lySqQE7aVSYiKsgjKVPRn9zGbcwHwYDVR0j
BBgwFoAU2oK2V91KbU4TPZQjLxdHXykCB9EwDQYJKoZIhvcNAQELBQADggEBAD6X
MUCftUl4xHSRaIPBFzmMJ59BpE+lGPRbbdpS9T2UGyhee0/LL18NhK5QQitQ7r2k
esxcRENsrTpOVLjuzhZAiD41ptoDTC/4nh5STzGJKAl9CKh2gnMzVbdA9t0vcFB0
II83prmIaxfoqmzxrfPZMq1AwYaNiKg+2zGySewVYRYA+NuVXKUcgV3Ih7A+NWGY
rNIVjgfD8hOCUir9zQ3Y/teELMnU1HViCIkNei6/dMvZs++lbGLj5cYJzjNFt7vG
eT9cwYnzyCG/iCz6Yd576iWKpSstejld8FASAeiRZrqF0s+Lwd3PP1aAH3eX46Yq
nEBd7qFHiJ8fUPiZ928=
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIDRzCCAi+gAwIBAgIUcV7b49wOPjvZ3tYvJtXasJmy/8kwDQYJKoZIhvcNAQEL
BQAwKjEoMCYGA1UEAwwfcXdlbiBub2tleWNlcnRzaWduIHRlc3Qgcm9vdCBDQTAg
Fw0yNjA4MTkxMjE5MzlaGA8yMTI2MDcyNjEyMTkzOVowKjEoMCYGA1UEAwwfcXdl
biBub2tleWNlcnRzaWduIHRlc3Qgcm9vdCBDQTCCASIwDQYJKoZIhvcNAQEBBQAD
ggEPADCCAQoCggEBAMWcyBjpNPcwAE/M/7BHMrmf9oUYoS/LH0mYxZOnjYsn1eZd
pCilJr5pfW4wqPYRyMXq1GxjTZtr/RnjmkUyG0rYSz9xiwnVj1xVSluK8zEjqzfo
VyKy1T5SgB0fxZbOCF8JNOWP/mL6C6bc0nNL8vQJUkBxMIDtVOSfAp4pjt89aw84
lDuv0jAhLTX8Y+9sMTwFLsvMrOLjvOXgywF2xj/ZJ+gModN6a15KpeDoVeBi0fbB
U0pMjdpkr8Ajaiy0uHSKIeiwoXuwhW+wmb2tQGlBfqcvRXH3HW57xXPA1bNzHMBS
BeVP+nGWVtX1gajmOQRUuPU0pAzdouqVMlNBWXkCAwEAAaNjMGEwHQYDVR0OBBYE
FNqCtlfdSm1OEz2UIy8XR18pAgfRMB8GA1UdIwQYMBaAFNqCtlfdSm1OEz2UIy8X
R18pAgfRMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgGCMA0GCSqGSIb3
DQEBCwUAA4IBAQBCqnvLLxY1eXSXDp03vOh5RvjoPJ4nxgA7fuubOKYA7AaoiUxI
BrlfcVzku3FjnMVJ5NGPmZgCcyGAs1xmSEewh0wZ9n+76BZdcKur7onBtCQuZVh4
Cxn5HwELJI60A0qNg2rj3LbIksiy/lf0iShL/xNFglgE/tTC6t3OhRgOOgOxn4ad
IYrwUAS7RHOK6k3tCEAtJPTtM8uVAt6JTjy3dIS1eas3jwvMWu+Rg6DY/ihe1NDu
EO8z6X9p57C7Aecy0gMzMedfdE7tDDAKLxkFG94HFaa7mKbagmUrUWWnvxb4crJx
xV7XpOu0J+IxSOacCqGLD7Aep4t6n1VT5GoN
-----END CERTIFICATE-----
`;
// The v3-no-basicConstraints intermediate the incapable-issuer gap message
// explicitly claims to cover ("or, as an X.509 v3 certificate, no
// basicConstraints at all"): extensions present (SKI/AKI) but no
// basicConstraints, so `.ca` is false while `declaresNotACa` is false too.
// Measured: the handshake fails INVALID_PURPOSE.
const TEST_TLS_CERT_FULLCHAIN_V3_NO_BC_INTERMEDIATE = `-----BEGIN CERTIFICATE-----
MIIDODCCAiCgAwIBAgIUe4EHdm1SdRvcvNwGOcKE68nSWKswDQYJKoZIhvcNAQEL
BQAwKDEmMCQGA1UEAwwdcXdlbiB2M25vYmMgdGVzdCBpbnRlcm1lZGlhdGUwIBcN
MjYwODE5MTIyMDM1WhgPMjEyNjA3MjYxMjIwMzVaMBQxEjAQBgNVBAMMCWxvY2Fs
aG9zdDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMjEEJ49QJyAzKb6
1D+i/+/d+2ZF01KXQzCIzMlKhIvqubTDNTXOHAboXUfkEhgbf83bof3rKzRQy52y
0ypd3sDZ7ALd3fhW/6og3ONIaq9SlXCTLKZUU8dw0ZGhsDcjLwLhZDsnGswG2Jam
dnlMNjjaedP3WmURuXNiee5AeyUCuB1Ml5B5ncNGeYTPknt0iYSW2u+A8lr9HwFA
CKlbmoK2zeaZRR8WcMc1cymItN53Wf8jaifjZSSwUC0FRHlBTDpDm/PwbQpIZdui
XKQ9+Spas+NqIUeaeE3GZA6ee5AWC3l/+eTjukjdjttg26+mwqO41z0eT3TMnpHT
rY6TYGUCAwEAAaNsMGowGgYDVR0RBBMwEYcEfwAAAYIJbG9jYWxob3N0MAwGA1Ud
EwEB/wQCMAAwHQYDVR0OBBYEFJaOHngXcX9UdhusQv0fS62FiFM5MB8GA1UdIwQY
MBaAFKUbHD8LOrV0itdYvxfiDa8+4U1hMA0GCSqGSIb3DQEBCwUAA4IBAQBBrIV9
8ihKm6dSEcyblYMe1UrwjBVRd7bf05poP7I2IV/hExWk9t+sMFwpGvcxRIZoUEC/
PVnqGjUeRg8z7AuSP5aco8f0BTR5Fa7FrpyA6poudahWSeoGQHERAC7p1gwhtGuz
Pg7lz3MRZIiAstTQB8VaQr6BmnEXhxMmET6cPDC2T5yKofU1Vd6krNjU+aQGGETX
DBA775cHQIKhpfZkzKpI56Bf/yMWhi4lOFnlqGZCktxTu7MuejQiRwa1ukceLslV
xsq9xpizdb5KmQQgm6tez9yu2Ft227sMjFR+keHWdGIRx9Xm2Hbhbl7EhPh4ulCW
N/i7TxrrvdGrjYnL
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIDHTCCAgWgAwIBAgIUdEkS/sVYEX/e6JujObGXf/H0kPswDQYJKoZIhvcNAQEL
BQAwIzEhMB8GA1UEAwwYcXdlbiB2M25vYmMgdGVzdCByb290IENBMCAXDTI2MDgx
OTEyMjAzNFoYDzIxMjYwNzI2MTIyMDM0WjAoMSYwJAYDVQQDDB1xd2VuIHYzbm9i
YyB0ZXN0IGludGVybWVkaWF0ZTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoC
ggEBAKtw5NRKmxRCnpeYvoW1IUqrtijG6P4GLz24OV8PYCpHMqeV2gY3cw1IE1rX
V4YxaZzEun7E41hPXN7sWHtu1QFDKYs3Lva5yNGDQJ+Q2Mk7AHyHvMXtk83UxC0D
BVEnEBF0g+wfLx7PsVtwDM+28xk69JACaWn0kU+y6+u2yE/PgtHwOCrH9hEFA666
mfmLS5cIg7LBD2a1hJBuC8ixpph9/xZGUESL2AAlQDI6wFU3oAa4q1GdO3dQeSj2
RVzQ0wJrSxtozIOEkjqv33dyvNVKiqhx7C0pkp8MrQaS1tzRn9E7AQ0RGpw/v6sX
+w0MhLECk3DQBqcvow/jClmJRx8CAwEAAaNCMEAwHQYDVR0OBBYEFKUbHD8LOrV0
itdYvxfiDa8+4U1hMB8GA1UdIwQYMBaAFHsJ6vUd16FC7XB5KbfcXizNgbeIMA0G
CSqGSIb3DQEBCwUAA4IBAQCGzu94HQLAzHTaiQFzDAxTPwVQCeqvXAiexXMM4C7m
Uoz7ij1GNp1snO6yLqEbgHTrLkF/U2iAR0P9RITrIpJKc9k8hxvXYG9QhnkM6fo1
1Ksb7CeQoZk9v2ozh1XFQGMT0MG/NsFz0SOemoHDUWuTwyuhNz4v0Ly0YjSEVJH0
gHGuh3gtRxR3ir6VLct7ohBZDpEtsO9VHCfCk68wemdbJueTaUOsZjAuniHyO+Ka
uwA7oXbljgKVp+keHjwgp2qVMYDhtkFCq/OrH1hHBLMv4R4bxNGUnrjKZXiPcd/N
QbnMbcuIyLUDuMt6ZeYnaKz04V1fxCE+prl1i12z1MEa
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIDOTCCAiGgAwIBAgIUbYiFCuCd5kQBXIBJaH5yWhXfT/0wDQYJKoZIhvcNAQEL
BQAwIzEhMB8GA1UEAwwYcXdlbiB2M25vYmMgdGVzdCByb290IENBMCAXDTI2MDgx
OTEyMjAzNFoYDzIxMjYwNzI2MTIyMDM0WjAjMSEwHwYDVQQDDBhxd2VuIHYzbm9i
YyB0ZXN0IHJvb3QgQ0EwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCr
dCCg0SHYYIzM9SUioam7Zt8OtA8LhYnGbcCUgvMvAQvSKyn378JufVNHKzMdzfkx
uTZ0eaFPkNRkxZ6RcZq1CaRUdD3MhJXPx5t9prYNr3HBr576N0MkVeJYOnJCkQnu
hsFCpGXBgJtuD0m/7My9QoGgh7WwU4SyNpJk991JfqFElPwszqg7cluMMo1ofDfp
XwQK4FzpzPTCuCA1e+2/pQny1hv3+0pk17xw4Hdcc9oiYob0rHHPcQWdCnCexOsj
SYmRNWLwEA67loOmoSEfsHkDUcCjjWl76hzbJ+ttQaohrsvT9qS8Ox0Z66dEZxYk
DrLOX/dlliFL30HzYKmPAgMBAAGjYzBhMB0GA1UdDgQWBBR7Cer1HdehQu1weSm3
3F4szYG3iDAfBgNVHSMEGDAWgBR7Cer1HdehQu1weSm33F4szYG3iDAPBgNVHRMB
Af8EBTADAQH/MA4GA1UdDwEB/wQEAwIBBjANBgkqhkiG9w0BAQsFAAOCAQEAV9Rp
3DR4OtkLiYjiJ9RNOQhN96eR+FlQaygvUkHawa9VLjN0m2MbAytba2WrbW7hAklN
iJtsh4Pauyx6yOyiPyxIXdb0IkVOUKbDn0we0IGjsvNAmVoI+j6sBpoVfcYxt01O
VBLyuUGeb32pG39AS+qscS1wbBBcrvZYuDYA4VvYwKKVLVQc1qQk9bHBeaF3BbcI
ndUiVDAPujPEMbWHkgJTunZXU9NG31kTRmdUzdKG15Xcw0mydZYui8d0LMTmrfUB
ZsRuGFKsNla8troQc0H7DVbwpTID7XPGF/DEOCZpa5AbF7TNWkROkZqJdLpXdc7G
Jj4O2TlARc1pC0hlVA==
-----END CERTIFICATE-----
`;
describe('describeWorkerTlsTrustGaps', () => {
const daemonUrl = 'https://127.0.0.1:4170';
@@ -1532,6 +1948,62 @@ describe('describeWorkerTlsTrustGaps', () => {
).toEqual([]);
});
// R2-21 entrance 2: nothing modelled OpenSSL's server-purpose check on the
// serving certificate ITSELF, so both shapes below booted green with an
// empty diagnostic while every worker handshake failed INVALID_PURPOSE.
it.each([
[
'keyUsage carrying keyCertSign only',
TEST_TLS_CERT_LEAF_KEY_CERT_SIGN_ONLY,
],
[
'extendedKeyUsage carrying clientAuth only',
TEST_TLS_CERT_LEAF_EKU_CLIENT_ONLY,
],
])(
'names the depth-0 server-purpose gap for a leaf with %s',
(_label, pem) => {
const gaps = describeWorkerTlsTrustGaps({
cert: Buffer.from(pem),
certPath: '/certs/daemon.pem',
daemonUrl,
});
expect(gaps).toHaveLength(1);
expect(gaps[0]).toContain('INVALID_PURPOSE');
expect(gaps[0]).toContain('server authentication');
},
);
// The control arm: absent extensions mean "unrestricted" in RFC 5280, so a
// fixture carrying neither keyUsage nor EKU must stay silent — otherwise
// the check above would warn on every certificate the other tests use.
it('stays quiet for a serving leaf that restricts nothing', () => {
expect(
describeWorkerTlsTrustGaps({
cert: Buffer.from(TEST_TLS_CERT),
certPath: '/certs/daemon.pem',
daemonUrl,
}),
).toEqual([]);
});
// R2-21 entrance 3: the issuer-capability check read basicConstraints and
// keyCertSign but never extendedKeyUsage, so a chain through a real CA
// whose EKU excludes serverAuth walked up green. An EKU on a chain member
// constrains everything below it — measured, this chain fails
// INVALID_PURPOSE and the EKU-free twin authorizes.
it('names the EKU gap for a chain through a clientAuth-only intermediate', () => {
const gaps = describeWorkerTlsTrustGaps({
cert: Buffer.from(TEST_TLS_CERT_FULLCHAIN_EKU_INTERMEDIATE),
certPath: '/certs/daemon.pem',
daemonUrl,
});
expect(gaps).toHaveLength(1);
expect(gaps[0]).toContain('INVALID_PURPOSE');
expect(gaps[0]).toContain('extendedKeyUsage does not include serverAuth');
expect(gaps[0]).toContain('qwen eku test intermediate');
});
it('names the leaf-as-trust-anchor gap for a CA-issued cert', () => {
const gaps = describeWorkerTlsTrustGaps({
cert: Buffer.from(TEST_TLS_CERT_CA_ISSUED),
@@ -1588,6 +2060,24 @@ describe('describeWorkerTlsTrustGaps', () => {
expect(gaps[0]).toContain('/certs/typo.pem');
});
// The generic unanchored gap told the operator to point NODE_EXTRA_CA_CERTS
// at the issuing CA — which this bundle already carries. The real fix is the
// issuer's keyUsage, so the operator re-exported, restarted, and looped on
// the same failure with no hint.
it('names the issuer keyUsage when the walk stops one link short', () => {
const gaps = describeWorkerTlsTrustGaps({
cert: Buffer.from(TEST_TLS_CERT_FULLCHAIN_NO_KEY_CERT_SIGN_ROOT),
certPath: '/certs/fullchain.pem',
daemonUrl,
});
expect(gaps).toHaveLength(1);
expect(gaps[0]).toContain('keyCertSign');
expect(gaps[0]).toContain('key usage does not include certificate signing');
expect(gaps[0]).toContain('qwen nokeycertsign test root CA');
// The advice this replaces is the one that sends them in a circle.
expect(gaps[0]).not.toContain('Point NODE_EXTRA_CA_CERTS at the');
});
it('softens the leaf-anchor gap for a CA already in the default trust store', () => {
// R2-3(c): the static model cannot see the workers' default trust store,
// so an issuer already anchored there makes this warning cry wolf. Say
@@ -1602,6 +2092,55 @@ describe('describeWorkerTlsTrustGaps', () => {
);
});
// The explicit-CA:FALSE fixture is the only shape the incapable-issuer
// check was pinned on, so unifying it with the terminator's
// `declaresNotACa` predicate shipped green — both predicates are true for
// that fixture — while chains through THIS shape were reported anchored and
// every worker handshake failed INVALID_PURPOSE/INVALID_CA.
it('names a v3 intermediate that carries no basicConstraints at all', () => {
const intermediate = new X509Certificate(
TEST_TLS_CERT_FULLCHAIN_V3_NO_BC_INTERMEDIATE.match(
/-----BEGIN CERTIFICATE-----[^-]*-----END CERTIFICATE-----/g,
)![1]!,
);
// Precondition: the two predicates disagree for this shape — which is
// what makes unifying them a silent regression.
expect(intermediate.ca).toBe(false);
const gaps = describeWorkerTlsTrustGaps({
cert: Buffer.from(TEST_TLS_CERT_FULLCHAIN_V3_NO_BC_INTERMEDIATE),
certPath: '/certs/fullchain.pem',
daemonUrl,
});
expect(gaps).toHaveLength(1);
expect(gaps[0]).toContain('no basicConstraints at all');
expect(gaps[0]).toContain('qwen v3nobc test intermediate');
});
// Every `operatorCaCert:` fixture in this suite is date-valid, so the date
// walk only ever fired on serving-file members — a mutant exempting
// operator-contributed members kept all of them green. An operator
// NODE_EXTRA_CA_CERTS file contributing an expired root on the anchor path
// is exactly the case the walk was added for.
it('names an expired chain member the OPERATOR file contributed', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2130-01-01T00:00:00Z'));
try {
const gaps = describeWorkerTlsTrustGaps({
cert: Buffer.from(TEST_TLS_CERT_FULLCHAIN_LEAF_ONLY),
certPath: '/certs/daemon.pem',
daemonUrl,
operatorCaCertPath: '/certs/rootCA.pem',
operatorCaCert: Buffer.from(fullchainRootPem()),
});
expect(gaps).toHaveLength(1);
expect(gaps[0]).toContain('CERT_HAS_EXPIRED');
expect(gaps[0]).toContain('qwen fullchain test root CA');
} finally {
vi.useRealTimers();
}
});
it('names an expired chain member the signature-only walk accepts', () => {
// R2-3(b): `x509.verify` checks signatures and never consults dates, so an
// expired root anchors a fullchain "fine" here while every worker
@@ -1626,6 +2165,42 @@ describe('describeWorkerTlsTrustGaps', () => {
}
});
// R2-21 entrance 4: nothing modelled pathLenConstraint, so this chain
// walked up anchored and green while every worker handshake failed
// PATH_LENGTH_EXCEEDED.
it('names a pathLenConstraint the chain exceeds', () => {
const gaps = describeWorkerTlsTrustGaps({
cert: Buffer.from(TEST_TLS_CERT_FULLCHAIN_PATHLEN0_ROOT),
certPath: '/certs/fullchain.pem',
daemonUrl,
});
expect(gaps).toHaveLength(1);
expect(gaps[0]).toContain('PATH_LENGTH_EXCEEDED');
expect(gaps[0]).toContain('pathlen:0');
expect(gaps[0]).toContain('qwen pathlen test root CA');
});
// R2-21 entrance 5, the false-alarm direction: the walk took whichever copy
// of a renewed CA came first and reported the expired one as the path the
// handshake relies on. OpenSSL may use either; preferring the usable one
// keeps the diagnostic from sending operators to renew a CA they already
// renewed. The clock is moved past the short-lived copy only.
it('prefers a usable issuer over an expired same-subject twin', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2030-01-01T00:00:00Z'));
try {
expect(
describeWorkerTlsTrustGaps({
cert: Buffer.from(TEST_TLS_CERT_FULLCHAIN_RENEWED_ROOT),
certPath: '/certs/fullchain.pem',
daemonUrl,
}),
).toEqual([]);
} finally {
vi.useRealTimers();
}
});
it('names a chain member whose validity window has not started', () => {
// Symmetric to the expiry branch: clock skew or a freshly minted root
// fails every handshake CERT_NOT_YET_VALID with the same silent boot.
@@ -1668,6 +2243,40 @@ describe('describeWorkerTlsTrustGaps', () => {
expect(gaps[0]).toContain('127.0.0.1');
});
// R2-21 entrance 1: the diagnostic SAN-checked the LOOSE parser's first
// block while `createSecureContext` — and the workers' loader — serve the
// loader-framed first block. Absorbing a predecessor's BEGIN marker into a
// comment line makes the two disagree: the line-oriented loader skips that
// line, the regex parser still matches it. Before the fix this file
// reported no gap (it judged the covering certificate that is never served)
// while every worker handshake failed ERR_TLS_CERT_ALTNAME_INVALID.
it("SAN-checks the served block, not the loose parser's first block", () => {
const covering = TEST_TLS_CERT.trim().split('\n');
const servingFile = [
`# vendor note: ${covering[0]}`,
...covering.slice(1),
TEST_TLS_CERT_NO_LOOPBACK_SAN.trim(),
'',
].join('\n');
// Precondition: the two parsers really do disagree about the leaf here.
expect(extractCertificateBlocks(servingFile)).toHaveLength(1);
expect(
new X509Certificate(extractCertificateBlocks(servingFile)![0]!).checkIP(
'127.0.0.1',
),
).toBeFalsy();
const gaps = describeWorkerTlsTrustGaps({
cert: Buffer.from(servingFile),
certPath: '/certs/daemon.pem',
daemonUrl,
});
expect(gaps).toHaveLength(1);
expect(gaps[0]).toContain('ERR_TLS_CERT_ALTNAME_INVALID');
expect(gaps[0]).toContain('127.0.0.1');
});
it('checks the host actually dialled, not a fixed loopback literal', () => {
expect(
describeWorkerTlsTrustGaps({
+274 -4
View File
@@ -80,6 +80,7 @@ import { createBridgeFileSystemAdapter } from './bridge-file-system-adapter.js';
import { PathMutexRegistry } from './fs/path-mutex-registry.js';
import { isDeepHealthQuery } from './health-query.js';
import { isLoopbackBind } from './loopback-binds.js';
import { isOwnInterfaceAddress } from './local-bind-addresses.js';
import { RUNTIME_STARTUP_CANCELLED_MESSAGE } from './runtime-startup-errors.js';
import { resolveWebShellDir } from './web-shell-resolver.js';
import {
@@ -687,6 +688,32 @@ export function formatChannelWorkerDaemonUrl(
return `${scheme}://${formatHostForUrl(host)}:${port}`;
}
/**
* Refuse a channel boot whose worker URL this host cannot answer.
*
* Workers run on the daemon's own machine and dial the address it actually
* bound. Loopback is not a fallback: `--hostname 192.168.1.100` binds that
* socket ONLY, so a worker sent to `127.0.0.1` gets `ECONNREFUSED`. A bind
* this host cannot certify as its own — a DNS name, or a literal on no local
* interface — passes every other boot check and then throws inside each
* worker: the first one's failure exits the daemon, and channels added later
* restart-loop while `/health` stays green. Name it once, at boot.
*/
export function assertChannelWorkerDaemonUrlIsLocal(
workerDaemonUrl: string,
hostname: string,
): void {
const host = new URL(workerDaemonUrl).hostname;
if (isLoopbackBind(host) || isOwnInterfaceAddress(host)) return;
throw new Error(
`Channels cannot start: --hostname "${hostname}" is not a loopback bind ` +
`and does not name an address on this host, so channel workers have no ` +
`local URL to reach the daemon on. Bind to loopback, to the wildcard ` +
`(0.0.0.0 / ::), or to a literal address of one of this machine's ` +
`interfaces.`,
);
}
/**
* Two TLS misconfigurations boot green and break only the channel workers:
* a serving cert that is not its own trust anchor (workers fail
@@ -765,6 +792,13 @@ export function describeWorkerTlsTrustGaps(opts: {
// The fallback keeps a leaf to reason about rather than reporting phantom
// gaps, but it must not pretend the operator CA reached the workers.
const servingChain = servingBlocks ?? chain;
// What `createSecureContext` serves and what the workers' loader reads is
// the loader-framed first block — NOT the loose parser's `chain[0]`. The two
// disagree whenever a predecessor's `-----BEGIN` line was absorbed into a
// comment: the diagnostic then SAN-checked and identified a certificate the
// handshake never presents, reporting no gap while every worker failed
// ERR_TLS_CERT_ALTNAME_INVALID.
const servedLeaf = servingChain[0] ?? x509;
const workerTrustStore =
operatorChain && servingBlocks
? [...servingChain, ...operatorChain]
@@ -798,10 +832,32 @@ export function describeWorkerTlsTrustGaps(opts: {
`with CA:TRUE, or point NODE_EXTRA_CA_CERTS at a chain whose ` +
`intermediates are real CAs, and restart.`,
);
} else if (anchorPath.nonServerIssuer) {
gaps.push(
`--tls-cert "${opts.certPath}" chains through ` +
`"${anchorPath.nonServerIssuer.subject.replace(/\r?\n/g, ', ')}", ` +
`whose extendedKeyUsage does not include serverAuth — an EKU on a ` +
`chain member constrains every certificate below it, so every worker ` +
`handshake to the daemon will fail INVALID_PURPOSE even though the ` +
`chain is complete and every signature verifies. Reissue that member ` +
`with serverAuth in its EKU (or without an EKU at all), and restart.`,
);
} else if (anchorPath.unusableIssuer) {
gaps.push(
`--tls-cert "${opts.certPath}" stops at ` +
`"${anchorPath.unusableIssuer.subject.replace(/\r?\n/g, ', ')}", ` +
`which is present in the workers' bundle and IS declared a CA, but ` +
`whose keyUsage omits keyCertSign — OpenSSL refuses the issuance ` +
`link, so the chain anchors nowhere and every worker handshake to ` +
`the daemon will fail with "key usage does not include certificate ` +
`signing". Re-export that CA with keyCertSign in its keyUsage; ` +
`pointing NODE_EXTRA_CA_CERTS at it again will not help — it is ` +
`already there.`,
);
} else if (!anchorPath.anchored) {
gaps.push(
`--tls-cert "${opts.certPath}" is issued by another CA ` +
`(${x509.issuer.replace(/\r?\n/g, ', ')}), not self-signed, and ` +
`(${servedLeaf.issuer.replace(/\r?\n/g, ', ')}), not self-signed, and ` +
`${
opts.operatorCaCertPath
? `NODE_EXTRA_CA_CERTS "${opts.operatorCaCertPath}" does not ` +
@@ -814,12 +870,23 @@ export function describeWorkerTlsTrustGaps(opts: {
`issuing CA (for mkcert: "$(mkcert -CAROOT)/rootCA.pem") and restart.`,
);
}
if (!servesTlsClients(servedLeaf)) {
gaps.push(
`--tls-cert "${opts.certPath}" is not usable as a TLS server ` +
`certificate: its keyUsage/extendedKeyUsage exclude server ` +
`authentication (OpenSSL checks the serving certificate's own ` +
`purpose at depth 0), so every worker handshake to the daemon will ` +
`fail INVALID_PURPOSE even though the daemon listens and /health ` +
`answers. Reissue it with extendedKeyUsage=serverAuth and a keyUsage ` +
`that includes digitalSignature or keyEncipherment, and restart.`,
);
}
// `X509Certificate.verify` checks signatures only and never consults dates,
// so an expired root or intermediate anchors "fine" here while every worker
// handshake fails CERT_HAS_EXPIRED. Boot validation covers the leaf alone.
const now = Date.now();
for (const member of anchorPath.path) {
if (member.fingerprint256 === x509.fingerprint256) continue;
if (member.fingerprint256 === servedLeaf.fingerprint256) continue;
const subject = member.subject.replace(/\r?\n/g, ', ');
if (new Date(member.validTo).getTime() < now) {
gaps.push(
@@ -837,8 +904,28 @@ export function describeWorkerTlsTrustGaps(opts: {
);
}
}
// `pathLenConstraint` caps how many CAs may sit BELOW a given CA on the
// path. Nothing modelled it, so a `pathlen:0` root anchoring a chain
// THROUGH an intermediate walked up green while the handshake failed
// PATH_LENGTH_EXCEEDED (measured; the pathlen-free control authorizes).
for (let depth = 1; depth < anchorPath.path.length; depth++) {
const member = anchorPath.path[depth];
if (!member) continue;
const limit = pathLenConstraint(member);
if (limit === undefined || depth - 1 <= limit) continue;
gaps.push(
`--tls-cert "${opts.certPath}" chains through ` +
`"${member.subject.replace(/\r?\n/g, ', ')}", which carries ` +
`basicConstraints pathlen:${limit} but has ${depth - 1} ` +
`intermediate CA${depth - 1 === 1 ? '' : 's'} below it — every ` +
`worker handshake to the daemon will fail PATH_LENGTH_EXCEEDED. ` +
`Reissue that CA with a pathlen that admits the chain, or shorten ` +
`the chain, and restart.`,
);
break;
}
const host = workerDialHost(opts.daemonUrl);
if (host && !certCoversHost(x509, host)) {
if (host && !certCoversHost(servedLeaf, host)) {
gaps.push(
`--tls-cert "${opts.certPath}" has no subjectAltName covering ` +
`"${host}", the host channel workers dial — every worker handshake ` +
@@ -871,6 +958,136 @@ function declaresNotACa(cert: X509Certificate): boolean {
return !cert.ca && cert.raw.includes(BASIC_CONSTRAINTS_OID_DER);
}
/** DER for the keyUsage OBJECT IDENTIFIER, 2.5.29.15. */
const KEY_USAGE_OID_DER = Buffer.from([0x06, 0x03, 0x55, 0x1d, 0x0f]);
/** RFC 5280 keyUsage bit positions. */
const KEY_USAGE_DIGITAL_SIGNATURE = 0;
const KEY_USAGE_KEY_ENCIPHERMENT = 2;
const KEY_USAGE_KEY_AGREEMENT = 4;
const KEY_USAGE_KEY_CERT_SIGN = 5;
const EKU_SERVER_AUTH = '1.3.6.1.5.5.7.3.1';
const EKU_ANY = '2.5.29.37.0';
/**
* The keyUsage bits `cert` asserts, or undefined when it carries no keyUsage
* extension (which RFC 5280 reads as "no restriction").
*
* Node exposes `X509Certificate.keyUsage` as the EXTENDED key usage OIDs, not
* these bits, and `toLegacyObject()` adds nothing — so read the DER the way
* `declaresNotACa` already does. The extension is a BIT STRING inside the
* extnValue OCTET STRING, and it is always short enough for definite short-
* form lengths.
*/
function keyUsageBits(cert: X509Certificate): readonly number[] | undefined {
const raw = cert.raw;
const at = raw.indexOf(KEY_USAGE_OID_DER);
if (at === -1) return undefined;
let i = at + KEY_USAGE_OID_DER.length;
// Optional `critical` BOOLEAN before the extnValue.
if (raw[i] === 0x01) i += 2 + (raw[i + 1] ?? 0);
if (raw[i] !== 0x04) return undefined;
i += 2;
if (raw[i] !== 0x03) return undefined;
const length = raw[i + 1];
const unused = raw[i + 2];
if (length === undefined || unused === undefined || length < 1)
return undefined;
const bytes = raw.subarray(i + 3, i + 3 + length - 1);
const bits: number[] = [];
for (let bit = 0; bit < bytes.length * 8 - unused; bit++) {
const byte = bytes[bit >> 3];
if (byte !== undefined && (byte & (0x80 >> (bit & 7))) !== 0)
bits.push(bit);
}
return bits;
}
/**
* Whether `cert` may terminate a TLS *server* handshake at depth 0.
*
* OpenSSL runs a server-purpose check on the serving leaf itself, which
* nothing else here models: measured on Node 22 / OpenSSL 3 with real
* handshakes, a self-signed serving cert with `keyUsage=critical,keyCertSign`
* (no server bits) and one with EKU `clientAuth` only BOTH fail
* INVALID_PURPOSE while the daemon boots green and `/health` answers.
*
* Absent extensions mean "unrestricted" — only an extension that is present
* and excludes server use is a gap.
*/
function servesTlsClients(cert: X509Certificate): boolean {
const eku = cert.keyUsage;
if (
eku &&
eku.length > 0 &&
!eku.includes(EKU_SERVER_AUTH) &&
!eku.includes(EKU_ANY)
) {
return false;
}
const bits = keyUsageBits(cert);
if (!bits || bits.length === 0) return true;
return (
bits.includes(KEY_USAGE_DIGITAL_SIGNATURE) ||
bits.includes(KEY_USAGE_KEY_ENCIPHERMENT) ||
bits.includes(KEY_USAGE_KEY_AGREEMENT)
);
}
/**
* Whether `cert` may sit ON the path of a TLS server chain.
*
* An EKU on a CA constrains everything below it: measured with a control arm,
* a chain through an intermediate carrying EKU `clientAuth` only fails
* INVALID_PURPOSE, and the same chain re-issued without the EKU authorizes —
* EKU the only variable. The issuer-capability check read basicConstraints
* and keyCertSign but never this.
*/
function issuerServesTlsChain(cert: X509Certificate): boolean {
const eku = cert.keyUsage;
if (!eku || eku.length === 0) return true;
return eku.includes(EKU_SERVER_AUTH) || eku.includes(EKU_ANY);
}
/**
* The `pathLenConstraint` a CA asserts, or undefined when it asserts none.
*
* basicConstraints is `SEQUENCE { cA BOOLEAN DEFAULT FALSE,
* pathLenConstraint INTEGER OPTIONAL }` inside the extnValue OCTET STRING.
* Nothing modelled it, so a `pathlen:0` root anchoring a chain THROUGH an
* intermediate walked up green while the handshake failed
* PATH_LENGTH_EXCEEDED (measured).
*/
function pathLenConstraint(cert: X509Certificate): number | undefined {
const raw = cert.raw;
const at = raw.indexOf(BASIC_CONSTRAINTS_OID_DER);
if (at === -1) return undefined;
let i = at + BASIC_CONSTRAINTS_OID_DER.length;
if (raw[i] === 0x01) i += 2 + (raw[i + 1] ?? 0);
if (raw[i] !== 0x04) return undefined;
i += 2;
if (raw[i] !== 0x30) return undefined;
const seqEnd = i + 2 + (raw[i + 1] ?? 0);
i += 2;
// Skip the optional cA BOOLEAN; the INTEGER is what we want.
if (raw[i] === 0x01) i += 2 + (raw[i + 1] ?? 0);
if (i >= seqEnd || raw[i] !== 0x02) return undefined;
const length = raw[i + 1] ?? 0;
let value = 0;
for (let byte = 0; byte < length; byte++)
value = value * 256 + (raw[i + 2 + byte] ?? 0);
return value;
}
/** Whether `cert`'s validity window contains `now`. */
function certValidAt(cert: X509Certificate, now: number): boolean {
return (
new Date(cert.validFrom).getTime() <= now &&
new Date(cert.validTo).getTime() >= now
);
}
function isSelfSignedCert(x509: X509Certificate): boolean {
try {
return x509.verify(x509.publicKey);
@@ -934,6 +1151,15 @@ function walkWorkerAnchorPath(chain: readonly X509Certificate[]): {
nonCaTerminator?: X509Certificate;
/** Set when the walk reached an issuer OpenSSL will not let issue. */
incapableIssuer?: X509Certificate;
/** Set when a chain member's EKU excludes TLS server use below it. */
nonServerIssuer?: X509Certificate;
/**
* Set when the walk stopped one link short of an anchor because the only
* subject-matching candidate cannot sign certificates. The generic
* unanchored text sends the operator to point NODE_EXTRA_CA_CERTS at a CA
* that is already provided, so they re-export, restart and loop.
*/
unusableIssuer?: X509Certificate;
} {
let next: X509Certificate | undefined = chain[0];
const walked = new Set<string>();
@@ -953,11 +1179,21 @@ function walkWorkerAnchorPath(chain: readonly X509Certificate[]): {
return { anchored: true, path };
}
walked.add(current.fingerprint256);
const issuer: X509Certificate | undefined = chain.find(
// First-match with no backtracking picked whichever copy of a renewed CA
// came first in the bundle, so an expired predecessor sharing the subject
// was reported as the path the handshake relies on — a false alarm
// claiming every handshake fails CERT_HAS_EXPIRED while the merged bundle
// authorizes (measured). OpenSSL is free to use either; prefer the one it
// can actually use, and fall back to the first match so a genuinely
// expired sole issuer is still reported by the date walk below.
const issuers: readonly X509Certificate[] = chain.filter(
(candidate) =>
!walked.has(candidate.fingerprint256) &&
certIssuedBy(current, candidate),
);
const issuer: X509Certificate | undefined =
issuers.find((candidate) => certValidAt(candidate, Date.now())) ??
issuers[0];
// `certIssuedBy` asks only "did this sign that": name match plus signature.
// OpenSSL asks a second question of every certificate it uses AS an issuer,
// and answering only the first is how a chain that walks THROUGH an
@@ -969,11 +1205,44 @@ function walkWorkerAnchorPath(chain: readonly X509Certificate[]): {
if (issuer && !isSelfSignedCert(issuer) && !issuer.ca) {
return { anchored: false, path, incapableIssuer: issuer };
}
// An EKU on a certificate used as an issuer constrains the whole chain
// below it, and reading only basicConstraints/keyCertSign missed that:
// measured with a control arm, an intermediate carrying EKU `clientAuth`
// only fails INVALID_PURPOSE while the same chain re-issued without the
// EKU authorizes.
if (issuer && !issuerServesTlsChain(issuer)) {
return { anchored: false, path, nonServerIssuer: issuer };
}
if (!issuer) {
// The walk stopped. `checkIssued` rejects the issuance link when the
// subject-matching candidate carries a keyUsage without keyCertSign
// (measured: checkIssued false while verify(publicKey) true), so the
// bundle DOES hold the issuer and the generic unanchored advice —
// "point NODE_EXTRA_CA_CERTS at the issuing CA" — is already satisfied.
const unusable = chain.find(
(candidate) =>
candidate.subject === current.issuer &&
candidate.fingerprint256 !== current.fingerprint256 &&
cannotSignCertificates(candidate),
);
if (unusable) return { anchored: false, path, unusableIssuer: unusable };
}
next = issuer;
}
return { anchored: false, path };
}
/**
* Whether `cert` carries a keyUsage extension that omits keyCertSign — the
* one shape that makes OpenSSL refuse an otherwise valid issuance link. An
* absent keyUsage is unrestricted, so only a present-and-lacking one counts.
*/
function cannotSignCertificates(cert: X509Certificate): boolean {
const bits = keyUsageBits(cert);
if (!bits || bits.length === 0) return false;
return !bits.includes(KEY_USAGE_KEY_CERT_SIGN);
}
/**
* WHATWG `URL.hostname` keeps the brackets on an IPv6 literal (`[::1]`), and
* `isIP` does not recognise the bracketed form — so an unstripped host falls
@@ -7440,6 +7709,7 @@ async function runQwenServeImpl(
actualPort,
tlsOptions !== undefined,
);
assertChannelWorkerDaemonUrlIsLocal(workerDaemonUrl, opts.hostname);
if (tlsOptions && tlsCertPath) {
const operatorCaCertPath = process.env['NODE_EXTRA_CA_CERTS'];
let operatorCaCert: Buffer | undefined;