403 lines
12 KiB
TypeScript
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();
|
|
});
|
|
}
|
|
}
|