Skip to content

Commit 7dc8ede

Browse files
oriolriusclaude
andcommitted
fix: resolve certificate/key mismatch in P12/JKS downloads
Fixes GitHub issue #1: Private key in P12/JKS downloads didn't match the certificate's public key because the KMS certify operation wasn't using the pre-created key pair. Root cause: - When issuing certificates, a key pair was created first via createKeyPair() - But the certify() call didn't reference this key pair via UniqueIdentifier - KMS would generate a NEW key pair for the certificate - The stored kmsKeyId pointed to the original key pair - Downloads fetched the original private key, which didn't match the certificate's actual public key Fix: - Add UniqueIdentifier to certify() request to reference existing public key - Add PublicKeyLink attribute as backup - Disable key reuse for renewals (was already broken due to same issue) - Add test to verify certificate/private key modulus match in P12 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent ed72b17 commit 7dc8ede

File tree

5 files changed

+90
-66
lines changed

5 files changed

+90
-66
lines changed

backend/src/kms/client.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -410,6 +410,17 @@ export class KMSClient {
410410
}): Promise<CertificateInfo> {
411411
const requestValue: KMIPElement[] = [];
412412

413+
// CRITICAL: Add UniqueIdentifier for existing public key
414+
// Per KMIP spec, when certifying an existing public key, the UniqueIdentifier
415+
// at the request level identifies which public key to certify
416+
if (options.publicKeyId) {
417+
requestValue.push({
418+
tag: "UniqueIdentifier",
419+
type: "TextString",
420+
value: options.publicKeyId,
421+
});
422+
}
423+
413424
// Add CSR if provided
414425
if (options.csr) {
415426
requestValue.push({
@@ -471,6 +482,27 @@ export class KMSClient {
471482
});
472483
}
473484

485+
// Add Link to public key (to use existing key pair instead of generating new one)
486+
// This is CRITICAL: without this link, KMS generates a new key pair for the certificate
487+
// which causes a mismatch between the stored private key and the certificate's public key
488+
if (options.publicKeyId) {
489+
attributes.push({
490+
tag: "Link",
491+
value: [
492+
{
493+
tag: "LinkType",
494+
type: "Enumeration",
495+
value: "PublicKeyLink",
496+
},
497+
{
498+
tag: "LinkedObjectIdentifier",
499+
type: "TextString",
500+
value: options.publicKeyId,
501+
},
502+
],
503+
});
504+
}
505+
474506
// Add subject name using CertificateAttributes structure with individual fields
475507
if (options.subjectName) {
476508
// Parse the DN string into individual components

backend/src/rest/routes/certificate-download.test.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -349,7 +349,7 @@ describe('Certificate Download - KMS Integration', () => {
349349
});
350350

351351
describe('PKCS#12 Bundle Downloads', () => {
352-
it('should download PKCS#12 bundle (p12)', async (ctx) => {
352+
it('should download PKCS#12 bundle (p12) with matching certificate and key', async (ctx) => {
353353
if (!kmsAvailable) {
354354
ctx.skip();
355355
return;
@@ -380,7 +380,19 @@ describe('Certificate Download - KMS Integration', () => {
380380
expect(keyBags[forge.pki.oids.pkcs8ShroudedKeyBag]).toBeDefined();
381381
expect(keyBags[forge.pki.oids.pkcs8ShroudedKeyBag]!.length).toBeGreaterThan(0);
382382

383-
console.log('✅ p12 format download works');
383+
// CRITICAL: Verify that certificate and private key modulus match
384+
// This validates the fix for GitHub issue #1: key mismatch in P12/JKS downloads
385+
const cert = certBags[forge.pki.oids.certBag]![0].cert!;
386+
const privateKey = keyBags[forge.pki.oids.pkcs8ShroudedKeyBag]![0].key as forge.pki.rsa.PrivateKey;
387+
388+
// Get modulus from certificate's public key and from private key
389+
const certModulus = (cert.publicKey as forge.pki.rsa.PublicKey).n.toString(16);
390+
const keyModulus = privateKey.n.toString(16);
391+
392+
// The modulus must match - this is the core validation that the key pair is correct
393+
expect(certModulus).toBe(keyModulus);
394+
395+
console.log('✅ p12 format download works with matching certificate and key modulus');
384396
});
385397

386398
it('should download PKCS#12 bundle (pfx alias)', async (ctx) => {

backend/src/services/certificate.service.ts

Lines changed: 17 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -850,31 +850,25 @@ export class CertificateService {
850850
const validityDays = params.validityDays ||
851851
Math.ceil((originalCert.notAfter.getTime() - originalCert.notBefore.getTime()) / (1000 * 60 * 60 * 24));
852852

853-
let kmsKeyId: string;
854-
let publicKeyId: string;
855-
856-
if (params.generateNewKey) {
857-
logger.info({ newCertId, originalCertId: params.id }, 'Creating new key pair for certificate renewal');
858-
859-
const keyPair = await kmsService.createKeyPair({
860-
sizeInBits: 2048,
861-
tags: [],
862-
purpose: 'certificate',
863-
entityId: newCertId,
864-
});
853+
// Always generate a new key pair for renewal
854+
// Key reuse is not supported because we don't store the public key ID separately,
855+
// and deriving it from the private key ID is unreliable
856+
if (!params.generateNewKey) {
857+
logger.warn({ newCertId, originalCertId: params.id },
858+
'Key reuse requested but not supported - generating new key pair instead');
859+
}
865860

866-
kmsKeyId = keyPair.privateKeyId;
867-
publicKeyId = keyPair.publicKeyId;
868-
} else {
869-
logger.info({ newCertId, originalCertId: params.id }, 'Reusing existing key pair for certificate renewal');
861+
logger.info({ newCertId, originalCertId: params.id }, 'Creating new key pair for certificate renewal');
870862

871-
if (!originalCert.kmsKeyId) {
872-
throw new CertificateNoKeyError(params.id);
873-
}
863+
const keyPair = await kmsService.createKeyPair({
864+
sizeInBits: 2048,
865+
tags: [],
866+
purpose: 'certificate',
867+
entityId: newCertId,
868+
});
874869

875-
kmsKeyId = originalCert.kmsKeyId;
876-
publicKeyId = kmsKeyId.replace('-private', '-public');
877-
}
870+
const kmsKeyId = keyPair.privateKeyId;
871+
const publicKeyId = keyPair.publicKeyId;
878872

879873
const subjectName = formatDN(subjectDN);
880874
logger.info({ newCertId, subjectName, caId: originalCert.caId }, 'Signing renewed certificate via KMS');
@@ -907,7 +901,7 @@ export class CertificateService {
907901
notBefore: certMetadata.validity.notBefore,
908902
notAfter: certMetadata.validity.notAfter,
909903
kmsCertificateId: certInfo.certificateId,
910-
kmsKeyId: params.generateNewKey ? kmsKeyId : null,
904+
kmsKeyId: kmsKeyId, // Always store since we always generate new keys
911905
status: 'active',
912906
sanDns: sanDns ? JSON.stringify(sanDns) : null,
913907
sanIp: sanIp ? JSON.stringify(sanIp) : null,

backend/src/trpc/procedures/certificate-renew.test.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,7 @@ describe("Certificate Renew - KMS Integration", () => {
156156
console.log(`✅ Certificate renewed with new key: ${result.id}`);
157157
});
158158

159-
it("should renew a certificate with key reuse (young cert)", async (ctx) => {
159+
it("should renew a certificate (generateNewKey=false still generates new key)", async (ctx) => {
160160
if (!kmsAvailable) {
161161
ctx.skip();
162162
return;
@@ -186,10 +186,11 @@ describe("Certificate Renew - KMS Integration", () => {
186186

187187
createdCertIds.push(freshCert.id);
188188

189-
// Now renew with key reuse
189+
// Now renew - note: key reuse is not supported, a new key will be generated
190+
// even when generateNewKey: false is specified
190191
const result = await caller.certificate.renew({
191192
id: freshCert.id,
192-
generateNewKey: false, // Reuse existing key
193+
generateNewKey: false, // Ignored - new key is always generated
193194
validityDays: 365,
194195
});
195196

@@ -202,12 +203,12 @@ describe("Certificate Renew - KMS Integration", () => {
202203
// Store for cleanup
203204
createdCertIds.push(result.id);
204205

205-
// Verify that kmsKeyId is null (reused key, not stored again)
206+
// Verify that kmsKeyId is set (new key is always generated due to key reuse not being supported)
206207
const dbCert = await db.select().from(certificates).where(eq(certificates.id, result.id));
207208
expect(dbCert).toHaveLength(1);
208-
expect(dbCert[0].kmsKeyId).toBeNull(); // Key was reused, not regenerated
209+
expect(dbCert[0].kmsKeyId).not.toBeNull(); // New key is always generated
209210

210-
console.log(`✅ Certificate renewed with key reuse: ${result.id}`);
211+
console.log(`✅ Certificate renewed (new key generated): ${result.id}`);
211212
});
212213

213214
it("should renew a certificate with updated subject info", async (ctx) => {

backend/src/trpc/procedures/certificate.ts

Lines changed: 20 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1001,42 +1001,27 @@ export const certificateRouter = router({
10011001
const validityDays = input.validityDays ||
10021002
Math.ceil((originalCert.notAfter.getTime() - originalCert.notBefore.getTime()) / (1000 * 60 * 60 * 24));
10031003

1004-
let kmsKeyId: string;
1005-
let publicKeyId: string;
1006-
1007-
if (input.generateNewKey) {
1008-
// Generate new key pair in KMS
1009-
logger.info({ newCertId, originalCertId: input.id }, 'Creating new key pair for certificate renewal');
1010-
1011-
// Determine key size from original certificate
1012-
// For simplicity, defaulting to RSA-2048. In production, you'd extract this from the original cert
1013-
const keyPair = await kmsService.createKeyPair({
1014-
sizeInBits: 2048,
1015-
tags: [],
1016-
purpose: 'certificate',
1017-
entityId: newCertId,
1018-
});
1019-
1020-
kmsKeyId = keyPair.privateKeyId;
1021-
publicKeyId = keyPair.publicKeyId;
1022-
} else {
1023-
// Reuse existing key pair
1024-
logger.info({ newCertId, originalCertId: input.id }, 'Reusing existing key pair for certificate renewal');
1004+
// Always generate a new key pair for renewal
1005+
// Key reuse is not supported because we don't store the public key ID separately,
1006+
// and deriving it from the private key ID is unreliable
1007+
if (!input.generateNewKey) {
1008+
logger.warn({ newCertId, originalCertId: input.id },
1009+
'Key reuse requested but not supported - generating new key pair instead');
1010+
}
10251011

1026-
if (!originalCert.kmsKeyId) {
1027-
throw new TRPCError({
1028-
code: 'BAD_REQUEST',
1029-
message: 'Original certificate has no associated KMS key to reuse',
1030-
});
1031-
}
1012+
logger.info({ newCertId, originalCertId: input.id }, 'Creating new key pair for certificate renewal');
10321013

1033-
kmsKeyId = originalCert.kmsKeyId;
1014+
// Determine key size from original certificate
1015+
// For simplicity, defaulting to RSA-2048. In production, you'd extract this from the original cert
1016+
const keyPair = await kmsService.createKeyPair({
1017+
sizeInBits: 2048,
1018+
tags: [],
1019+
purpose: 'certificate',
1020+
entityId: newCertId,
1021+
});
10341022

1035-
// Get the public key ID from KMS
1036-
// Note: This assumes the KMS service has a method to get public key from private key
1037-
// In production, you might need to store the public key ID alongside the private key ID
1038-
publicKeyId = kmsKeyId.replace('-private', '-public'); // Simplified assumption
1039-
}
1023+
const kmsKeyId = keyPair.privateKeyId;
1024+
const publicKeyId = keyPair.publicKeyId;
10401025

10411026
const subjectName = formatDN(subjectDN);
10421027
logger.info({ newCertId, subjectName, caId: originalCert.caId }, 'Signing renewed certificate via KMS');
@@ -1069,7 +1054,7 @@ export const certificateRouter = router({
10691054
notBefore: certMetadata.validity.notBefore,
10701055
notAfter: certMetadata.validity.notAfter,
10711056
kmsCertificateId: certInfo.certificateId,
1072-
kmsKeyId: input.generateNewKey ? kmsKeyId : null,
1057+
kmsKeyId: kmsKeyId, // Always store since we always generate new keys
10731058
status: 'active',
10741059
sanDns: sanDns ? JSON.stringify(sanDns) : null,
10751060
sanIp: sanIp ? JSON.stringify(sanIp) : null,
@@ -2594,7 +2579,7 @@ export const certificateRouter = router({
25942579
notBefore: certMetadata.validity.notBefore,
25952580
notAfter: certMetadata.validity.notAfter,
25962581
kmsCertificateId: certInfo.certificateId,
2597-
kmsKeyId: input.generateNewKey ? kmsKeyId : null,
2582+
kmsKeyId: kmsKeyId, // Always store since we always generate new keys
25982583
status: 'active',
25992584
sanDns: sanDns ? JSON.stringify(sanDns) : null,
26002585
sanIp: sanIp ? JSON.stringify(sanIp) : null,

0 commit comments

Comments
 (0)