Files
LexiChain/lib/services/certificate.service.ts
2026-05-10 18:25:58 +01:00

403 lines
12 KiB
TypeScript

/**
* Certificate Service
*
* Generates cryptographically signed certificates for blockchain-registered contracts.
* These certificates can be downloaded and verified by third parties, proving that
* a contract was registered on-chain with specific metadata at a specific timestamp.
*
* Uses ECDSA (Elliptic Curve Digital Signature Algorithm) via ethers.js Wallet.
*/
import { ethers } from "ethers";
import { createHash } from "crypto";
import PDFDocument from "pdfkit";
import QRCode from "qrcode";
import type { CryptographicCertificate } from "./blockchain.types";
import { BlockchainService } from "./blockchain.service";
export class CertificateService {
/**
* Generate a digitally-signed certificate for a blockchain-registered contract.
*
* @param contractId - Contract ID
* @param contractTitle - Contract title
* @param contractFileName - Original file name
* @param documentHash - SHA-256 hash of the document
* @param txHash - Blockchain transaction hash
* @param blockNumber - Block number
* @param blockTimestamp - Block timestamp (ISO string)
* @param network - Blockchain network ('hardhat' | 'sepolia')
* @param contractAddress - Smart contract address
* @returns Digitally-signed certificate
*/
static async generateCertificate(
contractId: string,
contractTitle: string | null,
contractFileName: string,
documentHash: string,
txHash: string,
blockNumber: number,
blockTimestamp: string,
network: string,
contractAddress: string,
): Promise<CryptographicCertificate> {
if (!BlockchainService.isReadConfigured()) {
throw new Error("Blockchain not configured for certificate generation");
}
// Generate unique certificate ID
const certificateId = this.generateCertificateId(contractId);
// Get issuer wallet address
let issuerAddress = "";
try {
// Try to get the wallet address if blockchain write is configured
if (BlockchainService.isConfigured()) {
// We can't directly access the wallet from here, so we'll use the private key env var
const privateKey = process.env.BLOCKCHAIN_PRIVATE_KEY;
if (privateKey) {
const wallet = new ethers.Wallet(privateKey);
issuerAddress = wallet.address;
}
}
} catch {
// If wallet creation fails, use a placeholder
issuerAddress = "0x0000000000000000000000000000000000000000";
}
const issuedAt = new Date().toISOString();
// Prepare the data to sign
const certificateData = {
contractId,
contractTitle,
contractFileName,
documentHash,
txHash,
blockNumber,
blockTimestamp,
network,
contractAddress,
certificateId,
issuedAt,
issuer: issuerAddress,
version: "1.0",
};
// Create a message to sign (hash of the certificate data)
const messageToSign = this.createSignableMessage(certificateData);
const messageHash = ethers.hashMessage(messageToSign);
let signature = "";
try {
// Sign the message with the server wallet
if (BlockchainService.isConfigured()) {
const privateKey = process.env.BLOCKCHAIN_PRIVATE_KEY;
if (privateKey) {
const wallet = new ethers.Wallet(privateKey);
signature = await wallet.signMessage(messageToSign);
}
}
} catch (error) {
console.warn("Failed to sign certificate:", error);
signature = "0x"; // Return empty signature if signing fails
}
const certificate: CryptographicCertificate = {
contractId,
contractTitle,
contractFileName,
documentHash,
txHash,
blockNumber,
blockTimestamp,
network,
contractAddress,
certificateId,
issuedAt,
issuer: issuerAddress,
signature,
version: "1.0",
};
return certificate;
}
/**
* Generate a unique certificate ID based on contract ID and timestamp.
*/
private static generateCertificateId(contractId: string): string {
const timestamp = Date.now();
const combined = `${contractId}:${timestamp}`;
const hash = createHash("sha256").update(combined).digest("hex");
return `cert_${hash.slice(0, 16)}`;
}
/**
* Create a human-readable and machine-verifiable message to sign.
*/
private static createSignableMessage(data: any): string {
const lines = [
"═══════════════════════════════════════════",
"LexiChain Cryptographic Certificate",
"═══════════════════════════════════════════",
"",
`Certificate ID: ${data.certificateId}`,
`Contract: ${data.contractTitle || data.contractFileName}`,
`Document Hash: ${data.documentHash}`,
"",
"Blockchain Proof:",
` Transaction Hash: ${data.txHash}`,
` Block Number: ${data.blockNumber}`,
` Timestamp: ${data.blockTimestamp}`,
` Network: ${data.network}`,
` Contract Address: ${data.contractAddress}`,
"",
`Issued At: ${data.issuedAt}`,
`Issued By: ${data.issuer}`,
"",
"This certificate cryptographically proves that the above",
"document was registered on the blockchain at the specified",
"block and timestamp. The signature on this message can be",
"verified using the issuer's public key.",
"═══════════════════════════════════════════",
];
return lines.join("\n");
}
/**
* Verify a certificate's signature (useful for third parties).
* Returns true if the signature is valid and matches the issuer.
*/
static verifyCertificateSignature(
certificate: CryptographicCertificate,
): boolean {
try {
const messageToSign = this.createSignableMessage(certificate);
const recoveredAddress = ethers.verifyMessage(
messageToSign,
certificate.signature,
);
return (
recoveredAddress.toLowerCase() === certificate.issuer.toLowerCase()
);
} catch {
return false;
}
}
/**
* Generate a professional PDF certificate for the given certificate data.
*/
static async generateCertificatePdf(
certificate: CryptographicCertificate,
): Promise<Buffer> {
const qrPayload = JSON.stringify({
certificateId: certificate.certificateId,
contractId: certificate.contractId,
documentHash: certificate.documentHash,
txHash: certificate.txHash,
blockNumber: certificate.blockNumber,
blockTimestamp: certificate.blockTimestamp,
network: certificate.network,
contractAddress: certificate.contractAddress,
issuer: certificate.issuer,
issuedAt: certificate.issuedAt,
version: certificate.version,
});
const qrPng = await QRCode.toBuffer(qrPayload, {
width: 160,
margin: 1,
errorCorrectionLevel: "M",
});
return new Promise((resolve) => {
const doc = new PDFDocument({ size: "A4", margin: 56 });
const chunks: Buffer[] = [];
doc.on("data", (chunk) => chunks.push(chunk as Buffer));
doc.on("end", () => resolve(Buffer.concat(chunks)));
// Header
doc
.font("Helvetica-Bold")
.fontSize(20)
.fillColor("#111827")
.text("LexiChain Contract Authenticity Certificate", {
align: "center",
})
.moveDown(0.2);
doc
.font("Helvetica")
.fontSize(10)
.fillColor("#6B7280")
.text("Digitally signed proof of blockchain registration", {
align: "center",
})
.moveDown(1.2);
// Divider
doc
.moveTo(doc.page.margins.left, doc.y)
.lineTo(doc.page.width - doc.page.margins.right, doc.y)
.lineWidth(1)
.strokeColor("#E5E7EB")
.stroke()
.moveDown(0.8);
const pageLeft = doc.page.margins.left;
const pageRight = doc.page.width - doc.page.margins.right;
const gap = 24;
const qrSize = 120;
const rightX = pageRight - qrSize;
const leftWidth = pageRight - pageLeft - qrSize - gap;
const sectionTitle = (title: string) => {
doc
.font("Helvetica-Bold")
.fontSize(12)
.fillColor("#111827")
.text(title, pageLeft, doc.y)
.moveDown(0.4);
};
const writeFieldAt = (
x: number,
width: number,
label: string,
value: string,
) => {
doc
.font("Helvetica-Bold")
.fontSize(10)
.fillColor("#111827")
.text(`${label} `, x, doc.y, { continued: true, width });
doc.font("Helvetica").fillColor("#111827").text(value, { width });
doc.moveDown(0.3);
};
// Summary + QR
const summaryStartY = doc.y + 6;
doc.image(qrPng, rightX, summaryStartY, {
width: qrSize,
height: qrSize,
});
doc
.font("Helvetica")
.fontSize(8)
.fillColor("#6B7280")
.text("Verification QR", rightX, summaryStartY + qrSize + 4, {
width: qrSize,
align: "center",
});
doc.y = summaryStartY;
doc
.font("Helvetica-Bold")
.fontSize(12)
.fillColor("#111827")
.text("Certificate Summary", pageLeft, doc.y, { width: leftWidth })
.moveDown(0.4);
writeFieldAt(
pageLeft,
leftWidth,
"Certificate ID:",
certificate.certificateId,
);
writeFieldAt(
pageLeft,
leftWidth,
"Contract:",
certificate.contractTitle || certificate.contractFileName,
);
writeFieldAt(pageLeft, leftWidth, "Issued At:", certificate.issuedAt);
writeFieldAt(pageLeft, leftWidth, "Network:", certificate.network);
doc.y = Math.max(doc.y, summaryStartY + qrSize + 24);
doc.moveDown(0.4);
// Document Proof
sectionTitle("Document Proof");
doc.font("Helvetica-Bold").fontSize(10).text("Document Hash:");
doc
.font("Courier")
.fontSize(9)
.fillColor("#111827")
.text(certificate.documentHash, { lineGap: 2 })
.moveDown(0.6);
// Blockchain Proof
sectionTitle("Blockchain Proof");
doc.font("Helvetica-Bold").fontSize(10).text("Transaction Hash:");
doc
.font("Courier")
.fontSize(9)
.fillColor("#111827")
.text(certificate.txHash, { lineGap: 2 })
.moveDown(0.4);
writeFieldAt(
pageLeft,
pageRight - pageLeft,
"Block Number:",
String(certificate.blockNumber),
);
writeFieldAt(
pageLeft,
pageRight - pageLeft,
"Block Timestamp:",
certificate.blockTimestamp,
);
writeFieldAt(
pageLeft,
pageRight - pageLeft,
"Contract Address:",
certificate.contractAddress,
);
// Issuer and Signature
doc.moveDown(0.2);
sectionTitle("Issuer and Signature");
writeFieldAt(
pageLeft,
pageRight - pageLeft,
"Issued By:",
certificate.issuer,
);
doc.font("Helvetica-Bold").fontSize(10).text("Digital Signature:");
doc
.font("Courier")
.fontSize(8.5)
.fillColor("#111827")
.text(certificate.signature || "Signature not available", {
lineGap: 2,
})
.moveDown(0.6);
doc
.font("Helvetica")
.fontSize(9)
.fillColor("#374151")
.text(
"This certificate verifies that the document referenced above was registered on the blockchain and can be independently verified using the issuer address and signature.",
{ align: "left" },
)
.moveDown(0.4);
doc
.font("Helvetica")
.fontSize(8)
.fillColor("#6B7280")
.text("LexiChain - Confidential and tamper-evident certification.", {
align: "center",
});
doc.end();
});
}
}