Skip to content

Commit 3daedb1

Browse files
author
Mux Protocol
committed
feat(wallets): add keyVersion field to Wallet model
Adds a keyVersion integer field (default 1) to the Wallet model to track the key algorithm/derivation scheme version independently of the existing encryptionVersion (envelope format) and secretVersion (rotation counter). Changes: - prisma/schema.prisma: add keyVersion Int @default(1) to Wallet - prisma/migrations/20260602_add_wallet_key_version/migration.sql: safe ALTER TABLE adding the column with DEFAULT 1 (no data migration needed) - src/wallets/domain/wallet.model.ts: add keyVersion to Wallet interface - src/wallets/wallets.service.ts: write keyVersion:1 on create, increment on rotateWalletKey, coerce null/undefined to 1 in mapper (stale rows) - src/wallets/wallet-creation-orchestrator.service.ts: same creation write and mapper fallback - src/key-management/domain/key-types.ts: add keyVersion to EncryptedKeyMaterial - src/key-management/key-management.service.ts: return keyVersion:1 from generateKey; preserve keyVersion in reEncryptKey (algo unchanged) - src/key-management/key-management.controller.ts: expose keyVersion in generate response - src/wallets/wallets.service.spec.ts: update mocks, add keyVersion describe block (init, increment on rotation, stale-row fallback) - src/key-management/key-management.service.spec.ts: assert keyVersion:1 in generateKey result
1 parent 25276f9 commit 3daedb1

10 files changed

Lines changed: 151 additions & 5 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
-- Migration: add keyVersion field to Wallet
2+
--
3+
-- keyVersion tracks the key algorithm/derivation scheme version on a wallet.
4+
-- It is distinct from:
5+
-- encryptionVersion – the envelope/KMS format used to encrypt the secret material
6+
-- secretVersion – a monotonic counter incremented on every key rotation
7+
--
8+
-- Default value of 1 is applied to all existing rows so no data migration is needed.
9+
10+
ALTER TABLE "Wallet" ADD COLUMN "keyVersion" INTEGER NOT NULL DEFAULT 1;

prisma/schema.prisma

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,11 @@ model Wallet {
140140
/// Supports rotation by incrementing secret material while preserving history.
141141
secretVersion Int @default(1)
142142
143+
/// Key algorithm/derivation scheme version (e.g. 1 = Stellar Ed25519 via stellar-sdk).
144+
/// Increment when the key algorithm or derivation path changes so consumers can detect
145+
/// stale material and trigger re-encryption or re-issuance.
146+
keyVersion Int @default(1)
147+
143148
network WalletNetwork
144149
status WalletStatus @default(PROVISIONING)
145150

src/key-management/domain/key-types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ export interface GeneratedKeyPair {
3030
export interface EncryptedKeyMaterial {
3131
encryptedData: string;
3232
encryptionVersion: number;
33+
/** Key algorithm/derivation scheme version. Matches Wallet.keyVersion. */
34+
keyVersion: number;
3335
keyType: KeyType;
3436
publicKey: string;
3537
}

src/key-management/key-management.controller.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ export class KeyManagementController {
3535
publicKey: result.publicKey,
3636
encryptedData: result.encryptedData,
3737
encryptionVersion: result.encryptionVersion,
38+
keyVersion: result.keyVersion,
3839
keyType: result.keyType,
3940
// Note: No private key is ever returned
4041
};

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ describe('KeyManagementService', () => {
4242
expect(result).toHaveProperty('publicKey');
4343
expect(result).toHaveProperty('keyType', KeyType.STELLAR_ED25519);
4444
expect(result).toHaveProperty('encryptionVersion');
45+
expect(result).toHaveProperty('keyVersion', 1);
4546

4647
// Critical: Should NOT contain plaintext private key
4748
expect(result).not.toHaveProperty('privateKey');

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

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ export class KeyManagementService {
101101
return {
102102
encryptedData,
103103
encryptionVersion: 1,
104+
keyVersion: 1,
104105
keyType: request.keyType,
105106
publicKey: keyPair.publicKey,
106107
};
@@ -199,6 +200,7 @@ export class KeyManagementService {
199200
async reEncryptKey(
200201
encryptedKeyMaterial: string,
201202
keyType: KeyType,
203+
currentKeyVersion: number = 1,
202204
): Promise<EncryptedKeyMaterial> {
203205
try {
204206
// Decrypt with old encryption
@@ -209,15 +211,12 @@ export class KeyManagementService {
209211
const newEncryptedData =
210212
this.encryptionService.encryptAndSerialize(privateKeyMaterial);
211213

212-
// Derive public key for result
213-
const provider = this.getProvider(keyType);
214-
const keyPair = await provider.generateKeyPair(keyType); // Temp for structure
215-
216214
this.logger.log('Successfully re-encrypted key material');
217215

218216
return {
219217
encryptedData: newEncryptedData,
220-
encryptionVersion: 2, // Increment version
218+
encryptionVersion: 2, // Increment encryption envelope version
219+
keyVersion: currentKeyVersion, // Key algorithm version is unchanged on re-encryption
221220
keyType,
222221
publicKey: '', // Would derive from private key in production
223222
};

src/wallets/domain/wallet.model.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,13 @@ export interface Wallet {
3737
/** Supports rotation by incrementing secret material while preserving history. */
3838
secretVersion: number;
3939

40+
/**
41+
* Key algorithm/derivation scheme version (e.g. 1 = Stellar Ed25519 via stellar-sdk).
42+
* Increment when the key algorithm or derivation path changes so consumers can detect
43+
* stale material and trigger re-encryption or re-issuance.
44+
*/
45+
keyVersion: number;
46+
4047
/** Mainnet/testnet separation. */
4148
network: WalletNetwork;
4249

src/wallets/wallet-creation-orchestrator.service.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,7 @@ export class WalletCreationOrchestrator {
242242
status: 'ACTIVE',
243243
encryptionVersion: 1,
244244
secretVersion: 1,
245+
keyVersion: 1,
245246
},
246247
});
247248

@@ -308,6 +309,7 @@ export class WalletCreationOrchestrator {
308309
encryptedSecret: prismaWallet.encryptedSecret,
309310
encryptionVersion: prismaWallet.encryptionVersion,
310311
secretVersion: prismaWallet.secretVersion,
312+
keyVersion: prismaWallet.keyVersion ?? 1,
311313
network: prismaWallet.network as WalletNetwork,
312314
status: prismaWallet.status as WalletStatus,
313315
statusReason: prismaWallet.statusReason,

src/wallets/wallets.service.spec.ts

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ describe('WalletsService', () => {
9595
status: 'ACTIVE',
9696
encryptionVersion: 1,
9797
secretVersion: 1,
98+
keyVersion: 1,
9899
statusReason: null,
99100
statusChangedAt: new Date(),
100101
rotatedFromId: null,
@@ -114,6 +115,7 @@ describe('WalletsService', () => {
114115
expect(result.wallet.userId).toBe('user-123');
115116
expect(result.wallet.publicKey).toBe('public-key-123');
116117
expect(result.privateKey).toBeDefined();
118+
expect(result.wallet.keyVersion).toBe(1);
117119
expect(encryptionService.encryptAndSerialize).toHaveBeenCalled();
118120
});
119121

@@ -251,6 +253,7 @@ describe('WalletsService', () => {
251253
publicKey: 'old-public-key',
252254
encryptedSecret: 'old-encrypted-secret',
253255
secretVersion: 1,
256+
keyVersion: 1,
254257
};
255258

256259
const updatedWallet = {
@@ -259,6 +262,7 @@ describe('WalletsService', () => {
259262
publicKey: 'new-public-key',
260263
encryptedSecret: 'new-encrypted-secret',
261264
secretVersion: 2,
265+
keyVersion: 2,
262266
network: WalletNetwork.TESTNET,
263267
status: 'ACTIVE',
264268
encryptionVersion: 1,
@@ -279,8 +283,19 @@ describe('WalletsService', () => {
279283

280284
expect(result.wallet.id).toBe('wallet-123');
281285
expect(result.wallet.secretVersion).toBe(2);
286+
expect(result.wallet.keyVersion).toBe(2);
282287
expect(result.privateKey).toBeDefined();
283288
expect(encryptionService.encryptAndSerialize).toHaveBeenCalled();
289+
290+
// Verify both secretVersion and keyVersion are incremented in the update call
291+
expect(mockPrisma.wallet.update).toHaveBeenCalledWith(
292+
expect.objectContaining({
293+
data: expect.objectContaining({
294+
secretVersion: existingWallet.secretVersion + 1,
295+
keyVersion: existingWallet.keyVersion + 1,
296+
}),
297+
}),
298+
);
284299
});
285300

286301
it('should throw NotFoundException if wallet not found', async () => {
@@ -291,4 +306,105 @@ describe('WalletsService', () => {
291306
);
292307
});
293308
});
309+
310+
describe('keyVersion field', () => {
311+
it('should initialise keyVersion to 1 on wallet creation', async () => {
312+
const mockWallet = {
313+
id: 'wallet-kv-1',
314+
userId: 'user-kv',
315+
publicKey: 'pk',
316+
encryptedSecret: 'enc',
317+
network: WalletNetwork.TESTNET,
318+
status: 'ACTIVE',
319+
encryptionVersion: 1,
320+
secretVersion: 1,
321+
keyVersion: 1,
322+
statusReason: null,
323+
statusChangedAt: new Date(),
324+
rotatedFromId: null,
325+
createdAt: new Date(),
326+
updatedAt: new Date(),
327+
};
328+
329+
mockPrisma.wallet.findFirst.mockResolvedValue(null);
330+
mockPrisma.wallet.create.mockResolvedValue(mockWallet);
331+
jest.spyOn(encryptionService, 'encryptAndSerialize').mockReturnValue('enc');
332+
333+
const result = await service.createWallet({ userId: 'user-kv', network: WalletNetwork.TESTNET });
334+
335+
expect(result.wallet.keyVersion).toBe(1);
336+
// Verify keyVersion: 1 is passed to Prisma on creation
337+
expect(mockPrisma.wallet.create).toHaveBeenCalledWith(
338+
expect.objectContaining({
339+
data: expect.objectContaining({ keyVersion: 1 }),
340+
}),
341+
);
342+
});
343+
344+
it('should increment keyVersion on key rotation', async () => {
345+
const existingWallet = {
346+
id: 'wallet-kv-2',
347+
userId: 'user-kv',
348+
publicKey: 'old-pk',
349+
encryptedSecret: 'old-enc',
350+
secretVersion: 3,
351+
keyVersion: 3,
352+
};
353+
const updatedWallet = {
354+
...existingWallet,
355+
publicKey: 'new-pk',
356+
encryptedSecret: 'new-enc',
357+
secretVersion: 4,
358+
keyVersion: 4,
359+
network: WalletNetwork.TESTNET,
360+
status: 'ACTIVE',
361+
encryptionVersion: 1,
362+
statusReason: null,
363+
statusChangedAt: new Date(),
364+
rotatedFromId: null,
365+
createdAt: new Date(),
366+
updatedAt: new Date(),
367+
};
368+
369+
mockPrisma.wallet.findUnique.mockResolvedValue(existingWallet);
370+
mockPrisma.wallet.update.mockResolvedValue(updatedWallet);
371+
jest.spyOn(encryptionService, 'encryptAndSerialize').mockReturnValue('new-enc');
372+
373+
const result = await service.rotateWalletKey('wallet-kv-2');
374+
375+
expect(result.wallet.keyVersion).toBe(4);
376+
expect(mockPrisma.wallet.update).toHaveBeenCalledWith(
377+
expect.objectContaining({
378+
data: expect.objectContaining({ keyVersion: 4 }),
379+
}),
380+
);
381+
});
382+
383+
it('should fall back to keyVersion 1 when field is absent (stale row)', async () => {
384+
// Simulates a wallet row that pre-dates the migration (no keyVersion column yet)
385+
const staleWallet = {
386+
id: 'wallet-stale',
387+
userId: 'user-stale',
388+
publicKey: 'pk',
389+
encryptedSecret: 'enc',
390+
network: WalletNetwork.TESTNET,
391+
status: 'ACTIVE',
392+
encryptionVersion: 1,
393+
secretVersion: 1,
394+
// keyVersion intentionally omitted (simulates NULL / missing column on old row)
395+
statusReason: null,
396+
statusChangedAt: new Date(),
397+
rotatedFromId: null,
398+
createdAt: new Date(),
399+
updatedAt: new Date(),
400+
};
401+
402+
mockPrisma.wallet.findUnique.mockResolvedValue(staleWallet);
403+
404+
const result = await service.findWalletById('wallet-stale');
405+
406+
// mapPrismaWalletToDomain should coerce undefined/null to 1
407+
expect(result.keyVersion).toBe(1);
408+
});
409+
});
294410
});

src/wallets/wallets.service.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ export class WalletsService {
8585
status: 'ACTIVE',
8686
encryptionVersion: 1,
8787
secretVersion: 1,
88+
keyVersion: 1,
8889
},
8990
});
9091

@@ -239,6 +240,7 @@ export class WalletsService {
239240
publicKey: newKeyPair.publicKey,
240241
encryptedSecret: newEncryptedSecret,
241242
secretVersion: existingWallet.secretVersion + 1,
243+
keyVersion: existingWallet.keyVersion + 1,
242244
updatedAt: new Date(),
243245
},
244246
});
@@ -334,6 +336,7 @@ export class WalletsService {
334336
encryptedSecret: prismaWallet.encryptedSecret,
335337
encryptionVersion: prismaWallet.encryptionVersion,
336338
secretVersion: prismaWallet.secretVersion,
339+
keyVersion: prismaWallet.keyVersion ?? 1,
337340
network: prismaWallet.network as WalletNetwork,
338341
status: prismaWallet.status as WalletStatus,
339342
statusReason: prismaWallet.statusReason,

0 commit comments

Comments
 (0)