[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:
David Sánchez 2023-04-25 17:44:41 +02:00 committed by GitHub
parent a018d38687
commit b92f5e94fd
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 415 additions and 2 deletions

View file

@ -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}`;

View file

@ -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",

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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 {

View file

@ -167,5 +167,7 @@ export function createMessageSigningServiceMock() {
generateKeyPair: jest.fn(),
sign: jest.fn(),
getPublicKey: jest.fn(),
removeKeyPair: jest.fn(),
rotateKeyPair: jest.fn(),
};
}

View file

@ -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) {

View file

@ -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!',
},
});
});
});

View file

@ -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 });
}
};

View file

@ -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
);
};

View file

@ -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 () => {

View file

@ -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;

View file

@ -21,3 +21,4 @@ export * from './check_permissions';
export * from './download_sources';
export * from './tags';
export * from './health_check';
export * from './message_signing_service';

View file

@ -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();
});
});

View file

@ -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.'
);
}
},
}
)
),
};