diff --git a/packages/auto-id/package.json b/packages/auto-id/package.json index c3a70e4c..cbe56e52 100644 --- a/packages/auto-id/package.json +++ b/packages/auto-id/package.json @@ -12,7 +12,8 @@ "@autonomys/auto-utils": "workspace:*", "@peculiar/asn1-schema": "^2.3.8", "@peculiar/asn1-x509": "^2.3.8", - "asn1js": "^3.0.5" + "asn1js": "^3.0.5", + "node-forge": "^1.3.1" }, "files": [ "dist", @@ -21,6 +22,7 @@ "devDependencies": { "@types/jest": "^29.5.12", "@types/node": "^20.12.12", + "@types/node-forge": "^1", "jest": "^29.7.0", "ts-jest": "^29.1.4", "ts-node": "^10.9.2", diff --git a/packages/auto-id/src/certificateManager.ts b/packages/auto-id/src/certificateManager.ts new file mode 100644 index 00000000..705f9eb8 --- /dev/null +++ b/packages/auto-id/src/certificateManager.ts @@ -0,0 +1,241 @@ +//! For key generation, management, `keyManagement.ts` file is used i.e. "crypto" library. +//! And for certificate related, used "node-forge" library. + +import { blake2b_256, stringToUint8Array } from '@autonomys/auto-utils' +import { KeyObject, createPublicKey, createSign } from 'crypto' +import fs from 'fs' +import forge from 'node-forge' +import { keyToPem } from './keyManagement' + +interface CustomCertificateExtension { + altNames: { + type: number + value: string + }[] +} + +interface SigningParams { + privateKey: KeyObject + algorithm: 'sha256' | null // Only 'sha256' or null for Ed25519 +} + +class CertificateManager { + private certificate: forge.pki.Certificate | null + private privateKey: KeyObject | null + + constructor( + certificate: forge.pki.Certificate | null = null, + privateKey: KeyObject | null = null, + ) { + this.certificate = certificate + this.privateKey = privateKey + } + + protected prepareSigningParams(): SigningParams { + const privateKey = this.privateKey + if (!privateKey) { + throw new Error('Private key is not set.') + } + + if (privateKey.asymmetricKeyType === 'ed25519') { + return { privateKey: privateKey, algorithm: null } + } + if (privateKey.asymmetricKeyType === 'rsa') { + return { privateKey: privateKey, algorithm: 'sha256' } + } + + throw new Error('Unsupported key type for signing.') + } + + static toCommonName(subjectName: string): forge.pki.CertificateField[] { + return [{ name: 'commonName', value: subjectName }] + } + + static prettyPrintCertificate(cert: forge.pki.Certificate): void { + console.log('Certificate:') + console.log('============') + console.log( + `Subject: ${cert.subject.attributes.map((attr) => `${attr.name}=${attr.value}`).join(', ')}`, + ) + console.log( + `Issuer: ${cert.issuer.attributes.map((attr) => `${attr.name}=${attr.value}`).join(', ')}`, + ) + console.log(`Serial Number: ${cert.serialNumber}`) + console.log(`Not Valid Before: ${cert.validity.notBefore.toISOString()}`) + console.log(`Not Valid After: ${cert.validity.notAfter.toISOString()}`) + console.log('\nExtensions:') + cert.extensions.forEach((ext) => { + console.log(` - ${ext.name} (${ext.id}): ${JSON.stringify(ext.value)}`) + }) + console.log('\nPublic Key:') + console.log(cert.publicKey) + } + + static certificateToPem(cert: forge.pki.Certificate): string { + return forge.pki.certificateToPem(cert) + } + + static pemToCertificate(pem: string): forge.pki.Certificate { + return forge.pki.certificateFromPem(pem) + } + + static getSubjectCommonName(subjectFields: forge.pki.CertificateField[]): string | undefined { + const cnField = subjectFields.find((field) => field.name === 'commonName') + if (cnField && typeof cnField.value === 'string') { + return cnField.value + } + return undefined + } + + static getCertificateAutoId(certificate: forge.pki.Certificate): string | undefined { + const sanExtension = certificate.getExtension('subjectAltName') + if (sanExtension) { + const san = sanExtension as CustomCertificateExtension + for (const name of san.altNames) { + if (name.type === 6 && name.value.startsWith('autoid:auto:')) { + return name.value.split(':').pop() + } + } + } + return undefined + } + + static pemPublicFromPrivateKey(privateKey: KeyObject): string { + const publicKey = createPublicKey(privateKey) + return publicKey.export({ type: 'spki', format: 'pem' }).toString() + } + + static derPublicFromPrivateKey(privateKey: KeyObject): string { + const publicKey = createPublicKey(privateKey) + return publicKey.export({ type: 'spki', format: 'der' }).toString() + } + + createCSR(subjectName: string): forge.pki.CertificateSigningRequest { + const privateKey = this.privateKey + if (!privateKey) { + throw new Error('Private key is not set.') + } + let csr = forge.pki.createCertificationRequest() + csr.setSubject(CertificateManager.toCommonName(subjectName)) + + if (privateKey.asymmetricKeyType === 'ed25519') { + // Manually handle Ed25519 due to possible forge limitations + const publicKeyDer = CertificateManager.derPublicFromPrivateKey(privateKey) + + // Directly assign the public key in DER format + csr.publicKey = forge.pki.publicKeyFromAsn1(forge.asn1.fromDer(publicKeyDer)) + // csr.publicKey = forge.pki.publicKeyFromPem( + // CertificateManager.pemPublicFromPrivateKey(privateKey), + // ) + } else { + csr.publicKey = forge.pki.publicKeyFromPem( + CertificateManager.pemPublicFromPrivateKey(privateKey), + ) + } + return csr + } + + signCSR(csr: forge.pki.CertificateSigningRequest): forge.pki.CertificateSigningRequest { + const signingParams = this.prepareSigningParams() + if (this.privateKey?.asymmetricKeyType === 'ed25519') { + // Ensure cryptographic algorithm is set to sign + // if (!csr.siginfo.algorithmOid) { + // throw new Error('Signature algorithm OID must be set before signing the CSR.') + // } + + // console.log('Inspecting CSR before converting to ASN.1:', csr) + const asn1 = forge.pki.certificationRequestToAsn1(csr) + const derBuffer = forge.asn1.toDer(asn1).getBytes() + + const sign = createSign('SHA256') + sign.update(derBuffer, 'binary') // Make sure the update is called with 'binary' encoding + sign.end() + + const signature = sign.sign(signingParams.privateKey, 'binary') + csr.signature = Buffer.from(signature, 'binary') + } else { + if (signingParams.algorithm) { + const digestMethod = forge.md[signingParams.algorithm].create() + csr.sign(forge.pki.privateKeyFromPem(keyToPem(signingParams.privateKey)), digestMethod) + } else { + throw new Error('Unsupported key type or missing algorithm.') + } + } + + return csr + } + + create_and_sign_csr(subject_name: string): forge.pki.CertificateSigningRequest { + const csr = this.createCSR(subject_name) + return this.signCSR(csr) + } + + issueCertificate( + csr: forge.pki.CertificateSigningRequest, + validityPeriodDays: number = 365, + ): string { + if (!this.privateKey) { + throw new Error('Private key is not set.') + } + let issuerName + let autoId: string + if (!this.certificate) { + issuerName = csr.subject.attributes + autoId = blake2b_256( + stringToUint8Array(CertificateManager.getSubjectCommonName(csr.subject.attributes) || ''), + ) + } else { + issuerName = this.certificate.subject.attributes + const autoIdPrefix = CertificateManager.getCertificateAutoId(this.certificate) || '' + autoId = blake2b_256( + stringToUint8Array( + autoIdPrefix + + (CertificateManager.getSubjectCommonName(this.certificate.subject.attributes) || ''), + ), + ) + } + const cert = forge.pki.createCertificate() + if (!csr.publicKey) + throw new Error('CSR does not have a public key. Please provide a CSR with a public key.') + cert.publicKey = csr.publicKey + cert.publicKey = csr.publicKey + cert.serialNumber = new Date().getTime().toString(16) + cert.validity.notBefore = new Date() + cert.validity.notAfter = new Date() + cert.validity.notAfter.setDate(cert.validity.notBefore.getDate() + validityPeriodDays) + cert.setSubject(csr.subject.attributes) + cert.setIssuer(issuerName) + const attribute = csr.getAttribute({ name: 'extensionRequest' }) + if (!attribute || !attribute.extensions) { + throw new Error('CSR does not have extensions.') + } + const extensions = attribute.extensions + if (!extensions) { + throw new Error('CSR does not have extensions. Please provide a CSR with extensions.') + } + extensions.push({ + name: 'subjectAltName', + altNames: [ + { + type: 6, // URI + value: `autoid:auto:${autoId}`, + }, + ], + }) + cert.setExtensions(extensions) + + cert.sign(forge.pki.privateKeyFromPem(keyToPem(this.privateKey)), forge.md.sha256.create()) + return forge.pki.certificateToPem(cert) + } + + saveCertificate(filePath: string): void { + const certificate = this.certificate + if (!certificate) { + throw new Error('No certificate available to save.') + } + const certificatePem = CertificateManager.certificateToPem(certificate) + fs.writeFileSync(filePath, certificatePem, 'utf8') + } +} + +export default CertificateManager diff --git a/packages/auto-id/src/index.ts b/packages/auto-id/src/index.ts index 55276676..953fd9ca 100644 --- a/packages/auto-id/src/index.ts +++ b/packages/auto-id/src/index.ts @@ -1,2 +1,3 @@ +export * from './certificateManager' export * from './keyManagement' export * from './utils' diff --git a/packages/auto-id/src/keyManagement.ts b/packages/auto-id/src/keyManagement.ts index 391f2618..3530b1d4 100644 --- a/packages/auto-id/src/keyManagement.ts +++ b/packages/auto-id/src/keyManagement.ts @@ -19,6 +19,12 @@ export function generateRsaKeyPair(keySize: number = 2048): [string, string] { return [privateKey, publicKey] } +// export function generateRsaKeyPair(keySize: number = 2048): [string, string] { +// const { publicKey, privateKey } = forge.pki.rsa.generateKeyPair({ bits: keySize, e: 0x10001 }) + +// return [privateKey.toString(), publicKey.toString()] +// } + /** * Generates an Ed25519 key pair. * @returns A tuple containing the Ed25519 private key and public key. @@ -32,6 +38,12 @@ export function generateEd25519KeyPair(): [string, string] { return [privateKey, publicKey] } +// export function generateEd25519KeyPair(): [string, string] { +// const { privateKey, publicKey } = forge.pki.ed25519.generateKeyPair() + +// return [privateKey.toString(), publicKey.toString()] +// } + /** * Converts a cryptographic key object into a PEM formatted string. * This function can handle both private and public key objects. @@ -167,7 +179,7 @@ export async function loadPrivateKey(filePath: string, password?: string): Promi try { const keyData = await read(filePath) const privateKey = pemToPrivateKey(keyData, password) - return privateKey; + return privateKey } catch (error: any) { throw new Error(`Failed to load private key: ${error.message}`) } @@ -286,4 +298,4 @@ export function doPublicKeysMatch(publicKey1: KeyObject, publicKey2: KeyObject): // Compare the serialized public key data return publicKey1Der.equals(publicKey2Der) -} \ No newline at end of file +} diff --git a/packages/auto-id/tests/certificateManager.test.ts b/packages/auto-id/tests/certificateManager.test.ts new file mode 100644 index 00000000..cc9ce4e1 --- /dev/null +++ b/packages/auto-id/tests/certificateManager.test.ts @@ -0,0 +1,84 @@ +import { createPublicKey } from 'crypto' +import * as forge from 'node-forge' +import CertificateManager from '../src/certificateManager' +import { + doPublicKeysMatch, + generateEd25519KeyPair, + generateRsaKeyPair, + pemToPrivateKey, + pemToPublicKey, +} from '../src/keyManagement' + +describe('CertificateManager', () => { + it('creates and signs a CSR with an Ed25519 key', () => { + // Generate an Ed25519 key pair + const [privateKey, _] = generateEd25519KeyPair() + // const keypair = forge.pki.ed25519.generateKeyPair() + + // Define the subject name for the CSR + const subjectName = 'Test' + + // Instantiate CertificateManager with the generated private key + const manager = new CertificateManager(null, pemToPrivateKey(privateKey)) + + // Create and sign CSR + const csr = manager.create_and_sign_csr(subjectName) + + // Assert that the CSR is not null + expect(csr).toBeDefined() + + // Assert that the CSR subject name matches the provided subject name + const commonNameField = csr.subject.attributes.find((attr) => attr.name === 'commonName') + expect(commonNameField?.value).toEqual(subjectName) + + // Get the derived public key (in forge) from original private key. + // private key (PEM) -> private key(KeyObject) -> public key(PEM) + const derivedPublicKeyObj = pemToPublicKey( + CertificateManager.pemPublicFromPrivateKey(pemToPrivateKey(privateKey)), + ) + + // Assert that the CSR public key matches the public key from the key pair + if (csr.publicKey) { + // Convert forge.PublicKey format to crypto.KeyObject + const csrPublicKeyObj = createPublicKey(forge.pki.publicKeyToPem(csr.publicKey)) + + expect(doPublicKeysMatch(csrPublicKeyObj, derivedPublicKeyObj)).toBe(true) + } else { + throw new Error('CSR does not have a public key.') + } + }) + + it('create and sign CSR with RSA key', () => { + // Generate a RSA key pair + const [privateKey, _] = generateRsaKeyPair() + + // Instantiate CertificateManager with the generated private key + const certificateManager = new CertificateManager(null, pemToPrivateKey(privateKey)) + + // Create and sign a CSR + const subjectName = 'Test' + const csr = certificateManager.create_and_sign_csr(subjectName) + + expect(csr).toBeDefined() + + // Assert that the CSR subject name matches the provided subject name + const commonNameField = csr.subject.attributes.find((attr) => attr.name === 'commonName') + expect(commonNameField?.value).toEqual(subjectName) + + // Get the derived public key (in forge) from original private key. + // private key (PEM) -> private key(KeyObject) -> public key(PEM) + const derivedPublicKeyObj = pemToPublicKey( + CertificateManager.pemPublicFromPrivateKey(pemToPrivateKey(privateKey)), + ) + + // Assert that the CSR public key matches the public key from the key pair + if (csr.publicKey) { + // Convert forge.PublicKey format to crypto.KeyObject + const csrPublicKeyObj = createPublicKey(forge.pki.publicKeyToPem(csr.publicKey)) + + expect(doPublicKeysMatch(csrPublicKeyObj, derivedPublicKeyObj)).toBe(true) + } else { + throw new Error('CSR does not have a public key.') + } + }) +}) diff --git a/yarn.lock b/yarn.lock index d2d7b95a..e7562e43 100644 --- a/yarn.lock +++ b/yarn.lock @@ -36,8 +36,10 @@ __metadata: "@peculiar/asn1-x509": "npm:^2.3.8" "@types/jest": "npm:^29.5.12" "@types/node": "npm:^20.12.12" + "@types/node-forge": "npm:^1" asn1js: "npm:^3.0.5" jest: "npm:^29.7.0" + node-forge: "npm:^1.3.1" ts-jest: "npm:^29.1.4" ts-node: "npm:^10.9.2" typescript: "npm:^5.4.5" @@ -1626,6 +1628,15 @@ __metadata: languageName: node linkType: hard +"@types/node-forge@npm:^1": + version: 1.3.11 + resolution: "@types/node-forge@npm:1.3.11" + dependencies: + "@types/node": "npm:*" + checksum: 10c0/3d7d23ca0ba38ac0cf74028393bd70f31169ab9aba43f21deb787840170d307d662644bac07287495effe2812ddd7ac8a14dbd43f16c2936bbb06312e96fc3b9 + languageName: node + linkType: hard + "@types/node@npm:*": version: 20.14.0 resolution: "@types/node@npm:20.14.0" @@ -4002,6 +4013,13 @@ __metadata: languageName: node linkType: hard +"node-forge@npm:^1.3.1": + version: 1.3.1 + resolution: "node-forge@npm:1.3.1" + checksum: 10c0/e882819b251a4321f9fc1d67c85d1501d3004b4ee889af822fd07f64de3d1a8e272ff00b689570af0465d65d6bf5074df9c76e900e0aff23e60b847f2a46fbe8 + languageName: node + linkType: hard + "node-gyp@npm:latest": version: 10.1.0 resolution: "node-gyp@npm:10.1.0"