diff --git a/docs/guides/native-hosts.md b/docs/guides/native-hosts.md index d15c9c8..dd54e62 100644 --- a/docs/guides/native-hosts.md +++ b/docs/guides/native-hosts.md @@ -513,11 +513,15 @@ access to operator pages. Game telemetry uses a separate `EC_TELEMETRY_TOKEN`, configured in each reporter's authorization header; see the [telemetry contract](beta-test-plans.md#game-reporter-protocol). -Hosts offers credential rotation and revocation. Rotation persists the new secret -on the agent before switching controller credentials, so a lost response can be -retried using the same proposed secret. Revocation affects one host only and does -not stop its running game containers. Re-enrollment requires a fresh token and an -explicit installer `--reenroll` when local credentials already exist. +Hosts offers credential rotation and revocation. Replacing a credential requires +an open administrator-requested rotation operation for that host. The controller +replaces the credential and completes that operation in one transaction. Rotation +persists the new secret on the agent before switching controller credentials, so +a lost response can be retried using the same proposed secret. A retry of the +active secret only acknowledges it; it cannot authorize another replacement. +Revocation affects one host only and does not stop its running game containers. +Re-enrollment requires a fresh token and an explicit installer `--reenroll` when +local credentials already exist. ## Host and game-file API diff --git a/src/lib/server/hosts/index.ts b/src/lib/server/hosts/index.ts index 50d7bbf..5cb2c3c 100644 --- a/src/lib/server/hosts/index.ts +++ b/src/lib/server/hosts/index.ts @@ -879,11 +879,26 @@ export function rotateCredential(hostId: string, nextCredential?: string): strin if (existing) { if (existing.host_id !== hostId || existing.revoked_at) throw new Error('This credential cannot be reused.'); + // A lost rotation response is retried with the persisted replacement. + // This acknowledges an active credential without authorizing another rotation. return nextCredential; } } + const operation = database() + .prepare( + `SELECT id FROM ec_host_operations WHERE host_id=? AND kind='credentials.rotate' + AND state IN ('pending','accepted','running','unknown') ORDER BY created_at,rowid LIMIT 1` + ) + .get(hostId) as { id: string } | undefined; + if (!operation) + throw new Error('Credential rotation requires an open administrator request.'); revokeCredentials(hostId); - return issueCredential(hostId, nextCredential); + const credential = issueCredential(hostId, nextCredential); + reportHostOperationStatus(hostId, operation.id, { + state: 'completed', + result: { rotated: true } + }); + return credential; })(); } export function heartbeat(hostId: string, value: unknown): Host { diff --git a/tests/unit/host-credential-rotation.test.ts b/tests/unit/host-credential-rotation.test.ts new file mode 100644 index 0000000..00e862f --- /dev/null +++ b/tests/unit/host-credential-rotation.test.ts @@ -0,0 +1,134 @@ +import { randomBytes } from 'node:crypto'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { newFixture, type Fixture } from '../helpers/fixture.js'; + +let fixture: Fixture; +beforeEach(async () => { + vi.resetModules(); + fixture = await newFixture([]); +}); +afterEach(async () => { + await fixture.cleanup(); +}); + +const newCredential = () => 'ec_host_' + randomBytes(32).toString('base64url'); +async function setup() { + const hosts = await import('../../src/lib/server/hosts/index.js'); + const { db } = await import('../../src/lib/server/db/index.js'); + const { POST } = await import('../../src/routes/api/agent/credentials/+server.js'); + const host = hosts.createHost({ name: 'Game host' }); + const { credential } = hosts.enrollHost(hosts.createEnrollment(host.id).token, {}); + const rotate = (next: string, token = credential) => + POST({ + request: new Request('https://ec.test/api/agent/credentials', { + method: 'POST', + headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' }, + body: JSON.stringify({ credential: next }) + }) + }); + return { ...hosts, db, host, credential, rotate }; +} + +describe('administrator-authorized agent credential rotation', () => { + it.each(['absent', 'other host', 'other kind', 'completed', 'failed'])( + 'rejects rotation when the authorization is %s without changing credentials', + async (authorization) => { + const api = await setup(); + if (authorization !== 'absent') { + const hostId = + authorization === 'other host' + ? api.createHost({ name: 'Other host' }).id + : api.host.id; + const operation = api.queueHostOperation( + hostId, + authorization === 'other kind' ? 'host.inspect' : 'credentials.rotate', + {} + ); + if (authorization === 'completed' || authorization === 'failed') + api.reportHostOperationStatus(hostId, operation.id, { state: authorization }); + } + const next = newCredential(); + const response = await api.rotate(next); + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ + error: 'Credential rotation requires an open administrator request.' + }); + expect(api.authenticateCredential(api.credential)?.id).toBe(api.host.id); + expect(api.authenticateCredential(next)).toBeNull(); + } + ); + + it.each(['pending', 'accepted', 'running', 'unknown'])( + 'consumes a %s rotation request and records completion before the agent receipt', + async (state) => { + const api = await setup(); + const { POST } = await import('../../src/routes/api/hosts/[id]/credentials/+server.js'); + const queued = POST({ + request: new Request(`https://ec.test/api/hosts/${api.host.id}/credentials`, { + method: 'POST' + }), + params: { id: api.host.id } + }); + expect(queued.status).toBe(202); + const { operationId } = await queued.json(); + if (state !== 'pending') + api.reportHostOperationStatus(api.host.id, operationId, { state }); + const next = newCredential(); + expect((await api.rotate(next)).status).toBe(200); + expect(api.authenticateCredential(api.credential)).toBeNull(); + expect(api.authenticateCredential(next)?.id).toBe(api.host.id); + expect(api.getHostOperationStatus(operationId)).toMatchObject({ + state: 'completed', + result: { rotated: true } + }); + // A new token cannot reuse the consumed authorization. + expect((await api.rotate(newCredential(), next)).status).toBe(400); + // Recovery after a lost response only acknowledges the active token. + expect((await api.rotate(next, next)).status).toBe(200); + api.reportHostOperationStatus(api.host.id, operationId, { + state: 'completed', + result: { rotated: true } + }); + expect( + api + .db() + .prepare( + "SELECT state FROM ec_host_operation_events WHERE operation_id=? AND state='completed'" + ) + .all(operationId) + ).toEqual([{ state: 'completed' }]); + const stored = JSON.stringify( + api.db().prepare('SELECT * FROM ec_host_operations WHERE id=?').get(operationId) + ); + expect(stored).not.toContain(next); + } + ); + + it('does not consume a new request when acknowledging the active credential', async () => { + const api = await setup(); + const first = api.queueHostOperation(api.host.id, 'credentials.rotate', {}); + const second = api.queueHostOperation(api.host.id, 'credentials.rotate', {}); + const next = newCredential(); + expect((await api.rotate(next)).status).toBe(200); + expect(api.getHostOperationStatus(first.id)?.state).toBe('completed'); + expect(api.getHostOperationStatus(second.id)?.state).toBe('pending'); + expect((await api.rotate(next, next)).status).toBe(200); + expect(api.getHostOperationStatus(second.id)?.state).toBe('pending'); + expect((await api.rotate(newCredential(), next)).status).toBe(200); + expect(api.getHostOperationStatus(second.id)?.state).toBe('completed'); + }); + + it('rolls back replacement and revocation if operation completion fails', async () => { + const api = await setup(); + const operation = api.queueHostOperation(api.host.id, 'credentials.rotate', {}); + api.db().exec(`CREATE TRIGGER reject_rotation_completion + BEFORE UPDATE OF state ON ec_host_operations + WHEN NEW.kind='credentials.rotate' AND NEW.state='completed' + BEGIN SELECT RAISE(ABORT, 'completion failed'); END`); + const next = newCredential(); + expect((await api.rotate(next)).status).toBe(400); + expect(api.authenticateCredential(api.credential)?.id).toBe(api.host.id); + expect(api.authenticateCredential(next)).toBeNull(); + expect(api.getHostOperationStatus(operation.id)?.state).toBe('pending'); + }); +}); diff --git a/tests/unit/host-request-cost.test.ts b/tests/unit/host-request-cost.test.ts index eb6a8b7..d097537 100644 --- a/tests/unit/host-request-cost.test.ts +++ b/tests/unit/host-request-cost.test.ts @@ -126,6 +126,7 @@ describe('agent credential usage writes', () => { await api.withHostRequestContext(() => { expect(api.authenticateCredential(api.credential)?.id).toBe(api.hostId); vi.setSystemTime(epoch + 1000); + api.queueHostOperation(api.hostId, 'credentials.rotate', {}); const replacement = api.rotateCredential(api.hostId); expect(api.authenticateCredential(api.credential)).toBeNull(); expect(api.authenticateCredential(replacement)?.id).toBe(api.hostId); diff --git a/tests/unit/native-hosts.test.ts b/tests/unit/native-hosts.test.ts index c52f51f..64fa2b4 100644 --- a/tests/unit/native-hosts.test.ts +++ b/tests/unit/native-hosts.test.ts @@ -433,6 +433,7 @@ describe('native host enrollment and scoped authentication', () => { const api = await enrolled(); const other = api.createHost({ name: 'Other' }); const second = api.enrollHost(api.createEnrollment(other.id).token, inventory); + api.queueHostOperation(api.host.id, 'credentials.rotate', {}); const rotated = api.rotateCredential(api.host.id); expect(api.authenticateCredential(api.credentials.credential)).toBeNull(); expect(api.authenticateCredential(rotated)?.id).toBe(api.host.id); @@ -472,6 +473,7 @@ describe('native host enrollment and scoped authentication', () => { it('recovers credential rotation when acknowledgement is lost using the agent-persisted replacement', async () => { const api = await enrolled(); const next = 'ec_host_' + randomBytes(32).toString('base64url'); + api.queueHostOperation(api.host.id, 'credentials.rotate', {}); expect(api.rotateCredential(api.host.id, next)).toBe(next); expect(api.authenticateCredential(api.credentials.credential)).toBeNull(); expect(api.authenticateCredential(next)?.id).toBe(api.host.id);