mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 09:48:58 -04:00
[Fleet] Rotate key pair API and Service (#155252)
## Summary Allows a fleet user to reset saved object encrypted key if/when Kibana is compromised via an API call. ### How to test it - Run Kibana locally and check for key pair to be generated and assigned to the agent policies. Use the Kibana dev console and run ```json5 GET .kibana/_search { "query": { "bool": { "filter": [ { "term": { "type": "fleet-message-signing-keys" } } ] } } } ``` - Request a `POST` to the API route `api/fleet/message_signing_service/rotate_key_pair?acknowledge=true` - Check that the key pair has been rotated in SO. ### Outdated - and in the existing agent policies in the `.kibana` index using the query above. - Verify that the policies also have the new public key as `agent.protection.signing_key` in the `.fleet-policies` index ```json5 GET .fleet-policies/_search { "query": { "bool": { "filter": [ { "term": { "policy_id": "<policy_id>" } } ] } } } ``` Note: Viewing agent policies using the Fleet->Agent Policies->View Policy at `http://<kibanaURL>/app/fleet/policies` should also show the same info as the above query. --------- Co-authored-by: Ashokaditya Co-authored-by: Ashokaditya Co-authored-by: Joey F. Poon
This commit is contained in:
parent
a018d38687
commit
b92f5e94fd
16 changed files with 415 additions and 2 deletions
|
@ -171,6 +171,11 @@ export const AGENTS_SETUP_API_ROUTES = {
|
|||
CREATE_PATTERN: `${API_ROOT}/agents/setup`,
|
||||
};
|
||||
|
||||
// Message signing service
|
||||
export const MESSAGE_SIGNING_SERVICE_API_ROUTES = {
|
||||
ROTATE_KEY_PAIR: `${API_ROOT}/message_signing_service/rotate_key_pair`,
|
||||
};
|
||||
|
||||
export const SETUP_API_ROUTE = `${API_ROOT}/setup`;
|
||||
|
||||
export const INSTALL_SCRIPT_API_ROUTES = `${API_ROOT}/install/{osType}`;
|
||||
|
|
|
@ -3402,6 +3402,46 @@
|
|||
},
|
||||
"parameters": []
|
||||
},
|
||||
"/message_signing_service/rotate_key_pair": {
|
||||
"post": {
|
||||
"summary": "Rotate key pair",
|
||||
"tags": [
|
||||
"Message Signing Service"
|
||||
],
|
||||
"operationId": "rotate-key-pair",
|
||||
"parameters": [
|
||||
{
|
||||
"schema": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"in": "query",
|
||||
"name": "acknowledge",
|
||||
"required": true,
|
||||
"description": "When set to true, rotate key pair is done. If set to false or missing, it returns an error."
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"message": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"$ref": "#/components/responses/error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/data_streams": {
|
||||
"get": {
|
||||
"summary": "List data streams",
|
||||
|
|
|
@ -2113,6 +2113,33 @@ paths:
|
|||
parameters:
|
||||
- $ref: '#/components/parameters/kbn_xsrf'
|
||||
parameters: []
|
||||
/message_signing_service/rotate_key_pair:
|
||||
post:
|
||||
summary: Rotate key pair
|
||||
tags:
|
||||
- Message Signing Service
|
||||
operationId: rotate-key-pair
|
||||
parameters:
|
||||
- schema:
|
||||
type: boolean
|
||||
in: query
|
||||
name: acknowledge
|
||||
required: true
|
||||
description: >-
|
||||
When set to true, rotate key pair is done. If set to false or
|
||||
missing, it returns an error.
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
'400':
|
||||
$ref: '#/components/responses/error'
|
||||
/data_streams:
|
||||
get:
|
||||
summary: List data streams
|
||||
|
|
|
@ -105,6 +105,10 @@ paths:
|
|||
/agent_policies/delete:
|
||||
$ref: paths/agent_policies@delete.yaml
|
||||
|
||||
# Message signing service
|
||||
/message_signing_service/rotate_key_pair:
|
||||
$ref: paths/message_signing_service@rotate_key_pair.yaml
|
||||
|
||||
# Data streams endpoints
|
||||
/data_streams:
|
||||
$ref: paths/data_streams.yaml
|
||||
|
|
|
@ -0,0 +1,24 @@
|
|||
post:
|
||||
summary: Rotate key pair
|
||||
tags:
|
||||
- Message Signing Service
|
||||
operationId: rotate-key-pair
|
||||
parameters:
|
||||
- schema:
|
||||
type: boolean
|
||||
in: query
|
||||
name: acknowledge
|
||||
required: true
|
||||
description: When set to true, rotate key pair is done. If set to false or missing, it returns an error.
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
'400':
|
||||
$ref: ../components/responses/error.yaml
|
|
@ -74,6 +74,8 @@ export {
|
|||
FLEET_PROXY_SAVED_OBJECT_TYPE,
|
||||
// Authz
|
||||
ENDPOINT_PRIVILEGES,
|
||||
// Message signing service
|
||||
MESSAGE_SIGNING_SERVICE_API_ROUTES,
|
||||
} from '../../common/constants';
|
||||
|
||||
export {
|
||||
|
|
|
@ -167,5 +167,7 @@ export function createMessageSigningServiceMock() {
|
|||
generateKeyPair: jest.fn(),
|
||||
sign: jest.fn(),
|
||||
getPublicKey: jest.fn(),
|
||||
removeKeyPair: jest.fn(),
|
||||
rotateKeyPair: jest.fn(),
|
||||
};
|
||||
}
|
||||
|
|
|
@ -24,6 +24,7 @@ import { registerRoutes as registerDownloadSourcesRoutes } from './download_sour
|
|||
import { registerRoutes as registerHealthCheckRoutes } from './health_check';
|
||||
import { registerRoutes as registerFleetServerHostRoutes } from './fleet_server_policy_config';
|
||||
import { registerRoutes as registerFleetProxiesRoutes } from './fleet_proxies';
|
||||
import { registerRoutes as registerMessageSigningServiceRoutes } from './message_signing_service';
|
||||
|
||||
export async function registerRoutes(fleetAuthzRouter: FleetAuthzRouter, config: FleetConfigType) {
|
||||
// Always register app routes for permissions checking
|
||||
|
@ -43,6 +44,7 @@ export async function registerRoutes(fleetAuthzRouter: FleetAuthzRouter, config:
|
|||
registerFleetProxiesRoutes(fleetAuthzRouter);
|
||||
registerDownloadSourcesRoutes(fleetAuthzRouter);
|
||||
registerHealthCheckRoutes(fleetAuthzRouter);
|
||||
registerMessageSigningServiceRoutes(fleetAuthzRouter);
|
||||
|
||||
// Conditional config routes
|
||||
if (config.agents.enabled) {
|
||||
|
|
|
@ -0,0 +1,99 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import type { AwaitedProperties } from '@kbn/utility-types';
|
||||
import { httpServerMock, coreMock } from '@kbn/core/server/mocks';
|
||||
import type { KibanaRequest } from '@kbn/core/server';
|
||||
|
||||
import { createAppContextStartContractMock, xpackMocks } from '../../mocks';
|
||||
import { appContextService } from '../../services/app_context';
|
||||
import type { FleetRequestHandlerContext } from '../../types';
|
||||
|
||||
import { rotateKeyPairHandler } from './handlers';
|
||||
|
||||
describe('FleetMessageSigningServiceHandler', () => {
|
||||
let context: AwaitedProperties<Omit<FleetRequestHandlerContext, 'resolve'>>;
|
||||
let response: ReturnType<typeof httpServerMock.createResponseFactory>;
|
||||
let request: KibanaRequest<
|
||||
undefined,
|
||||
Readonly<{} & { acknowledge: boolean }> | undefined,
|
||||
undefined,
|
||||
any
|
||||
>;
|
||||
|
||||
beforeEach(async () => {
|
||||
context = xpackMocks.createRequestHandlerContext();
|
||||
request = httpServerMock.createKibanaRequest({
|
||||
method: 'post',
|
||||
path: '/api/fleet/message_signing_service/rotate_key_pair',
|
||||
query: { acknowledge: true },
|
||||
params: {},
|
||||
body: {},
|
||||
});
|
||||
response = httpServerMock.createResponseFactory();
|
||||
// prevents `Logger not set.` and other appContext errors
|
||||
appContextService.start(createAppContextStartContractMock());
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
appContextService.stop();
|
||||
});
|
||||
|
||||
it('POST /message_signing_service/rotate_key_pair?acknowledge=true succeeds with an 200 with `acknowledge=true`', async () => {
|
||||
(appContextService.getMessageSigningService()?.rotateKeyPair as jest.Mock).mockReturnValue(
|
||||
true
|
||||
);
|
||||
|
||||
await rotateKeyPairHandler(
|
||||
coreMock.createCustomRequestHandlerContext(context),
|
||||
request,
|
||||
response
|
||||
);
|
||||
expect(response.ok).toHaveBeenCalledWith({
|
||||
body: {
|
||||
message: 'Key pair rotated successfully.',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it(`POST /message_signing_service/rotate_key_pair?acknowledge=true fails with an 500 with "acknowledge=true" when rotateKeyPair doesn't succeed`, async () => {
|
||||
(appContextService.getMessageSigningService()?.rotateKeyPair as jest.Mock).mockReturnValue(
|
||||
false
|
||||
);
|
||||
|
||||
await rotateKeyPairHandler(
|
||||
coreMock.createCustomRequestHandlerContext(context),
|
||||
request,
|
||||
response
|
||||
);
|
||||
expect(response.customError).toHaveBeenCalledWith({
|
||||
statusCode: 500,
|
||||
body: {
|
||||
message: 'Failed to rotate key pair!',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it(`POST /message_signing_service/rotate_key_pair?acknowledge=true fails with an 500 with "acknowledge=true" when no messaging service`, async () => {
|
||||
(appContextService.getMessageSigningService()?.rotateKeyPair as jest.Mock).mockReturnValue(
|
||||
undefined
|
||||
);
|
||||
|
||||
await rotateKeyPairHandler(
|
||||
coreMock.createCustomRequestHandlerContext(context),
|
||||
request,
|
||||
response
|
||||
);
|
||||
expect(response.customError).toHaveBeenCalledWith({
|
||||
statusCode: 500,
|
||||
body: {
|
||||
message: 'Failed to rotate key pair!',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,42 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import type { TypeOf } from '@kbn/config-schema';
|
||||
|
||||
import type { FleetRequestHandler } from '../../types';
|
||||
|
||||
import { defaultFleetErrorHandler } from '../../errors';
|
||||
import { appContextService } from '../../services';
|
||||
import type { RotateKeyPairSchema } from '../../types/rest_spec/message_signing_service';
|
||||
|
||||
export const rotateKeyPairHandler: FleetRequestHandler<
|
||||
undefined,
|
||||
TypeOf<typeof RotateKeyPairSchema.query>,
|
||||
undefined
|
||||
> = async (_, __, response) => {
|
||||
try {
|
||||
const rotateKeyPairResponse = await appContextService
|
||||
.getMessageSigningService()
|
||||
?.rotateKeyPair();
|
||||
|
||||
if (!rotateKeyPairResponse) {
|
||||
return response.customError({
|
||||
statusCode: 500,
|
||||
body: {
|
||||
message: 'Failed to rotate key pair!',
|
||||
},
|
||||
});
|
||||
}
|
||||
return response.ok({
|
||||
body: {
|
||||
message: 'Key pair rotated successfully.',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return defaultFleetErrorHandler({ error, response });
|
||||
}
|
||||
};
|
|
@ -0,0 +1,26 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import type { FleetAuthzRouter } from '../../services/security';
|
||||
import { MESSAGE_SIGNING_SERVICE_API_ROUTES } from '../../constants';
|
||||
import { RotateKeyPairSchema } from '../../types';
|
||||
|
||||
import { rotateKeyPairHandler } from './handlers';
|
||||
|
||||
export const registerRoutes = (router: FleetAuthzRouter) => {
|
||||
// Rotate fleet message signing key pair
|
||||
router.post(
|
||||
{
|
||||
path: MESSAGE_SIGNING_SERVICE_API_ROUTES.ROTATE_KEY_PAIR,
|
||||
validate: RotateKeyPairSchema,
|
||||
fleetAuthz: {
|
||||
fleet: { all: true },
|
||||
},
|
||||
},
|
||||
rotateKeyPairHandler
|
||||
);
|
||||
};
|
|
@ -35,6 +35,23 @@ describe('MessageSigningService', () => {
|
|||
});
|
||||
}
|
||||
|
||||
function mockCreatePointInTimeFinderAsInternalUserOnce(savedObjects: unknown[] = []) {
|
||||
esoClientMock.createPointInTimeFinderDecryptedAsInternalUser = jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
close: jest.fn(),
|
||||
find: function* asyncGenerator() {
|
||||
yield { saved_objects: savedObjects };
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
close: jest.fn(),
|
||||
find: function* asyncGenerator() {
|
||||
yield { saved_objects: [] };
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function setupMocks(canEncrypt = true) {
|
||||
const mockContext = createAppContextStartContractMock();
|
||||
mockContext.encryptedSavedObjectsSetup = encryptedSavedObjectsMock.createSetup({
|
||||
|
@ -74,19 +91,49 @@ describe('MessageSigningService', () => {
|
|||
it('can correctly generate key pair if none exist', async () => {
|
||||
mockCreatePointInTimeFinderAsInternalUser();
|
||||
|
||||
await messageSigningService.generateKeyPair();
|
||||
const generateKeyPairResponse = await messageSigningService.generateKeyPair();
|
||||
expect(soClientMock.create).toBeCalledWith(MESSAGE_SIGNING_KEYS_SAVED_OBJECT_TYPE, {
|
||||
private_key: expect.any(String),
|
||||
public_key: expect.any(String),
|
||||
passphrase: expect.any(String),
|
||||
});
|
||||
|
||||
expect(generateKeyPairResponse).toEqual({
|
||||
passphrase: expect.any(String),
|
||||
privateKey: expect.any(String),
|
||||
publicKey: expect.any(String),
|
||||
});
|
||||
});
|
||||
|
||||
it('can correctly rotate existing key pair', async () => {
|
||||
mockCreatePointInTimeFinderAsInternalUserOnce([keyPairObj]);
|
||||
|
||||
const rotateKeyPairResponse = await messageSigningService.rotateKeyPair();
|
||||
|
||||
expect(soClientMock.delete).toBeCalledWith(
|
||||
MESSAGE_SIGNING_KEYS_SAVED_OBJECT_TYPE,
|
||||
keyPairObj.id
|
||||
);
|
||||
expect(soClientMock.create).toBeCalledWith(MESSAGE_SIGNING_KEYS_SAVED_OBJECT_TYPE, {
|
||||
private_key: expect.any(String),
|
||||
public_key: expect.any(String),
|
||||
passphrase: expect.any(String),
|
||||
});
|
||||
|
||||
expect(rotateKeyPairResponse).toEqual(true);
|
||||
});
|
||||
|
||||
it('does not generate key pair if one exists', async () => {
|
||||
mockCreatePointInTimeFinderAsInternalUser([keyPairObj]);
|
||||
|
||||
await messageSigningService.generateKeyPair();
|
||||
const generateKeyPairResponse = await messageSigningService.generateKeyPair();
|
||||
expect(soClientMock.create).not.toBeCalled();
|
||||
|
||||
expect(generateKeyPairResponse).toEqual({
|
||||
passphrase: expect.any(String),
|
||||
privateKey: expect.any(String),
|
||||
publicKey: expect.any(String),
|
||||
});
|
||||
});
|
||||
|
||||
it('can correctly sign messages', async () => {
|
||||
|
|
|
@ -30,6 +30,7 @@ export interface MessageSigningServiceInterface {
|
|||
generateKeyPair(
|
||||
providedPassphrase?: string
|
||||
): Promise<{ privateKey: string; publicKey: string; passphrase: string }>;
|
||||
rotateKeyPair(): Promise<boolean>;
|
||||
sign(message: Buffer | Record<string, unknown>): Promise<{ data: Buffer; signature: string }>;
|
||||
getPublicKey(): Promise<string>;
|
||||
}
|
||||
|
@ -134,6 +135,25 @@ export class MessageSigningService implements MessageSigningServiceInterface {
|
|||
return publicKey;
|
||||
}
|
||||
|
||||
public async rotateKeyPair(): Promise<boolean> {
|
||||
const isRemoved = await this.removeKeyPair();
|
||||
if (isRemoved) {
|
||||
await this.generateKeyPair();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
// TODO: Apply changes to all policies
|
||||
}
|
||||
|
||||
private async removeKeyPair(): Promise<boolean> {
|
||||
const currentKeyPair = await this.getCurrentKeyPairObj();
|
||||
if (currentKeyPair) {
|
||||
await this.soClient.delete(MESSAGE_SIGNING_KEYS_SAVED_OBJECT_TYPE, currentKeyPair.id);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private get soClient() {
|
||||
if (this._soClient) {
|
||||
return this._soClient;
|
||||
|
|
|
@ -21,3 +21,4 @@ export * from './check_permissions';
|
|||
export * from './download_sources';
|
||||
export * from './tags';
|
||||
export * from './health_check';
|
||||
export * from './message_signing_service';
|
||||
|
|
|
@ -0,0 +1,42 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { RotateKeyPairSchema } from './message_signing_service';
|
||||
|
||||
describe('RotateKeyPairSchema', () => {
|
||||
it('should throw on `false` values for acknowledge', () => {
|
||||
expect(() =>
|
||||
RotateKeyPairSchema.query.validate({
|
||||
acknowledge: false,
|
||||
})
|
||||
).toThrowError(
|
||||
'You must acknowledge the risks of rotating the key pair with acknowledge=true in the request parameters.'
|
||||
);
|
||||
});
|
||||
|
||||
it('should allow without any query', () => {
|
||||
expect(() => RotateKeyPairSchema.query.validate({})).toThrowError(
|
||||
'You must acknowledge the risks of rotating the key pair with acknowledge=true in the request parameters.'
|
||||
);
|
||||
});
|
||||
|
||||
it.each([1, 'string'])('should not allow non-boolean `%s` values for acknowledge', (value) => {
|
||||
expect(() =>
|
||||
RotateKeyPairSchema.query.validate({
|
||||
acknowledge: value,
|
||||
})
|
||||
).toThrowError(`[acknowledge]: expected value of type [boolean] but got [${typeof value}]`);
|
||||
});
|
||||
|
||||
it('should not throw on `true` values for acknowledge', () => {
|
||||
expect(() =>
|
||||
RotateKeyPairSchema.query.validate({
|
||||
acknowledge: true,
|
||||
})
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
|
@ -0,0 +1,30 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { schema } from '@kbn/config-schema';
|
||||
|
||||
export const RotateKeyPairSchema = {
|
||||
query: schema.maybe(
|
||||
schema.object(
|
||||
{
|
||||
acknowledge: schema.boolean({
|
||||
defaultValue: false,
|
||||
}),
|
||||
},
|
||||
{
|
||||
defaultValue: { acknowledge: false },
|
||||
validate: (value: { acknowledge: boolean }) => {
|
||||
if (!value || !value.acknowledge) {
|
||||
throw new Error(
|
||||
'You must acknowledge the risks of rotating the key pair with acknowledge=true in the request parameters.'
|
||||
);
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
),
|
||||
};
|
Loading…
Add table
Add a link
Reference in a new issue