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
+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);