Require administrator-requested host credential rotation (Refs #97)
CI / checks (push) Successful in 5m24s
CI / checks (push) Successful in 5m24s
This commit is contained in:
@@ -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
|
configured in each reporter's authorization header; see the
|
||||||
[telemetry contract](beta-test-plans.md#game-reporter-protocol).
|
[telemetry contract](beta-test-plans.md#game-reporter-protocol).
|
||||||
|
|
||||||
Hosts offers credential rotation and revocation. Rotation persists the new secret
|
Hosts offers credential rotation and revocation. Replacing a credential requires
|
||||||
on the agent before switching controller credentials, so a lost response can be
|
an open administrator-requested rotation operation for that host. The controller
|
||||||
retried using the same proposed secret. Revocation affects one host only and does
|
replaces the credential and completes that operation in one transaction. Rotation
|
||||||
not stop its running game containers. Re-enrollment requires a fresh token and an
|
persists the new secret on the agent before switching controller credentials, so
|
||||||
explicit installer `--reenroll` when local credentials already exist.
|
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
|
## Host and game-file API
|
||||||
|
|
||||||
|
|||||||
@@ -879,11 +879,26 @@ export function rotateCredential(hostId: string, nextCredential?: string): strin
|
|||||||
if (existing) {
|
if (existing) {
|
||||||
if (existing.host_id !== hostId || existing.revoked_at)
|
if (existing.host_id !== hostId || existing.revoked_at)
|
||||||
throw new Error('This credential cannot be reused.');
|
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;
|
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);
|
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 {
|
export function heartbeat(hostId: string, value: unknown): Host {
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -126,6 +126,7 @@ describe('agent credential usage writes', () => {
|
|||||||
await api.withHostRequestContext(() => {
|
await api.withHostRequestContext(() => {
|
||||||
expect(api.authenticateCredential(api.credential)?.id).toBe(api.hostId);
|
expect(api.authenticateCredential(api.credential)?.id).toBe(api.hostId);
|
||||||
vi.setSystemTime(epoch + 1000);
|
vi.setSystemTime(epoch + 1000);
|
||||||
|
api.queueHostOperation(api.hostId, 'credentials.rotate', {});
|
||||||
const replacement = api.rotateCredential(api.hostId);
|
const replacement = api.rotateCredential(api.hostId);
|
||||||
expect(api.authenticateCredential(api.credential)).toBeNull();
|
expect(api.authenticateCredential(api.credential)).toBeNull();
|
||||||
expect(api.authenticateCredential(replacement)?.id).toBe(api.hostId);
|
expect(api.authenticateCredential(replacement)?.id).toBe(api.hostId);
|
||||||
|
|||||||
@@ -433,6 +433,7 @@ describe('native host enrollment and scoped authentication', () => {
|
|||||||
const api = await enrolled();
|
const api = await enrolled();
|
||||||
const other = api.createHost({ name: 'Other' });
|
const other = api.createHost({ name: 'Other' });
|
||||||
const second = api.enrollHost(api.createEnrollment(other.id).token, inventory);
|
const second = api.enrollHost(api.createEnrollment(other.id).token, inventory);
|
||||||
|
api.queueHostOperation(api.host.id, 'credentials.rotate', {});
|
||||||
const rotated = api.rotateCredential(api.host.id);
|
const rotated = api.rotateCredential(api.host.id);
|
||||||
expect(api.authenticateCredential(api.credentials.credential)).toBeNull();
|
expect(api.authenticateCredential(api.credentials.credential)).toBeNull();
|
||||||
expect(api.authenticateCredential(rotated)?.id).toBe(api.host.id);
|
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 () => {
|
it('recovers credential rotation when acknowledgement is lost using the agent-persisted replacement', async () => {
|
||||||
const api = await enrolled();
|
const api = await enrolled();
|
||||||
const next = 'ec_host_' + randomBytes(32).toString('base64url');
|
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.rotateCredential(api.host.id, next)).toBe(next);
|
||||||
expect(api.authenticateCredential(api.credentials.credential)).toBeNull();
|
expect(api.authenticateCredential(api.credentials.credential)).toBeNull();
|
||||||
expect(api.authenticateCredential(next)?.id).toBe(api.host.id);
|
expect(api.authenticateCredential(next)?.id).toBe(api.host.id);
|
||||||
|
|||||||
Reference in New Issue
Block a user