From 3861dc3c5a0e07433399a3f7f524104807e893cd Mon Sep 17 00:00:00 2001 From: Stephan-Thomas Date: Sat, 27 Jun 2026 13:08:49 +0100 Subject: [PATCH] feat(#804): Implement GCP KMS key provider for production --- oracle/.env.example | 7 ++ oracle/src/keys/key-provider.factory.ts | 13 +- .../providers/gcp-kms-key.provider.spec.ts | 111 ++++++++++++++++++ .../keys/providers/gcp-kms-key.provider.ts | 11 +- 4 files changed, 127 insertions(+), 15 deletions(-) create mode 100644 oracle/src/keys/providers/gcp-kms-key.provider.spec.ts diff --git a/oracle/.env.example b/oracle/.env.example index 5b095b44..fe166795 100644 --- a/oracle/.env.example +++ b/oracle/.env.example @@ -12,3 +12,10 @@ ORACLE_HIGH_VALUE_THRESHOLD_XLM=10000 # Minimum prize amount (in XLM) for a draw request to be classified as MEDIUM priority (Bull priority 5) # Must be less than ORACLE_HIGH_VALUE_THRESHOLD_XLM; falls back to defaults if not ORACLE_MED_VALUE_THRESHOLD_XLM=1000 + +# Provider for keys: env, aws-kms, gcp-kms +KEY_PROVIDER=env + +# GCP KMS Config +GCP_KMS_PROJECT= +GCP_KMS_KEY_PATH= diff --git a/oracle/src/keys/key-provider.factory.ts b/oracle/src/keys/key-provider.factory.ts index 478c16ee..a623dc20 100644 --- a/oracle/src/keys/key-provider.factory.ts +++ b/oracle/src/keys/key-provider.factory.ts @@ -82,19 +82,16 @@ export class KeyProviderFactory { } private static createGcpKmsProvider(configService: ConfigService): GcpKmsKeyProvider { - const projectId = configService.get('GCP_PROJECT_ID'); - const locationId = configService.get('GCP_LOCATION_ID', 'global'); - const keyRingId = configService.get('GCP_KEY_RING_ID'); - const keyId = configService.get('GCP_KEY_ID'); - const keyVersion = configService.get('GCP_KEY_VERSION', '1'); + const projectId = configService.get('GCP_KMS_PROJECT'); + const keyPath = configService.get('GCP_KMS_KEY_PATH'); - if (!projectId || !keyRingId || !keyId) { + if (!projectId || !keyPath) { throw new Error( - 'GCP_PROJECT_ID, GCP_KEY_RING_ID, and GCP_KEY_ID environment variables are required for GCP KMS provider', + 'GCP_KMS_PROJECT and GCP_KMS_KEY_PATH environment variables are required for GCP KMS provider', ); } this.logger.log('Using Google Cloud KMS for secure key management'); - return new GcpKmsKeyProvider(projectId, locationId, keyRingId, keyId, keyVersion); + return new GcpKmsKeyProvider(projectId, keyPath); } } diff --git a/oracle/src/keys/providers/gcp-kms-key.provider.spec.ts b/oracle/src/keys/providers/gcp-kms-key.provider.spec.ts new file mode 100644 index 00000000..9917d6a9 --- /dev/null +++ b/oracle/src/keys/providers/gcp-kms-key.provider.spec.ts @@ -0,0 +1,111 @@ +import { GcpKmsKeyProvider } from './gcp-kms-key.provider'; + +const mockGetPublicKey = jest.fn(); +const mockAsymmetricSign = jest.fn(); +const mockGetCryptoKeyVersion = jest.fn(); + +jest.mock('@google-cloud/kms', () => { + return { + KeyManagementServiceClient: jest.fn().mockImplementation(() => { + return { + getPublicKey: mockGetPublicKey, + asymmetricSign: mockAsymmetricSign, + getCryptoKeyVersion: mockGetCryptoKeyVersion, + }; + }), + }; +}); + +describe('GcpKmsKeyProvider', () => { + let provider: GcpKmsKeyProvider; + const testKeyPath = 'projects/test-project/locations/global/keyRings/test-ring/cryptoKeys/test-key/cryptoKeyVersions/1'; + + beforeEach(() => { + jest.clearAllMocks(); + provider = new GcpKmsKeyProvider('test-project', testKeyPath); + }); + + describe('initialization', () => { + it('throws error if project ID or key path are missing', () => { + expect(() => new GcpKmsKeyProvider('', 'key-path')).toThrow(); + expect(() => new GcpKmsKeyProvider('project', '')).toThrow(); + }); + }); + + describe('getPublicKey / getPublicKeyBuffer', () => { + it('loads and parses the public key successfully', async () => { + const pemKey = `-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\n-----END PUBLIC KEY-----`; + mockGetPublicKey.mockResolvedValue([{ pem: pemKey }]); + + const pubKeyString = await provider.getPublicKey(); + expect(mockGetPublicKey).toHaveBeenCalledWith({ + name: testKeyPath, + }); + expect(pubKeyString).toBeDefined(); + + const buffer = await provider.getPublicKeyBuffer(); + expect(buffer).toBeInstanceOf(Buffer); + }); + + it('caches the public key', async () => { + const pemKey = `-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\n-----END PUBLIC KEY-----`; + mockGetPublicKey.mockResolvedValue([{ pem: pemKey }]); + + await provider.getPublicKey(); + await provider.getPublicKey(); // Should use cache + expect(mockGetPublicKey).toHaveBeenCalledTimes(1); + }); + + it('throws error if getPublicKey fails', async () => { + mockGetPublicKey.mockRejectedValue(new Error('Network error')); + await expect(provider.getPublicKey()).rejects.toThrow('Failed to retrieve public key from GCP KMS'); + }); + }); + + describe('sign', () => { + it('signs data successfully', async () => { + const mockSignature = Buffer.from('test-signature'); + mockAsymmetricSign.mockResolvedValue([{ signature: mockSignature }]); + + const dataToSign = Buffer.from('test-data'); + const signature = await provider.sign(dataToSign); + + expect(mockAsymmetricSign).toHaveBeenCalledWith({ + name: testKeyPath, + data: dataToSign, + }); + expect(signature).toEqual(mockSignature); + }); + + it('throws error if asymmetricSign fails', async () => { + mockAsymmetricSign.mockRejectedValue(new Error('Signing error')); + const dataToSign = Buffer.from('test-data'); + await expect(provider.sign(dataToSign)).rejects.toThrow('Failed to sign data with GCP KMS'); + }); + }); + + describe('getProviderHealth', () => { + it('returns healthy when getCryptoKeyVersion succeeds', async () => { + mockGetCryptoKeyVersion.mockResolvedValue([{ name: testKeyPath }]); + const health = await provider.getProviderHealth(); + expect(health.status).toBe('healthy'); + expect(health.activeKeyId).toBe(testKeyPath); + }); + + it('returns permission_denied when IAM fails', async () => { + const error: any = new Error('7 PERMISSION_DENIED'); + error.code = 7; + mockGetCryptoKeyVersion.mockRejectedValue(error); + const health = await provider.getProviderHealth(); + expect(health.status).toBe('permission_denied'); + }); + + it('returns unavailable when network fails', async () => { + const error: any = new Error('14 UNAVAILABLE'); + error.code = 14; + mockGetCryptoKeyVersion.mockRejectedValue(error); + const health = await provider.getProviderHealth(); + expect(health.status).toBe('unavailable'); + }); + }); +}); diff --git a/oracle/src/keys/providers/gcp-kms-key.provider.ts b/oracle/src/keys/providers/gcp-kms-key.provider.ts index efcf2134..5341846c 100644 --- a/oracle/src/keys/providers/gcp-kms-key.provider.ts +++ b/oracle/src/keys/providers/gcp-kms-key.provider.ts @@ -25,18 +25,15 @@ export class GcpKmsKeyProvider implements KeyProvider { constructor( projectId: string, - locationId: string, - keyRingId: string, - keyId: string, - keyVersion: string = '1', + keyPath: string, ) { - if (!projectId || !locationId || !keyRingId || !keyId) { + if (!projectId || !keyPath) { throw new Error( - 'GCP project, location, keyRing, and key IDs are required for GcpKmsKeyProvider', + 'GCP_KMS_PROJECT and GCP_KMS_KEY_PATH are required for GcpKmsKeyProvider', ); } - this.keyVersionName = `projects/${projectId}/locations/${locationId}/keyRings/${keyRingId}/cryptoKeys/${keyId}/cryptoKeyVersions/${keyVersion}`; + this.keyVersionName = keyPath; try { // Lazy load Google Cloud SDK to avoid requiring it when not using GCP KMS