fix(tunnel): recover an expired leaf over plain HTTPS, not mTLS

Correction to the previous commit. Its recovery vhost could not work: nginx
will not forward an expired client certificate in ANY `ssl_verify_client` mode.
`optional` answers a bare `400 The SSL certificate error` before any location
runs, and `optional_no_ca` only tolerates CHAIN failures — see nginx's
`ngx_ssl_verify_error_optional()`, which covers self-signed / unknown-issuer /
unverifiable-leaf and NOT `X509_V_ERR_CERT_HAS_EXPIRED`. Verified live against
the deployed :8472 server, which rejected the real expired leaf.

So recovery drops mTLS instead of trying to bend it:

- agent: `buildTlsOptions` and the mTLS renew transport go back to being
  strictly fail-closed on expiry — the relaxation is gone from the TLS layer
  entirely. The rotator now decides per attempt: valid → mTLS `/renew`,
  expired-inside-grace → plain `/recover`, expired-beyond-grace → terminal
  `onExhausted` with no request issued at all.
- new `recoverCert` POSTs `{cert, csr}` with no client certificate. Possession
  of the private key is still proven: the CSR is self-signed by it and the host
  signer already enforces CSR PoP plus `CSR key == registered key`, so a
  replayed (public) cert without the key yields at most a certificate the
  attacker cannot authenticate with.
- control-plane: `/renew` is strict again (grace 0, matching the terminator).
  The grace lives on the new `POST /recover`, which reads the cert from the BODY
  and ignores the `x-client-cert` header, then runs the identical trust
  pipeline: X.509 path validation to the frp-client-CA anchors, SPIFFE parse,
  `notBefore` (never graced), registry `active` + account match. Revocation
  still bites.
- deploy: no new vhost, no new SNI, no DNS. One `location = /recover` merged
  into the existing enroll vhost, documented in
  `deploy/nginx/enroll-recover-location.md` with the nginx source citation.

The load-bearing new test is "a self-signed cert with a FORGED SPIFFE SAN is
refused → 401": nginx no longer validates the chain on this path, so that
assertion is what keeps `/recover` from being a cert vending machine.

Verified: agent 289/289, control-plane 290/290, tsc clean on both.
This commit is contained in:
Yaojia Wang
2026-07-29 09:52:17 +02:00
parent f3f4d8baa6
commit 5509c81eee
13 changed files with 559 additions and 472 deletions

View File

@@ -490,65 +490,10 @@ describe('CP6c POST /renew — presented current-cert chain + expiry verificatio
expect(res.statusCode).toBe(201)
})
/**
* POLICY CHANGE (expired-leaf deadlock fix): `/renew` is mTLS-authenticated by the very leaf it
* renews, so refusing every expired leaf meant a host whose leaf lapsed could NEVER renew and was
* bricked until a manual re-pair. A recently-expired leaf is now accepted for RE-ISSUANCE ONLY,
* inside a bounded window. Everything else stays fail-closed — the tests below pin that down.
*/
test('a leaf expired INSIDE the grace window renews → 201 (breaks the deadlock)', async () => {
const ctx = await hostCtx('alice')
const now = Date.now()
const expired = await mintHostLeaf(ctx, new Date(now - 9 * DAY_MS), new Date(now - 8 * DAY_MS))
const app = appWith(hostDeps(ctx, { hostCaAnchorsDer: [ctx.ca.caDer] }))
await app.ready()
const res = await app.inject({
method: 'POST',
url: '/renew',
headers: { 'x-client-cert': certHeader(expired) },
payload: { csr: b64Csr(ctx.csr) },
})
expect(res.statusCode).toBe(201)
})
test('a leaf expired BEYOND the grace window is rejected → 401', async () => {
const ctx = await hostCtx('alice')
const now = Date.now()
const stale = await mintHostLeaf(ctx, new Date(now - 60 * DAY_MS), new Date(now - 31 * DAY_MS))
const app = appWith(hostDeps(ctx, { hostCaAnchorsDer: [ctx.ca.caDer] }))
await app.ready()
const res = await app.inject({
method: 'POST',
url: '/renew',
headers: { 'x-client-cert': certHeader(stale) },
payload: { csr: b64Csr(ctx.csr) },
})
expect(res.statusCode).toBe(401)
})
test('grace 0 restores the strict fail-closed behaviour → 401', async () => {
test('an EXPIRED current cert (valid chain, notAfter in the past) is rejected → 401', async () => {
const ctx = await hostCtx('alice')
const now = Date.now()
const expired = await mintHostLeaf(ctx, new Date(now - 2 * DAY_MS), new Date(now - DAY_MS))
const app = appWith(
hostDeps(ctx, { hostCaAnchorsDer: [ctx.ca.caDer], expiredRenewGraceMs: 0 }),
)
await app.ready()
const res = await app.inject({
method: 'POST',
url: '/renew',
headers: { 'x-client-cert': certHeader(expired) },
payload: { csr: b64Csr(ctx.csr) },
})
expect(res.statusCode).toBe(401)
})
test('grace NEVER bypasses revocation — revoked host with an in-grace leaf → 403', async () => {
const ctx = await hostCtx('alice')
const now = Date.now()
const expired = await mintHostLeaf(ctx, new Date(now - 9 * DAY_MS), new Date(now - 8 * DAY_MS))
const host = await ctx.hosts.getHostBySubdomain('alice')
await ctx.hosts.setHostStatus(host!.hostId, 'revoked')
const app = appWith(hostDeps(ctx, { hostCaAnchorsDer: [ctx.ca.caDer] }))
await app.ready()
const res = await app.inject({
@@ -557,42 +502,8 @@ describe('CP6c POST /renew — presented current-cert chain + expiry verificatio
headers: { 'x-client-cert': certHeader(expired) },
payload: { csr: b64Csr(ctx.csr) },
})
expect(res.statusCode).toBe(403)
})
test('grace NEVER bypasses chain validation — untrusted CA + in-grace leaf → 401', async () => {
const ctx = await hostCtx('alice')
const rogueCa = await makeP256Ca('rogue-CA')
const now = Date.now()
const rogue = await mintHostLeaf(
ctx,
new Date(now - 9 * DAY_MS),
new Date(now - 8 * DAY_MS),
rogueCa.caSigner,
)
const app = appWith(hostDeps(ctx, { hostCaAnchorsDer: [ctx.ca.caDer] }))
await app.ready()
const res = await app.inject({
method: 'POST',
url: '/renew',
headers: { 'x-client-cert': certHeader(rogue) },
payload: { csr: b64Csr(ctx.csr) },
})
expect(res.statusCode).toBe(401)
})
test('grace applies ONLY to notAfter — a not-yet-valid leaf is still rejected → 401', async () => {
const ctx = await hostCtx('alice')
const now = Date.now()
const future = await mintHostLeaf(ctx, new Date(now + DAY_MS), new Date(now + 2 * DAY_MS))
const app = appWith(hostDeps(ctx, { hostCaAnchorsDer: [ctx.ca.caDer] }))
await app.ready()
const res = await app.inject({
method: 'POST',
url: '/renew',
headers: { 'x-client-cert': certHeader(future) },
payload: { csr: b64Csr(ctx.csr) },
})
// STRICT on this route: nginx would never forward an expired client cert here anyway, so the
// deadlock escape hatch lives on `/recover` (below) and NOT on the mTLS renewal path.
expect(res.statusCode).toBe(401)
})
@@ -612,3 +523,152 @@ describe('CP6c POST /renew — presented current-cert chain + expiry verificatio
expect(res.statusCode).toBe(401)
})
})
/**
* CP6d · POST /recover — the expired-leaf escape hatch.
*
* Unlike `/renew` this route is NOT mTLS-authenticated: nginx cannot forward an expired client cert
* (under `ssl_verify_client optional` it 400s, and `optional_no_ca` only tolerates CHAIN errors, not
* `X509_V_ERR_CERT_HAS_EXPIRED`). The lapsed cert therefore arrives in the BODY, and the
* control-plane becomes the sole verifier. These tests pin down that nothing except the `notAfter`
* bound was relaxed — chain, SPIFFE, `notBefore`, and registry status all still decide.
*/
describe('CP6d POST /recover — expired-leaf recovery', () => {
async function mintLeaf(
ctx: HostCtx,
notBefore: Date,
notAfter: Date,
signer = ctx.ca.caSigner,
): Promise<Uint8Array> {
const spiffe = `spiffe://relay.terminal.yaojia.wang/account/${ctx.accountId}/host/${ctx.subdomain}`
return assembleCertificate({
subjectPublicKey: ctx.spki,
subject: `CN=${ctx.subdomain}`,
issuer: ctx.ca.caCert.subjectName,
serialNumber: Uint8Array.from([0x0b]),
notBefore,
notAfter,
extensions: [
new x509.SubjectAlternativeNameExtension([
{ type: 'dns', value: `${ctx.subdomain}.terminal.yaojia.wang` },
{ type: 'url', value: spiffe },
]),
new x509.BasicConstraintsExtension(false, undefined, true),
new x509.KeyUsagesExtension(x509.KeyUsageFlags.digitalSignature, true),
new x509.ExtendedKeyUsageExtension([x509.ExtendedKeyUsage.clientAuth]),
],
signer,
sigAlg: 'ecdsa-p256',
})
}
const b64 = (der: Uint8Array): string => Buffer.from(der).toString('base64')
async function post(ctx: HostCtx, cert: Uint8Array, extra?: Partial<RenewDeps>) {
const app = appWith(hostDeps(ctx, { hostCaAnchorsDer: [ctx.ca.caDer], ...extra }))
await app.ready()
return app.inject({
method: 'POST',
url: '/recover',
payload: { cert: b64(cert), csr: b64Csr(ctx.csr) },
})
}
test('a leaf expired INSIDE the grace window is re-issued → 201 (breaks the deadlock)', async () => {
const ctx = await hostCtx('alice')
const now = Date.now()
const res = await post(ctx, await mintLeaf(ctx, new Date(now - 9 * DAY_MS), new Date(now - 8 * DAY_MS)))
expect(res.statusCode).toBe(201)
expect(JSON.parse(res.payload).cert).toBeTruthy()
})
test('a leaf expired BEYOND the grace window is refused → 401', async () => {
const ctx = await hostCtx('alice')
const now = Date.now()
const res = await post(ctx, await mintLeaf(ctx, new Date(now - 60 * DAY_MS), new Date(now - 31 * DAY_MS)))
expect(res.statusCode).toBe(401)
})
test('grace 0 disables recovery entirely → 401', async () => {
const ctx = await hostCtx('alice')
const now = Date.now()
const res = await post(
ctx,
await mintLeaf(ctx, new Date(now - 2 * DAY_MS), new Date(now - DAY_MS)),
{ expiredRenewGraceMs: 0 },
)
expect(res.statusCode).toBe(401)
})
/**
* THE load-bearing test for this route. nginx no longer validates the chain here, so a forged
* self-signed cert carrying a correct-looking SPIFFE SAN must be rejected by the control-plane
* alone. If this ever goes green-to-red, `/recover` becomes an unauthenticated cert vending machine.
*/
test('a self-signed cert with a FORGED SPIFFE SAN is refused → 401', async () => {
const ctx = await hostCtx('alice')
const rogueCa = await makeP256Ca('rogue-CA')
const now = Date.now()
const rogue = await mintLeaf(
ctx,
new Date(now - 9 * DAY_MS),
new Date(now - 8 * DAY_MS),
rogueCa.caSigner,
)
const res = await post(ctx, rogue)
expect(res.statusCode).toBe(401)
})
test('recovery NEVER bypasses revocation — revoked host → 403', async () => {
const ctx = await hostCtx('alice')
const now = Date.now()
const cert = await mintLeaf(ctx, new Date(now - 9 * DAY_MS), new Date(now - 8 * DAY_MS))
const host = await ctx.hosts.getHostBySubdomain('alice')
await ctx.hosts.setHostStatus(host!.hostId, 'revoked')
const res = await post(ctx, cert)
expect(res.statusCode).toBe(403)
})
test('grace covers notAfter ONLY — a not-yet-valid leaf is refused → 401', async () => {
const ctx = await hostCtx('alice')
const now = Date.now()
const res = await post(ctx, await mintLeaf(ctx, new Date(now + DAY_MS), new Date(now + 2 * DAY_MS)))
expect(res.statusCode).toBe(401)
})
test('a still-valid leaf may also use /recover → 201', async () => {
const ctx = await hostCtx('alice')
const res = await post(ctx, ctx.currentCertDer)
expect(res.statusCode).toBe(201)
})
test('a missing cert field is rejected → 400 (uniform schema reject, same as a missing csr)', async () => {
const ctx = await hostCtx('alice')
const app = appWith(hostDeps(ctx, { hostCaAnchorsDer: [ctx.ca.caDer] }))
await app.ready()
const res = await app.inject({ method: 'POST', url: '/recover', payload: { csr: b64Csr(ctx.csr) } })
// The module answers with uniform rejects that never say which check failed: 400 for a malformed
// body, 401 for a cert that parses but is not trusted, 403 for a trusted cert that is not allowed.
expect(res.statusCode).toBe(400)
})
/**
* The header is the mTLS terminator's channel. On `/recover` the cert comes from the body, so a
* client-supplied header must not be able to substitute an identity.
*/
test('an x-client-cert HEADER cannot override the body identity → 401', async () => {
const ctx = await hostCtx('alice')
const rogueCa = await makeP256Ca('rogue-CA')
const now = Date.now()
const rogue = await mintLeaf(ctx, new Date(now - 9 * DAY_MS), new Date(now - 8 * DAY_MS), rogueCa.caSigner)
const app = appWith(hostDeps(ctx, { hostCaAnchorsDer: [ctx.ca.caDer] }))
await app.ready()
const res = await app.inject({
method: 'POST',
url: '/recover',
headers: { 'x-client-cert': certHeader(ctx.currentCertDer) },
payload: { cert: b64(rogue), csr: b64Csr(ctx.csr) },
})
expect(res.statusCode).toBe(401)
})
})