mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-30 23:34:27 +00:00
241 lines
8.5 KiB
TypeScript
241 lines
8.5 KiB
TypeScript
// In-browser X.509 self-signed certificate generator for the Azure
|
|
// certificate-authentication onboarding flow.
|
|
//
|
|
// The keypair is generated with the browser's native Web Crypto API
|
|
// (`crypto.subtle.generateKey`) and never leaves the tab. `@peculiar/x509`
|
|
// wraps the public key in a self-signed X.509 certificate whose SHA-1
|
|
// thumbprint we can hand back to the user; the private key is exported as
|
|
// base64-encoded PEM ready to paste into the Prowler wizard's Certificate
|
|
// Private Key field.
|
|
//
|
|
// See the certificate authentication guide in
|
|
// docs/user-guide/providers/azure/authentication.mdx for the equivalent
|
|
// openssl/PowerShell recipes, and PROWLER-2378 for the Deploy-to-Azure
|
|
// quick-start feature this UX affordance belongs to.
|
|
|
|
// `@peculiar/x509` transitively depends on `tsyringe`, which pulls in a
|
|
// decorators/DI runtime that requires the `reflect-metadata` polyfill. The
|
|
// import has to happen before anything from `@peculiar/x509` is imported so
|
|
// the metadata store is registered on `Reflect` first.
|
|
import "reflect-metadata";
|
|
|
|
import {
|
|
cryptoProvider,
|
|
Extension,
|
|
X509CertificateGenerator,
|
|
} from "@peculiar/x509";
|
|
|
|
// Bind @peculiar/x509 to the browser's native SubtleCrypto. Without this the
|
|
// library falls back to a Node-only crypto provider that vitest+jsdom does
|
|
// not expose, and the helper would throw in tests.
|
|
if (typeof globalThis !== "undefined" && globalThis.crypto?.subtle) {
|
|
cryptoProvider.set(globalThis.crypto);
|
|
}
|
|
|
|
export interface GeneratedProwlerCertificate {
|
|
/**
|
|
* Base64 of the DER-encoded X.509 public certificate. Feed this into the
|
|
* `certificateBase64` parameter of the Prowler Bicep quick-start template
|
|
* (or paste directly into the Azure Portal "Certificate Base64" field).
|
|
*/
|
|
publicCertificateBase64Der: string;
|
|
/**
|
|
* Base64 of the PEM bundle containing both the X.509 certificate and the
|
|
* PKCS#8 private key. `azure.identity.CertificateCredential` accepts this
|
|
* exact shape; the Prowler wizard pastes it into the Certificate Private
|
|
* Key (Base64) textarea.
|
|
*/
|
|
privateKeyBundleBase64Pem: string;
|
|
/** Human-readable SHA-1 thumbprint, uppercase hex, matching what Entra ID displays. */
|
|
thumbprintHex: string;
|
|
/** ISO 8601 not-valid-before timestamp of the generated cert. */
|
|
notBefore: string;
|
|
/** ISO 8601 not-valid-after timestamp of the generated cert. */
|
|
notAfter: string;
|
|
}
|
|
|
|
export interface GenerateProwlerCertificateOptions {
|
|
/**
|
|
* Common name to embed in the certificate subject. Defaults to "Prowler"
|
|
* to match the openssl/PowerShell examples in the docs.
|
|
*/
|
|
commonName?: string;
|
|
/** Certificate lifetime in days. Default 365. */
|
|
validityDays?: number;
|
|
/** RSA modulus length. Default 4096 (matches the openssl example). */
|
|
modulusLength?: 2048 | 3072 | 4096;
|
|
/**
|
|
* Injected clock for deterministic tests. Defaults to `Date.now()`.
|
|
*/
|
|
now?: () => Date;
|
|
}
|
|
|
|
const DEFAULTS: Required<
|
|
Pick<
|
|
GenerateProwlerCertificateOptions,
|
|
"commonName" | "validityDays" | "modulusLength"
|
|
>
|
|
> = {
|
|
commonName: "Prowler",
|
|
validityDays: 365,
|
|
modulusLength: 4096,
|
|
};
|
|
|
|
/**
|
|
* Generate a fresh self-signed X.509 certificate + RSA-4096 keypair in the
|
|
* browser. Nothing crosses the network — the private key exists only in the
|
|
* returned object and inside the caller's memory.
|
|
*
|
|
* Throws when the browser does not expose SubtleCrypto (e.g. insecure origin
|
|
* or old browser). Callers should surface a friendly fallback ("use openssl
|
|
* instead") when that happens.
|
|
*/
|
|
export async function generateProwlerCertificate(
|
|
options: GenerateProwlerCertificateOptions = {},
|
|
): Promise<GeneratedProwlerCertificate> {
|
|
const commonName = options.commonName ?? DEFAULTS.commonName;
|
|
const validityDays = options.validityDays ?? DEFAULTS.validityDays;
|
|
const modulusLength = options.modulusLength ?? DEFAULTS.modulusLength;
|
|
const now = options.now ?? (() => new Date());
|
|
|
|
const subtle = globalThis.crypto?.subtle;
|
|
if (!subtle) {
|
|
throw new Error(
|
|
"Web Crypto API is not available in this browser. Use the openssl or PowerShell instructions from the certificate generation guide instead.",
|
|
);
|
|
}
|
|
|
|
const keyPair = (await subtle.generateKey(
|
|
{
|
|
name: "RSASSA-PKCS1-v1_5",
|
|
modulusLength,
|
|
publicExponent: new Uint8Array([1, 0, 1]),
|
|
hash: "SHA-256",
|
|
},
|
|
true,
|
|
["sign", "verify"],
|
|
)) as CryptoKeyPair;
|
|
|
|
const notBefore = now();
|
|
const notAfter = new Date(
|
|
notBefore.getTime() + validityDays * 24 * 60 * 60 * 1000,
|
|
);
|
|
|
|
const cert = await X509CertificateGenerator.createSelfSigned({
|
|
// Random serial: 16 hex chars is plenty for identification purposes and
|
|
// matches how `openssl x509 -req` chooses serials by default.
|
|
serialNumber: randomHex(16),
|
|
name: `CN=${commonName}`,
|
|
notBefore,
|
|
notAfter,
|
|
signingAlgorithm: {
|
|
name: "RSASSA-PKCS1-v1_5",
|
|
hash: "SHA-256",
|
|
},
|
|
keys: keyPair,
|
|
extensions: [] as Extension[],
|
|
});
|
|
|
|
const certPem = cert.toString("pem");
|
|
const certDer = new Uint8Array(cert.rawData);
|
|
const publicCertificateBase64Der = toBase64(certDer);
|
|
|
|
const privateKeyPkcs8 = new Uint8Array(
|
|
await subtle.exportKey("pkcs8", keyPair.privateKey),
|
|
);
|
|
const privateKeyPem = pkcs8ToPem(privateKeyPkcs8);
|
|
|
|
// Bundle order matches what azure-identity expects: certificate first,
|
|
// private key second. The full bundle is then base64-encoded so it can
|
|
// live inside a single form field / JSON payload.
|
|
const bundlePem = `${certPem.trim()}\n${privateKeyPem.trim()}\n`;
|
|
const privateKeyBundleBase64Pem = toBase64(new TextEncoder().encode(bundlePem));
|
|
|
|
const thumbprintBytes = new Uint8Array(
|
|
await subtle.digest("SHA-1", certDer),
|
|
);
|
|
const thumbprintHex = bytesToHexUpper(thumbprintBytes);
|
|
|
|
return {
|
|
publicCertificateBase64Der,
|
|
privateKeyBundleBase64Pem,
|
|
thumbprintHex,
|
|
notBefore: notBefore.toISOString(),
|
|
notAfter: notAfter.toISOString(),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Trigger a browser download of the given base64-DER public certificate as a
|
|
* plain-text file, so the user has a single file to open next to the Portal
|
|
* deployment blade and copy into the `Certificate Base64` parameter.
|
|
*
|
|
* Split from `generateProwlerCertificate` so the pure generator can be unit
|
|
* tested without stubbing `document.createElement`.
|
|
*/
|
|
export function downloadPublicCertificateFile(
|
|
publicCertificateBase64Der: string,
|
|
filename = "prowler-cert-base64.txt",
|
|
): void {
|
|
const blob = new Blob([publicCertificateBase64Der], {
|
|
type: "text/plain;charset=utf-8",
|
|
});
|
|
const url = URL.createObjectURL(blob);
|
|
const anchor = document.createElement("a");
|
|
anchor.href = url;
|
|
anchor.download = filename;
|
|
document.body.appendChild(anchor);
|
|
anchor.click();
|
|
document.body.removeChild(anchor);
|
|
// Free the blob URL immediately; the browser has already started the
|
|
// download at this point, so revoking is safe.
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
|
|
// -- helpers ---------------------------------------------------------------
|
|
|
|
/**
|
|
* Uint8Array → base64. Kept private to this module because the codebase does
|
|
* not yet have a shared helper and this one only needs to handle small
|
|
* payloads (a cert + key are ~5 KB total). If a shared helper appears later,
|
|
* swap this out.
|
|
*/
|
|
function toBase64(bytes: Uint8Array): string {
|
|
let binary = "";
|
|
for (let i = 0; i < bytes.length; i++) {
|
|
const byte = bytes[i];
|
|
binary += String.fromCharCode(byte);
|
|
}
|
|
return btoa(binary);
|
|
}
|
|
|
|
function randomHex(chars: number): string {
|
|
const bytes = new Uint8Array(Math.ceil(chars / 2));
|
|
globalThis.crypto.getRandomValues(bytes);
|
|
return bytesToHexUpper(bytes).slice(0, chars);
|
|
}
|
|
|
|
function bytesToHexUpper(bytes: Uint8Array): string {
|
|
let hex = "";
|
|
for (let i = 0; i < bytes.length; i++) {
|
|
const byte = bytes[i];
|
|
hex += byte.toString(16).padStart(2, "0").toUpperCase();
|
|
}
|
|
return hex;
|
|
}
|
|
|
|
// `@peculiar/x509` exports certs to PEM directly but not PKCS#8 keys — we
|
|
// build the PEM ourselves so the private key format is deterministic and
|
|
// matches what `openssl pkey -inform DER` would emit.
|
|
function pkcs8ToPem(pkcs8: Uint8Array): string {
|
|
const base64 = toBase64(pkcs8);
|
|
// 64-char lines is the classic PEM formatting; azure-identity and every
|
|
// other PEM parser accept both wrapped and unwrapped, but wrapping keeps
|
|
// the file human-readable.
|
|
const wrapped = base64.match(/.{1,64}/g)?.join("\n") ?? base64;
|
|
return `-----BEGIN PRIVATE KEY-----\n${wrapped}\n-----END PRIVATE KEY-----`;
|
|
}
|
|
|
|
// Named export needed by @/lib/shared/base64 fallback below.
|
|
export const __internal = { pkcs8ToPem, bytesToHexUpper, randomHex };
|