Require administrator-requested host credential rotation (Refs #97)
CI / checks (push) Successful in 5m24s

This commit is contained in:
2026-10-12 19:13:29 -04:00
parent 272055a1ed
commit 1b908f891f
5 changed files with 162 additions and 6 deletions
+9 -5
View File
@@ -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
+16 -1
View File
@@ -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 {
+134
View File
@@ -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');
});
});
+1
View File
@@ -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);
+2
View File
@@ -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);