fix(tunnel): close the three leftovers from the renewal-deadlock fix

1. `.gitignore` swallowed a source file. `agent/src/dist/buildBinary.ts` is the
   packaging config, not build output, but the blanket `dist/` rule meant it was
   never committed — a fresh clone could neither typecheck `agent/src/index.ts`
   nor import it from the committed `agent/test/buildBinary.test.ts`. Re-included
   the DIRECTORY first (git does not descend into an excluded one, so un-ignoring
   just the file would not have worked) and committed the file. Verified build
   output under `agent/dist/`, `dist/`, `public/build/` is still ignored.

2. Tunnel logs named no host. `pair` learned hostId/subdomain from the enroll
   response and threw them away, so the long-running `run` process logged
   `{"subdomain":null,"hostId":null}` — including all 6380 warnings during the
   8-day outage, at exactly the moment you want to know which host. New
   `config/hostRecord.ts` persists them at enrol; `resolveHostIdentity` resolves
   config > record > the subdomain embedded in the leaf's SPIFFE SAN, so hosts
   enrolled before the record existed get an identifier back without re-pairing.

3. The phone track had no recovery path. Added `POST /device/:id/recover`,
   mirroring the host route: cert in the body, device-CA path validation, SPIFFE
   parse, `notBefore` never graced, and the full registry check (active + same
   account + `:id` matches the cert + same key) — only `notAfter` is relaxed.

Verified: agent 300/300, control-plane 296/296, tsc clean on both.
NOTE: the iOS/Android clients are not wired to call /device/:id/recover yet —
the server capability exists, the client-side trigger does not.
This commit is contained in:
Yaojia Wang
2026-07-29 10:40:15 +02:00
parent befe677759
commit 2a602d5289
9 changed files with 478 additions and 3 deletions

View File

@@ -672,3 +672,132 @@ describe('CP6d POST /recover — expired-leaf recovery', () => {
expect(res.statusCode).toBe(401)
})
})
/**
* CP6e · POST /device/:id/recover — the phone-track counterpart of `/recover`.
*
* A device certificate hits the identical deadlock as a host leaf: `/device/:id/renew` is
* mTLS-authenticated by the cert being renewed, and nginx refuses to forward an expired client cert
* in any mode. Without this route a phone whose cert lapsed has to be re-enrolled by hand.
*/
describe('CP6e POST /device/:id/recover — expired device-cert recovery', () => {
async function mintDeviceLeaf(
ctx: DeviceCtx,
accountId: string,
notBefore: Date,
notAfter: Date,
signer = ctx.ca.caSigner,
): Promise<Uint8Array> {
const spiffe = `spiffe://relay.terminal.yaojia.wang/account/${accountId}/device/${ctx.deviceId}`
return assembleCertificate({
subjectPublicKey: ctx.spki,
subject: `CN=web-terminal-device`,
issuer: ctx.ca.caCert.subjectName,
serialNumber: Uint8Array.from([0x0d]),
notBefore,
notAfter,
extensions: [
new x509.SubjectAlternativeNameExtension([
{ type: 'dns', value: `alice.${ZONE}` },
{ 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: DeviceCtx, cert: Uint8Array, extra?: Partial<RenewDeps>) {
const app = appWith({ ...deviceDeps(ctx), deviceCaAnchorsDer: [ctx.ca.caDer], ...extra })
await app.ready()
return app.inject({
method: 'POST',
url: `/device/${ctx.deviceId}/recover`,
payload: { cert: b64(cert), csr: b64Csr(ctx.csr) },
})
}
test('a device cert expired INSIDE the grace window is re-issued → 201', async () => {
const ctx = await deviceCtx('alice')
const rec = await ctx.devices.getDevice(ctx.deviceId)
const now = Date.now()
const res = await post(
ctx,
await mintDeviceLeaf(ctx, rec!.accountId, 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 device cert expired BEYOND the grace window is refused → 401', async () => {
const ctx = await deviceCtx('alice')
const rec = await ctx.devices.getDevice(ctx.deviceId)
const now = Date.now()
const res = await post(
ctx,
await mintDeviceLeaf(ctx, rec!.accountId, new Date(now - 60 * DAY_MS), new Date(now - 31 * DAY_MS)),
)
expect(res.statusCode).toBe(401)
})
/** Same load-bearing check as the host route: nginx validates nothing on this path. */
test('a self-signed cert with a FORGED device SPIFFE SAN is refused → 401', async () => {
const ctx = await deviceCtx('alice')
const rogueCa = await makeP256Ca('rogue-CA')
const rec = await ctx.devices.getDevice(ctx.deviceId)
const now = Date.now()
const res = await post(
ctx,
await mintDeviceLeaf(
ctx,
rec!.accountId,
new Date(now - 9 * DAY_MS),
new Date(now - 8 * DAY_MS),
rogueCa.caSigner,
),
)
expect(res.statusCode).toBe(401)
})
test('recovery NEVER bypasses revocation — revoked device → 403', async () => {
const ctx = await deviceCtx('alice')
const rec = await ctx.devices.getDevice(ctx.deviceId)
const now = Date.now()
const cert = await mintDeviceLeaf(ctx, rec!.accountId, new Date(now - 9 * DAY_MS), new Date(now - 8 * DAY_MS))
await ctx.devices.setDeviceStatus(ctx.deviceId, 'revoked')
const res = await post(ctx, cert)
expect(res.statusCode).toBe(403)
})
test('the path :id must match the cert identity — no cross-device smuggling → 403', async () => {
const ctx = await deviceCtx('alice')
const rec = await ctx.devices.getDevice(ctx.deviceId)
const now = Date.now()
const cert = await mintDeviceLeaf(ctx, rec!.accountId, new Date(now - 9 * DAY_MS), new Date(now - 8 * DAY_MS))
const app = appWith({ ...deviceDeps(ctx), deviceCaAnchorsDer: [ctx.ca.caDer] })
await app.ready()
const res = await app.inject({
method: 'POST',
url: `/device/someone-else/recover`,
payload: { cert: b64(cert), csr: b64Csr(ctx.csr) },
})
expect(res.statusCode).toBe(403)
})
test('grace 0 disables device recovery entirely → 401', async () => {
const ctx = await deviceCtx('alice')
const rec = await ctx.devices.getDevice(ctx.deviceId)
const now = Date.now()
const res = await post(
ctx,
await mintDeviceLeaf(ctx, rec!.accountId, new Date(now - 2 * DAY_MS), new Date(now - DAY_MS)),
{ expiredRenewGraceMs: 0 },
)
expect(res.statusCode).toBe(401)
})
})