Compare commits
5 Commits
0.6.1
..
c4a86c1532
| Author | SHA1 | Date | |
|---|---|---|---|
| c4a86c1532 | |||
| 668f3c3e28 | |||
| 258231e58c | |||
| d373c8a72d | |||
| b7fe873dc9 |
@@ -0,0 +1,198 @@
|
|||||||
|
# Bug Reports
|
||||||
|
|
||||||
|
## `@azure/keyvault-certificates` — `CertificateClient.importCertificate` silently drops `policy.contentType`
|
||||||
|
|
||||||
|
**Package:** `@azure/keyvault-certificates`
|
||||||
|
**Confirmed versions:** 4.10.3, current `main` branch
|
||||||
|
**Source file:** `sdk/keyvault/keyvault-certificates/src/index.ts`
|
||||||
|
**Serializer file:** `sdk/keyvault/keyvault-certificates/src/models/models.ts`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Summary
|
||||||
|
|
||||||
|
`CertificateClient.importCertificate()` accepts `ImportCertificateOptions.policy.contentType` as part of its public API but never sends it to Azure. The value is silently dropped due to a key name mismatch between the high-level client and the generated REST serializer. As a result, Azure falls back to the existing stored policy for that certificate name. When that policy specifies a different format than the bytes being imported (e.g. importing PFX into a certificate previously stored as PEM), Azure rejects the request with:
|
||||||
|
|
||||||
|
> `The specified PEM X.509 certificate content is in an unexpected format.`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Root Cause
|
||||||
|
|
||||||
|
In `index.ts`, `importCertificate` builds the parameters object for the low-level generated client by spreading `updatedOptions`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const result = await this.client.importCertificate(
|
||||||
|
certificateName,
|
||||||
|
{
|
||||||
|
base64EncodedCertificate,
|
||||||
|
preserveCertOrder: updatedOptions.preserveCertificateOrder,
|
||||||
|
...updatedOptions, // contributes: policy, password, tags, ...
|
||||||
|
},
|
||||||
|
updatedOptions,
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
The spread adds the property as `policy` (the public API name from `ImportCertificateOptions`).
|
||||||
|
|
||||||
|
In `models/models.ts`, the generated serializer reads a **different** key name:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export function certificateImportParametersSerializer(item: CertificateImportParameters): any {
|
||||||
|
return {
|
||||||
|
value: item["base64EncodedCertificate"],
|
||||||
|
pwd: item["password"],
|
||||||
|
policy: !item["certificatePolicy"] // reads "certificatePolicy", not "policy"
|
||||||
|
? item["certificatePolicy"]
|
||||||
|
: certificatePolicySerializer(item["certificatePolicy"]),
|
||||||
|
attributes: !item["certificateAttributes"]
|
||||||
|
? item["certificateAttributes"]
|
||||||
|
: certificateAttributesSerializer(item["certificateAttributes"]),
|
||||||
|
tags: item["tags"],
|
||||||
|
preserveCertOrder: item["preserveCertOrder"],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`item["certificatePolicy"]` is always `undefined` because the spread only populated `item["policy"]`. The REST body is sent with `policy: null`, regardless of what the caller specified.
|
||||||
|
|
||||||
|
**Note:** `password` is not affected — it is read as `item["password"]` which matches the spread key and is correctly transmitted.
|
||||||
|
|
||||||
|
**Note:** The Python SDK does not have this bug — it uses a different serialization architecture.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Effect
|
||||||
|
|
||||||
|
`ImportCertificateOptions.policy` is effectively a no-op. Any value passed is ignored. The observable consequence:
|
||||||
|
|
||||||
|
- Importing a certificate for the first time works, because Azure auto-detects the format from the bytes (PEM headers are recognisable; PFX ASN.1 magic bytes may also be detected).
|
||||||
|
- Importing a **new version** of an existing certificate in a **different format** fails: Azure validates the incoming bytes against the stored policy's `content_type`, which no longer matches.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Reproduction
|
||||||
|
|
||||||
|
A self-contained runnable script is provided in [`docs/bug-reproduce.ts`](bug-reproduce.ts):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
KEYVAULT_NAME=<vault> npx tsx docs/bug-reproduce.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
The script generates a self-signed certificate, imports it as PEM (Step 1 — succeeds), then attempts to re-import it as PFX with `policy.contentType: "application/x-pkcs12"` (Step 2 — fails with the error above, confirming the bug).
|
||||||
|
|
||||||
|
The workaround is demonstrated in [`docs/bug-workaround.ts`](bug-workaround.ts):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
KEYVAULT_NAME=<vault> npx tsx docs/bug-workaround.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
This calls `updateCertificatePolicy()` before the PFX import to pre-set the stored `content_type`, allowing the import to succeed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
In `sdk/keyvault/keyvault-certificates/src/index.ts`, explicitly map the public `policy` field to the internal `certificatePolicy` key after the spread:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// BEFORE
|
||||||
|
const result = await this.client.importCertificate(
|
||||||
|
certificateName,
|
||||||
|
{
|
||||||
|
base64EncodedCertificate,
|
||||||
|
preserveCertOrder: updatedOptions.preserveCertificateOrder,
|
||||||
|
...updatedOptions,
|
||||||
|
},
|
||||||
|
updatedOptions,
|
||||||
|
);
|
||||||
|
|
||||||
|
// AFTER
|
||||||
|
const result = await this.client.importCertificate(
|
||||||
|
certificateName,
|
||||||
|
{
|
||||||
|
base64EncodedCertificate,
|
||||||
|
preserveCertOrder: updatedOptions.preserveCertificateOrder,
|
||||||
|
...updatedOptions,
|
||||||
|
certificatePolicy: updatedOptions.policy, // map public name → serializer key
|
||||||
|
},
|
||||||
|
updatedOptions,
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
No changes required to the serializer, the public `ImportCertificateOptions` interface, or any other file.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Test
|
||||||
|
|
||||||
|
Place in `sdk/keyvault/keyvault-certificates/test/` alongside the existing import tests. The test intercepts the outgoing HTTP request and asserts that `policy.secret_props.content_type` is present in the body.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { assert } from "@azure/test-utils";
|
||||||
|
import { CertificateClient } from "../../src/index.js";
|
||||||
|
import { createTestCredential } from "@azure-tools/test-credential";
|
||||||
|
import { HttpClient, PipelineRequest, PipelineResponse } from "@azure/core-rest-pipeline";
|
||||||
|
|
||||||
|
function makeFakeResponse(): PipelineResponse {
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
headers: { get: () => "application/json", set: () => {}, has: () => true, delete: () => {}, toJSON: () => ({}) } as any,
|
||||||
|
bodyAsText: JSON.stringify({
|
||||||
|
id: "https://vault.azure.net/certificates/MyCert/abc123",
|
||||||
|
cer: Buffer.alloc(0).toString("base64"),
|
||||||
|
attributes: { enabled: true },
|
||||||
|
policy: {
|
||||||
|
secret_props: { contentType: "application/x-pkcs12" },
|
||||||
|
issuer: { name: "Unknown" },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
request: null as any,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
it("importCertificate sends policy.contentType in the REST body", async () => {
|
||||||
|
let capturedBody: any;
|
||||||
|
|
||||||
|
const fakeHttpClient: HttpClient = {
|
||||||
|
sendRequest: async (request: PipelineRequest): Promise<PipelineResponse> => {
|
||||||
|
capturedBody = JSON.parse(request.body as string);
|
||||||
|
return makeFakeResponse();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const client = new CertificateClient(
|
||||||
|
"https://fakevault.vault.azure.net",
|
||||||
|
createTestCredential(),
|
||||||
|
{ httpClient: fakeHttpClient },
|
||||||
|
);
|
||||||
|
|
||||||
|
const pfxBytes = Buffer.from("fakepfxbytes");
|
||||||
|
|
||||||
|
await client.importCertificate("MyCert", pfxBytes, {
|
||||||
|
password: "secret",
|
||||||
|
policy: { contentType: "application/x-pkcs12" },
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.isDefined(
|
||||||
|
capturedBody?.policy?.secret_props?.contentType,
|
||||||
|
"policy.secret_props.contentType must be present in the REST body",
|
||||||
|
);
|
||||||
|
assert.strictEqual(
|
||||||
|
capturedBody.policy.secret_props.contentType,
|
||||||
|
"application/x-pkcs12",
|
||||||
|
"contentType must match the value passed in ImportCertificateOptions.policy",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Contribution Steps
|
||||||
|
|
||||||
|
1. Search [azure-sdk-for-js issues](https://github.com/Azure/azure-sdk-for-js/issues) for `importCertificate policy contentType` — file a new issue if none exists.
|
||||||
|
2. Fork `Azure/azure-sdk-for-js` on GitHub.
|
||||||
|
3. Clone your fork locally.
|
||||||
|
4. Apply the one-line fix in `sdk/keyvault/keyvault-certificates/src/index.ts`.
|
||||||
|
5. Add the test above to the existing import test suite.
|
||||||
|
6. Open a PR referencing the issue, with this document as the description basis.
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
// Reproduces the bug in @azure/keyvault-certificates:
|
||||||
|
// CertificateClient.importCertificate() silently drops policy.contentType,
|
||||||
|
// so Azure validates incoming bytes against the existing stored policy.
|
||||||
|
// Importing PFX into a certificate previously stored as PEM fails with:
|
||||||
|
// "The specified PEM X.509 certificate content is in an unexpected format."
|
||||||
|
//
|
||||||
|
// Requirements: openssl in PATH
|
||||||
|
// Usage: KEYVAULT_NAME=<vault> node docs/bug-reproduce.mjs
|
||||||
|
|
||||||
|
import { execSync } from 'node:child_process';
|
||||||
|
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
||||||
|
import { randomBytes } from 'node:crypto';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { DefaultAzureCredential } from '@azure/identity';
|
||||||
|
import { CertificateClient } from '@azure/keyvault-certificates';
|
||||||
|
|
||||||
|
const vaultName = process.env.KEYVAULT_NAME;
|
||||||
|
if (!vaultName) {
|
||||||
|
console.error('Set KEYVAULT_NAME');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const vaultUrl = `https://${vaultName}.vault.azure.net`;
|
||||||
|
|
||||||
|
const CERT_NAME = `bug-reproduce-${randomBytes(4).toString('hex')}`;
|
||||||
|
const PFX_PASSWORD = 'test-password-123';
|
||||||
|
|
||||||
|
const tmp = mkdtempSync(join(tmpdir(), 'kv-bug-'));
|
||||||
|
try {
|
||||||
|
console.log('Generating self-signed certificate...');
|
||||||
|
execSync(
|
||||||
|
`openssl req -x509 -newkey rsa:2048 -keyout "${join(tmp, 'key.pem')}" -out "${join(tmp, 'cert.pem')}" -days 365 -nodes -subj "/CN=bug-reproduce-test"`,
|
||||||
|
{ stdio: 'pipe' },
|
||||||
|
);
|
||||||
|
execSync(
|
||||||
|
`openssl pkcs12 -export -out "${join(tmp, 'cert.pfx')}" -inkey "${join(tmp, 'key.pem')}" -in "${join(tmp, 'cert.pem')}" -passout pass:${PFX_PASSWORD}`,
|
||||||
|
{ stdio: 'pipe' },
|
||||||
|
);
|
||||||
|
|
||||||
|
const pemBytes = Buffer.concat([readFileSync(join(tmp, 'cert.pem')), readFileSync(join(tmp, 'key.pem'))]);
|
||||||
|
const pfxBytes = readFileSync(join(tmp, 'cert.pfx'));
|
||||||
|
|
||||||
|
const credential = new DefaultAzureCredential();
|
||||||
|
const client = new CertificateClient(vaultUrl, credential);
|
||||||
|
|
||||||
|
console.log(`\nStep 1: importing '${CERT_NAME}' as PEM...`);
|
||||||
|
try {
|
||||||
|
await client.importCertificate(CERT_NAME, pemBytes, {
|
||||||
|
policy: { contentType: 'application/x-pem-file', issuerName: 'Unknown', subject: 'CN=bug-reproduce-test' },
|
||||||
|
});
|
||||||
|
console.log(' OK — PEM import succeeded.');
|
||||||
|
} catch (err) {
|
||||||
|
console.error(` FAILED — ${err.message}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\nStep 2: re-importing '${CERT_NAME}' as PFX (policy.contentType should tell Azure to expect PFX)...`);
|
||||||
|
try {
|
||||||
|
await client.importCertificate(CERT_NAME, pfxBytes, {
|
||||||
|
password: PFX_PASSWORD,
|
||||||
|
policy: { contentType: 'application/x-pkcs12', issuerName: 'Unknown', subject: 'CN=bug-reproduce-test' },
|
||||||
|
});
|
||||||
|
console.log(' OK — PFX import succeeded (bug may be fixed in this SDK version).');
|
||||||
|
} catch (err) {
|
||||||
|
console.error(` FAILED — ${err.message}`);
|
||||||
|
console.error('\n This confirms the bug: policy.contentType was silently dropped.');
|
||||||
|
console.error(' Azure validated the PFX bytes against the existing PEM policy and rejected them.');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
rmSync(tmp, { recursive: true });
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
// Reproduces the bug in @azure/keyvault-certificates:
|
||||||
|
// CertificateClient.importCertificate() silently drops policy.contentType,
|
||||||
|
// so Azure validates incoming bytes against the existing stored policy.
|
||||||
|
// Importing PFX into a certificate previously stored as PEM fails with:
|
||||||
|
// "The specified PEM X.509 certificate content is in an unexpected format."
|
||||||
|
//
|
||||||
|
// Usage: KEYVAULT_NAME=<vault> npx tsx docs/bug-reproduce.ts
|
||||||
|
|
||||||
|
import { webcrypto, randomBytes } from 'node:crypto';
|
||||||
|
import * as forge from 'node-forge';
|
||||||
|
import { DefaultAzureCredential } from '@azure/identity';
|
||||||
|
import { CertificateClient } from '@azure/keyvault-certificates';
|
||||||
|
|
||||||
|
const ALG: RsaHashedKeyGenParams = {
|
||||||
|
name: 'RSASSA-PKCS1-v1_5',
|
||||||
|
modulusLength: 2048,
|
||||||
|
publicExponent: new Uint8Array([1, 0, 1]),
|
||||||
|
hash: 'SHA-256',
|
||||||
|
};
|
||||||
|
|
||||||
|
async function generateSelfSigned(cn: string): Promise<{ privateKeyPem: string; certPem: string }> {
|
||||||
|
const keyPair = await webcrypto.subtle.generateKey(ALG, true, ['sign', 'verify']);
|
||||||
|
|
||||||
|
const privateKeyDer = await webcrypto.subtle.exportKey('pkcs8', keyPair.privateKey);
|
||||||
|
const privateKeyB64 = Buffer.from(privateKeyDer).toString('base64').match(/.{1,64}/g)!.join('\n');
|
||||||
|
const privateKeyPem = `-----BEGIN PRIVATE KEY-----\n${privateKeyB64}\n-----END PRIVATE KEY-----\n`;
|
||||||
|
|
||||||
|
const privateKey = forge.pki.privateKeyFromPem(privateKeyPem);
|
||||||
|
const publicKey = forge.pki.rsa.setPublicKey(privateKey.n, privateKey.e);
|
||||||
|
|
||||||
|
const cert = forge.pki.createCertificate();
|
||||||
|
cert.publicKey = publicKey;
|
||||||
|
cert.serialNumber = '01';
|
||||||
|
cert.validity.notBefore = new Date();
|
||||||
|
cert.validity.notAfter = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000);
|
||||||
|
const attrs = [{ name: 'commonName', value: cn }];
|
||||||
|
cert.setSubject(attrs);
|
||||||
|
cert.setIssuer(attrs);
|
||||||
|
cert.sign(privateKey, forge.md.sha256.create());
|
||||||
|
|
||||||
|
return { privateKeyPem, certPem: forge.pki.certificateToPem(cert) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function pemToPfx(privateKeyPem: string, certPem: string, password: string): Buffer {
|
||||||
|
const key = forge.pki.privateKeyFromPem(privateKeyPem);
|
||||||
|
const cert = forge.pki.certificateFromPem(certPem);
|
||||||
|
const p12 = forge.pkcs12.toPkcs12Asn1(key, [cert], password, { algorithm: '3des' });
|
||||||
|
return Buffer.from(forge.asn1.toDer(p12).getBytes(), 'binary');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const vaultName = process.env.KEYVAULT_NAME;
|
||||||
|
if (!vaultName) {
|
||||||
|
console.error('Set KEYVAULT_NAME');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const vaultUrl = `https://${vaultName}.vault.azure.net`;
|
||||||
|
|
||||||
|
const hash = randomBytes(4).toString('hex');
|
||||||
|
const CERT_NAME = `bug-reproduce-${hash}`;
|
||||||
|
const PFX_PASSWORD = 'test-password-123';
|
||||||
|
|
||||||
|
const credential = new DefaultAzureCredential();
|
||||||
|
const client = new CertificateClient(vaultUrl, credential);
|
||||||
|
|
||||||
|
console.log(`Generating self-signed certificate (${CERT_NAME})...`);
|
||||||
|
const { privateKeyPem, certPem } = await generateSelfSigned(CERT_NAME);
|
||||||
|
const pemBytes = Buffer.from(certPem + privateKeyPem);
|
||||||
|
const pfxBytes = pemToPfx(privateKeyPem, certPem, PFX_PASSWORD);
|
||||||
|
|
||||||
|
console.log(`\nStep 1: importing '${CERT_NAME}' as PEM...`);
|
||||||
|
try {
|
||||||
|
await client.importCertificate(CERT_NAME, pemBytes, {
|
||||||
|
policy: { contentType: 'application/x-pem-file', issuerName: 'Unknown', subject: `CN=${CERT_NAME}` },
|
||||||
|
});
|
||||||
|
console.log(' OK — PEM import succeeded.');
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.error(` FAILED — ${err instanceof Error ? err.message : err}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\nStep 2: re-importing '${CERT_NAME}' as PFX (policy.contentType should tell Azure to expect PFX)...`);
|
||||||
|
try {
|
||||||
|
await client.importCertificate(CERT_NAME, pfxBytes, {
|
||||||
|
password: PFX_PASSWORD,
|
||||||
|
policy: { contentType: 'application/x-pkcs12', issuerName: 'Unknown', subject: `CN=${CERT_NAME}` },
|
||||||
|
});
|
||||||
|
console.log(' OK — PFX import succeeded (bug may be fixed in this SDK version).');
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.error(` FAILED — ${err instanceof Error ? err.message : err}`);
|
||||||
|
console.error('\n This confirms the bug: policy.contentType was silently dropped.');
|
||||||
|
console.error(' Azure validated the PFX bytes against the existing PEM policy and rejected them.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(err => { console.error(err); process.exit(1); });
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
// Demonstrates the workaround for the bug in @azure/keyvault-certificates:
|
||||||
|
// Since importCertificate() silently drops policy.contentType, we call
|
||||||
|
// updateCertificatePolicy() first to shift the stored content_type to the
|
||||||
|
// new format before importing. Azure then validates incoming bytes correctly.
|
||||||
|
//
|
||||||
|
// Requirements: openssl in PATH
|
||||||
|
// Usage: KEYVAULT_NAME=<vault> node docs/bug-workaround.mjs
|
||||||
|
|
||||||
|
import { execSync } from 'node:child_process';
|
||||||
|
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
||||||
|
import { randomBytes } from 'node:crypto';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { DefaultAzureCredential } from '@azure/identity';
|
||||||
|
import { CertificateClient } from '@azure/keyvault-certificates';
|
||||||
|
|
||||||
|
const vaultName = process.env.KEYVAULT_NAME;
|
||||||
|
if (!vaultName) {
|
||||||
|
console.error('Set KEYVAULT_NAME');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const vaultUrl = `https://${vaultName}.vault.azure.net`;
|
||||||
|
|
||||||
|
const CERT_NAME = `bug-workaround-${randomBytes(4).toString('hex')}`;
|
||||||
|
const PFX_PASSWORD = 'test-password-123';
|
||||||
|
|
||||||
|
const tmp = mkdtempSync(join(tmpdir(), 'kv-workaround-'));
|
||||||
|
try {
|
||||||
|
console.log('Generating self-signed certificate...');
|
||||||
|
execSync(
|
||||||
|
`openssl req -x509 -newkey rsa:2048 -keyout "${join(tmp, 'key.pem')}" -out "${join(tmp, 'cert.pem')}" -days 365 -nodes -subj "/CN=bug-workaround-test"`,
|
||||||
|
{ stdio: 'pipe' },
|
||||||
|
);
|
||||||
|
execSync(
|
||||||
|
`openssl pkcs12 -export -out "${join(tmp, 'cert.pfx')}" -inkey "${join(tmp, 'key.pem')}" -in "${join(tmp, 'cert.pem')}" -passout pass:${PFX_PASSWORD}`,
|
||||||
|
{ stdio: 'pipe' },
|
||||||
|
);
|
||||||
|
|
||||||
|
const pemBytes = Buffer.concat([readFileSync(join(tmp, 'cert.pem')), readFileSync(join(tmp, 'key.pem'))]);
|
||||||
|
const pfxBytes = readFileSync(join(tmp, 'cert.pfx'));
|
||||||
|
|
||||||
|
const credential = new DefaultAzureCredential();
|
||||||
|
const client = new CertificateClient(vaultUrl, credential);
|
||||||
|
|
||||||
|
console.log(`\nStep 1: importing '${CERT_NAME}' as PEM...`);
|
||||||
|
try {
|
||||||
|
await client.importCertificate(CERT_NAME, pemBytes, {
|
||||||
|
policy: { contentType: 'application/x-pem-file', issuerName: 'Unknown', subject: 'CN=bug-workaround-test' },
|
||||||
|
});
|
||||||
|
console.log(' OK — PEM import succeeded.');
|
||||||
|
} catch (err) {
|
||||||
|
console.error(` FAILED — ${err.message}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\nStep 2: updating policy to application/x-pkcs12 before importing PFX...`);
|
||||||
|
await client.updateCertificatePolicy(CERT_NAME, {
|
||||||
|
contentType: 'application/x-pkcs12',
|
||||||
|
issuerName: 'Unknown',
|
||||||
|
subject: 'CN=bug-workaround-test',
|
||||||
|
});
|
||||||
|
console.log(' OK — policy updated.');
|
||||||
|
|
||||||
|
console.log(`\nStep 3: importing '${CERT_NAME}' as PFX...`);
|
||||||
|
await client.importCertificate(CERT_NAME, pfxBytes, {
|
||||||
|
password: PFX_PASSWORD,
|
||||||
|
});
|
||||||
|
console.log(' OK — PFX import succeeded.');
|
||||||
|
|
||||||
|
console.log('\nWorkaround confirmed: updateCertificatePolicy() before import allows format change.');
|
||||||
|
} finally {
|
||||||
|
rmSync(tmp, { recursive: true });
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
// Demonstrates the workaround for the bug in @azure/keyvault-certificates:
|
||||||
|
// Since importCertificate() silently drops policy.contentType, we call
|
||||||
|
// updateCertificatePolicy() first to shift the stored content_type to the
|
||||||
|
// new format before importing. Azure then validates incoming bytes correctly.
|
||||||
|
//
|
||||||
|
// Usage: KEYVAULT_NAME=<vault> npx tsx docs/bug-workaround.ts
|
||||||
|
|
||||||
|
import { webcrypto, randomBytes } from 'node:crypto';
|
||||||
|
import * as forge from 'node-forge';
|
||||||
|
import { DefaultAzureCredential } from '@azure/identity';
|
||||||
|
import { CertificateClient, CertificatePolicy } from '@azure/keyvault-certificates';
|
||||||
|
|
||||||
|
const ALG: RsaHashedKeyGenParams = {
|
||||||
|
name: 'RSASSA-PKCS1-v1_5',
|
||||||
|
modulusLength: 2048,
|
||||||
|
publicExponent: new Uint8Array([1, 0, 1]),
|
||||||
|
hash: 'SHA-256',
|
||||||
|
};
|
||||||
|
|
||||||
|
async function generateSelfSigned(cn: string): Promise<{ privateKeyPem: string; certPem: string }> {
|
||||||
|
const keyPair = await webcrypto.subtle.generateKey(ALG, true, ['sign', 'verify']);
|
||||||
|
|
||||||
|
const privateKeyDer = await webcrypto.subtle.exportKey('pkcs8', keyPair.privateKey);
|
||||||
|
const privateKeyB64 = Buffer.from(privateKeyDer).toString('base64').match(/.{1,64}/g)!.join('\n');
|
||||||
|
const privateKeyPem = `-----BEGIN PRIVATE KEY-----\n${privateKeyB64}\n-----END PRIVATE KEY-----\n`;
|
||||||
|
|
||||||
|
const privateKey = forge.pki.privateKeyFromPem(privateKeyPem);
|
||||||
|
const publicKey = forge.pki.rsa.setPublicKey(privateKey.n, privateKey.e);
|
||||||
|
|
||||||
|
const cert = forge.pki.createCertificate();
|
||||||
|
cert.publicKey = publicKey;
|
||||||
|
cert.serialNumber = '01';
|
||||||
|
cert.validity.notBefore = new Date();
|
||||||
|
cert.validity.notAfter = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000);
|
||||||
|
const attrs = [{ name: 'commonName', value: cn }];
|
||||||
|
cert.setSubject(attrs);
|
||||||
|
cert.setIssuer(attrs);
|
||||||
|
cert.sign(privateKey, forge.md.sha256.create());
|
||||||
|
|
||||||
|
return { privateKeyPem, certPem: forge.pki.certificateToPem(cert) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function pemToPfx(privateKeyPem: string, certPem: string, password: string): Buffer {
|
||||||
|
const key = forge.pki.privateKeyFromPem(privateKeyPem);
|
||||||
|
const cert = forge.pki.certificateFromPem(certPem);
|
||||||
|
const p12 = forge.pkcs12.toPkcs12Asn1(key, [cert], password, { algorithm: '3des' });
|
||||||
|
return Buffer.from(forge.asn1.toDer(p12).getBytes(), 'binary');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const vaultName = process.env.KEYVAULT_NAME;
|
||||||
|
if (!vaultName) {
|
||||||
|
console.error('Set KEYVAULT_NAME');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const vaultUrl = `https://${vaultName}.vault.azure.net`;
|
||||||
|
|
||||||
|
const hash = randomBytes(4).toString('hex');
|
||||||
|
const CERT_NAME = `bug-workaround-${hash}`;
|
||||||
|
const PFX_PASSWORD = 'test-password-123';
|
||||||
|
|
||||||
|
const credential = new DefaultAzureCredential();
|
||||||
|
const client = new CertificateClient(vaultUrl, credential);
|
||||||
|
|
||||||
|
console.log(`Generating self-signed certificate (${CERT_NAME})...`);
|
||||||
|
const { privateKeyPem, certPem } = await generateSelfSigned(CERT_NAME);
|
||||||
|
const pemBytes = Buffer.from(certPem + privateKeyPem);
|
||||||
|
const pfxBytes = pemToPfx(privateKeyPem, certPem, PFX_PASSWORD);
|
||||||
|
|
||||||
|
console.log(`\nStep 1: importing '${CERT_NAME}' as PEM...`);
|
||||||
|
try {
|
||||||
|
await client.importCertificate(CERT_NAME, pemBytes, {
|
||||||
|
policy: { contentType: 'application/x-pem-file', issuerName: 'Unknown', subject: `CN=${CERT_NAME}` },
|
||||||
|
});
|
||||||
|
console.log(' OK — PEM import succeeded.');
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.error(` FAILED — ${err instanceof Error ? err.message : err}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\nStep 2: updating policy to application/x-pkcs12 before importing PFX...`);
|
||||||
|
try {
|
||||||
|
await client.updateCertificatePolicy(CERT_NAME, { contentType: 'application/x-pkcs12', issuerName: 'Unknown', subject: `CN=${CERT_NAME}` } as CertificatePolicy);
|
||||||
|
console.log(' OK — policy updated.');
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.error(` FAILED — ${err instanceof Error ? err.message : err}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\nStep 3: importing '${CERT_NAME}' as PFX...`);
|
||||||
|
try {
|
||||||
|
await client.importCertificate(CERT_NAME, pfxBytes, { password: PFX_PASSWORD });
|
||||||
|
console.log(' OK — PFX import succeeded.');
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.error(` FAILED — ${err instanceof Error ? err.message : err}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\nWorkaround confirmed: updateCertificatePolicy() before import allows format change.');
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(err => { console.error(err); process.exit(1); });
|
||||||
+12
-18
@@ -1,6 +1,7 @@
|
|||||||
import { TokenCredential } from '@azure/identity';
|
import { TokenCredential } from '@azure/identity';
|
||||||
import {
|
import {
|
||||||
CertificateClient,
|
CertificateClient,
|
||||||
|
CertificatePolicy,
|
||||||
KeyVaultCertificateWithPolicy,
|
KeyVaultCertificateWithPolicy,
|
||||||
} from '@azure/keyvault-certificates';
|
} from '@azure/keyvault-certificates';
|
||||||
import { SecretClient } from '@azure/keyvault-secrets';
|
import { SecretClient } from '@azure/keyvault-secrets';
|
||||||
@@ -48,24 +49,17 @@ export class KeyVaultStore {
|
|||||||
|
|
||||||
async importCertificate(name: string, cert: string | Buffer, format: 'pem' | 'pfx' = 'pem', password?: string): Promise<void> {
|
async importCertificate(name: string, cert: string | Buffer, format: 'pem' | 'pfx' = 'pem', password?: string): Promise<void> {
|
||||||
const certBuffer = typeof cert === 'string' ? Buffer.from(cert) : cert;
|
const certBuffer = typeof cert === 'string' ? Buffer.from(cert) : cert;
|
||||||
// The high-level CertificateClient spreads `policy` into import params but the
|
const contentType = format === 'pfx' ? 'application/x-pkcs12' : 'application/x-pem-file';
|
||||||
// generated serializer reads `certificatePolicy` — a key mismatch that silently
|
try {
|
||||||
// drops content_type from the REST body. Call the internal client directly so
|
// When a certificate already exists, Azure validates the incoming bytes against
|
||||||
// secret_props.content_type reaches Azure (required for PFX; without it Azure
|
// its stored policy's content_type. Updating the policy first tells Azure to
|
||||||
// defaults to PEM parsing and rejects binary PFX data).
|
// expect the new format; without this, converting PEM→PFX (or vice-versa)
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// fails because Azure tries to parse binary PFX data as PEM.
|
||||||
const internalClient = (this.certClient as any).client;
|
await this.certClient.updateCertificatePolicy(name, { contentType } as CertificatePolicy);
|
||||||
await internalClient.importCertificate(name, {
|
} catch {
|
||||||
base64EncodedCertificate: format === 'pem'
|
// Certificate doesn't exist yet — no policy to update, proceed to import.
|
||||||
? certBuffer.toString('ascii')
|
}
|
||||||
: certBuffer.toString('base64'),
|
await this.certClient.importCertificate(name, certBuffer, { password });
|
||||||
password,
|
|
||||||
certificatePolicy: {
|
|
||||||
secretProperties: {
|
|
||||||
contentType: format === 'pfx' ? 'application/x-pkcs12' : 'application/x-pem-file',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}, {});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+7
-1
@@ -32,9 +32,15 @@ export function pfxToPem(pfxBuffer: Buffer, password = ''): PemBundle {
|
|||||||
if (!keyBag?.key) throw new Error('No private key found in PFX');
|
if (!keyBag?.key) throw new Error('No private key found in PFX');
|
||||||
if (allCertBags.length === 0) throw new Error('No certificates found in PFX');
|
if (allCertBags.length === 0) throw new Error('No certificates found in PFX');
|
||||||
|
|
||||||
|
// Export as PKCS#8 — Azure Key Vault rejects PKCS#1 (BEGIN RSA PRIVATE KEY) in PEM imports
|
||||||
|
const pkcs8Asn1 = forge.pki.wrapRsaPrivateKey(forge.pki.privateKeyToAsn1(keyBag.key));
|
||||||
|
const pkcs8Der = forge.asn1.toDer(pkcs8Asn1).getBytes();
|
||||||
|
const pkcs8B64 = forge.util.encode64(pkcs8Der).match(/.{1,64}/g)!.join('\n');
|
||||||
|
const privateKeyPem = `-----BEGIN PRIVATE KEY-----\n${pkcs8B64}\n-----END PRIVATE KEY-----\n`;
|
||||||
|
|
||||||
const [first, ...rest] = allCertBags.map(bag => forge.pki.certificateToPem(bag.cert!));
|
const [first, ...rest] = allCertBags.map(bag => forge.pki.certificateToPem(bag.cert!));
|
||||||
return {
|
return {
|
||||||
privateKeyPem: forge.pki.privateKeyToPem(keyBag.key),
|
privateKeyPem,
|
||||||
certPem: first,
|
certPem: first,
|
||||||
chainPem: rest.join(''),
|
chainPem: rest.join(''),
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user