Skip to content

Commit 8174316

Browse files
committed
feat: implement #492 - Link successor key versions on rotation
- Added keyVersion support to GenerateKeyRequest interface - generateKey now uses request.keyVersion ?? 1 instead of hardcoded 1 - rotateKey derives keyVersion = predecessor.keyVersion + 1 and passes it to generateKey - Successor wallet is created with explicit keyVersion from generated key material - Added missing EventEmitter2 and RequestContextService DI injection - Added event emissions for key.generated and key.signed events - Fixed EventEmitterModule import in KeyManagementModule - Added KeyValidationCacheService to module providers - Updated unit tests with key version linking test - Added keyVersion to mock data in test fixtures
1 parent 0c05372 commit 8174316

4 files changed

Lines changed: 82 additions & 12 deletions

File tree

src/key-management/key-management.module.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { Module } from '@nestjs/common';
2+
import { EventEmitterModule } from '@nestjs/event-emitter';
23
import { makeCounterProvider, makeHistogramProvider } from '@willsoto/nestjs-prometheus';
34
import { KeyManagementService } from './key-management.service';
45
import { KeyManagementController } from './key-management.controller';
@@ -7,6 +8,7 @@ import { EncryptionModule } from '../encryption/encryption.module';
78
import { KeyRotationAuditService } from './key-rotation-audit.service';
89
import { PrismaModule } from '../prisma/prisma.module';
910
import { KeyManagementMetricsService } from './key-management-metrics.service';
11+
import { KeyValidationCacheService } from './key-validation-cache/key-validation-cache.service';
1012

1113
@Module({
1214
imports: [EncryptionModule, PrismaModule, EventEmitterModule.forRoot()],
@@ -16,6 +18,7 @@ import { KeyManagementMetricsService } from './key-management-metrics.service';
1618
StellarKeyProvider,
1719
KeyRotationAuditService,
1820
KeyManagementMetricsService,
21+
KeyValidationCacheService,
1922
makeCounterProvider({
2023
name: 'key_mgmt_operations_total',
2124
help: 'Total number of key management operations by type and status',

src/key-management/key-management.service.spec.ts

Lines changed: 53 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { KeyType } from './domain/key-types';
1111
import { KeyDecryptionException } from './exceptions/key-decryption.exception';
1212
import { KeyRotationAuditService } from './key-rotation-audit.service';
1313
import { KeyManagementMetricsService } from './key-management-metrics.service';
14+
import { RequestContextService } from '../common/request-context/request-context.service';
1415

1516
// Prevent loading the real PrismaService (which requires the generated Prisma client)
1617
jest.mock('../prisma/prisma.service', () => ({
@@ -23,6 +24,7 @@ import { PrismaService } from '../prisma/prisma.service';
2324
describe('KeyManagementService', () => {
2425
let service: KeyManagementService;
2526
let encryptionService: EncryptionService;
27+
let module: TestingModule;
2628

2729
// Minimal Prisma mock — only the methods used by rotateKey
2830
const mockPrisma = {
@@ -46,6 +48,14 @@ describe('KeyManagementService', () => {
4648
recordKeyOperationDuration: jest.fn(),
4749
};
4850

51+
const mockEventEmitter = {
52+
emit: jest.fn(),
53+
};
54+
55+
const mockRequestContext = {
56+
getRequestId: jest.fn().mockReturnValue('test-req-id'),
57+
};
58+
4959
beforeEach(async () => {
5060
jest.clearAllMocks();
5161

@@ -60,7 +70,7 @@ describe('KeyManagementService', () => {
6070
}),
6171
};
6272

63-
const module: TestingModule = await Test.createTestingModule({
73+
module = await Test.createTestingModule({
6474
providers: [
6575
KeyManagementService,
6676
EncryptionService,
@@ -80,6 +90,14 @@ describe('KeyManagementService', () => {
8090
provide: KeyManagementMetricsService,
8191
useValue: mockMetricsService,
8292
},
93+
{
94+
provide: EventEmitter2,
95+
useValue: mockEventEmitter,
96+
},
97+
{
98+
provide: RequestContextService,
99+
useValue: mockRequestContext,
100+
},
83101
],
84102
}).compile();
85103

@@ -545,6 +563,7 @@ describe('KeyManagementService', () => {
545563
encryptedSecret: 'enc-secret',
546564
encryptionVersion: 1,
547565
secretVersion: 1,
566+
keyVersion: 1,
548567
network: 'TESTNET',
549568
status: 'ACTIVE',
550569
successorId: null,
@@ -558,6 +577,7 @@ describe('KeyManagementService', () => {
558577
encryptedSecret: 'enc-secret-new',
559578
encryptionVersion: 1,
560579
secretVersion: 2,
580+
keyVersion: 2,
561581
network: 'TESTNET',
562582
status: 'ACTIVE',
563583
rotatedFromId: predecessorId,
@@ -689,6 +709,32 @@ describe('KeyManagementService', () => {
689709
activePredecessor.secretVersion + 1,
690710
);
691711
});
712+
713+
it('should link successor keyVersion from predecessor incremented by 1', async () => {
714+
mockPrisma.wallet.findUnique.mockResolvedValue(activePredecessor);
715+
716+
// Capture what was passed to tx.wallet.create
717+
let capturedCreateData: any;
718+
mockPrisma.$transaction.mockImplementation(async (cb: any) => {
719+
const tx = {
720+
wallet: {
721+
create: jest.fn().mockImplementation(async ({ data }: any) => {
722+
capturedCreateData = data;
723+
return createdSuccessor;
724+
}),
725+
update: jest.fn().mockResolvedValue({}),
726+
},
727+
};
728+
return cb(tx);
729+
});
730+
731+
await service.rotateKey(predecessorId);
732+
733+
// keyVersion should be predecessor.keyVersion + 1 = 2
734+
expect(capturedCreateData.keyVersion).toBe(
735+
activePredecessor.keyVersion + 1,
736+
);
737+
});
692738
});
693739

694740
// ─────────────────────────────────────────────────────────────────────────────
@@ -736,16 +782,13 @@ describe('KeyManagementService', () => {
736782
// domain events
737783
// ─────────────────────────────────────────────────────────────────────────────
738784
describe('domain events', () => {
739-
let eventEmitter: EventEmitter2;
740-
741785
beforeEach(() => {
742-
eventEmitter = service['eventEmitter'] as EventEmitter2;
743-
jest.spyOn(eventEmitter, 'emit');
786+
jest.spyOn(mockEventEmitter, 'emit');
744787
});
745788

746789
it('should emit key.generated after successful key generation', async () => {
747790
await service.generateKey({ keyType: KeyType.STELLAR_ED25519 });
748-
expect(eventEmitter.emit).toHaveBeenCalledWith(
791+
expect(mockEventEmitter.emit).toHaveBeenCalledWith(
749792
'key.generated',
750793
expect.objectContaining({ keyType: KeyType.STELLAR_ED25519 }),
751794
);
@@ -758,7 +801,7 @@ describe('KeyManagementService', () => {
758801
dataToSign: Buffer.from('data'),
759802
publicKey: key.publicKey,
760803
});
761-
expect(eventEmitter.emit).toHaveBeenCalledWith(
804+
expect(mockEventEmitter.emit).toHaveBeenCalledWith(
762805
'key.signed',
763806
expect.objectContaining({ publicKey: key.publicKey }),
764807
);
@@ -771,13 +814,14 @@ describe('KeyManagementService', () => {
771814
status: 'ACTIVE',
772815
successorId: null,
773816
secretVersion: 1,
817+
keyVersion: 1,
774818
network: 'TESTNET',
775819
publicKey: 'GPRED',
776820
});
777821

778822
await service.rotateKey('pred-1');
779823

780-
expect(eventEmitter.emit).toHaveBeenCalledWith(
824+
expect(mockEventEmitter.emit).toHaveBeenCalledWith(
781825
'key.rotated',
782826
expect.objectContaining({ predecessorWalletId: 'pred-1' }),
783827
);
@@ -790,7 +834,7 @@ describe('KeyManagementService', () => {
790834
key.encryptedData,
791835
KeyType.STELLAR_ED25519,
792836
);
793-
expect(eventEmitter.emit).toHaveBeenCalledWith(
837+
expect(mockEventEmitter.emit).toHaveBeenCalledWith(
794838
'key.validated',
795839
expect.objectContaining({ publicKey: key.publicKey }),
796840
);

src/key-management/key-management.service.ts

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
} from '../encryption/encryption.service';
1414
import { SafeLogger } from '../common/safe-logger';
1515
import { PrismaService } from '../prisma/prisma.service';
16+
import { RequestContextService } from '../common/request-context/request-context.service';
1617
import { KeyDecryptionException } from './exceptions/key-decryption.exception';
1718
import { KeyManagementMetricsService } from './key-management-metrics.service';
1819
import { retryWithBackoff } from './utils/retry.util';
@@ -38,6 +39,8 @@ import { KeyValidatedEvent } from './events/key-validated.event';
3839
export interface GenerateKeyRequest {
3940
keyType: KeyType;
4041
metadata?: Record<string, any>;
42+
/** Key algorithm/derivation scheme version. When omitted defaults to 1. */
43+
keyVersion?: number;
4144
}
4245

4346
export interface SignRequest {
@@ -105,6 +108,8 @@ export class KeyManagementService {
105108
private readonly prisma: PrismaService,
106109
private readonly auditService: KeyRotationAuditService,
107110
private readonly metricsService: KeyManagementMetricsService,
111+
private readonly eventEmitter: EventEmitter2,
112+
private readonly requestContext: RequestContextService,
108113
) {
109114
this.maxRetries = this.configService.get<number>('KEY_MGMT_MAX_RETRIES', 3);
110115
this.retryBackoffMs = this.configService.get<number>('KEY_MGMT_RETRY_BACKOFF_MS', 200);
@@ -172,10 +177,15 @@ export class KeyManagementService {
172177
`Generated ${request.keyType} key in ${duration}ms (publicKey: ${keyPair.publicKey.substring(0, 12)}...)`,
173178
);
174179

180+
this.eventEmitter.emit(
181+
'key.generated',
182+
new KeyGeneratedEvent(keyPair.publicKey, request.keyType, new Date()),
183+
);
184+
175185
return {
176186
encryptedData,
177187
encryptionVersion: 1,
178-
keyVersion: 1,
188+
keyVersion: request.keyVersion ?? 1,
179189
keyType: request.keyType,
180190
publicKey: keyPair.publicKey,
181191
};
@@ -251,6 +261,11 @@ export class KeyManagementService {
251261
`Signed data in ${duration}ms (publicKey: ${request.publicKey.substring(0, 12)}...)`,
252262
);
253263

264+
this.eventEmitter.emit(
265+
'key.signed',
266+
new KeySignedEvent(request.publicKey, new Date()),
267+
);
268+
254269
return signature;
255270
} catch (error) {
256271
// Handle decrypt failures — log and convert to typed HTTP exception
@@ -417,20 +432,25 @@ export class KeyManagementService {
417432
);
418433
}
419434

420-
// Generate new keypair
435+
// Derive the next key version from the predecessor
436+
const newKeyVersion = (predecessor.keyVersion ?? 1) + 1;
437+
438+
// Generate new keypair with the successor key version
421439
const keyMaterial = await this.generateKey({
422440
keyType: KeyType.STELLAR_ED25519,
441+
keyVersion: newKeyVersion,
423442
metadata: { rotatedFromId: predecessorWalletId },
424443
});
425444

426445
const [successor] = await this.prisma.$transaction(async (tx) => {
427-
// Create successor wallet
446+
// Create successor wallet with linked key version
428447
const newWallet = await tx.wallet.create({
429448
data: {
430449
userId: predecessor.userId,
431450
publicKey: keyMaterial.publicKey,
432451
encryptedSecret: keyMaterial.encryptedData,
433452
encryptionVersion: keyMaterial.encryptionVersion,
453+
keyVersion: keyMaterial.keyVersion,
434454
secretVersion: predecessor.secretVersion + 1,
435455
network: predecessor.network,
436456
status: 'ACTIVE',

test/key-management.e2e-spec.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ const activePredecessorWallet = {
5959
encryptedSecret: 'enc-secret',
6060
encryptionVersion: 1,
6161
secretVersion: 1,
62+
keyVersion: 1,
6263
network: 'TESTNET',
6364
status: 'ACTIVE',
6465
successorId: null,
@@ -94,6 +95,7 @@ describe('Key Management (e2e)', () => {
9495
encryptedSecret: 'enc-new',
9596
encryptionVersion: 1,
9697
secretVersion: 2,
98+
keyVersion: 2,
9799
network: 'TESTNET',
98100
status: 'ACTIVE',
99101
rotatedFromId: predecessorId,
@@ -144,6 +146,7 @@ describe('Key Management (e2e)', () => {
144146
encryptedSecret: 'enc-new',
145147
encryptionVersion: 1,
146148
secretVersion: 2,
149+
keyVersion: 2,
147150
network: 'TESTNET',
148151
status: 'ACTIVE',
149152
rotatedFromId: predecessorId,

0 commit comments

Comments
 (0)