Readme
This commit is contained in:
402
lib/services/certificate.service.ts
Normal file
402
lib/services/certificate.service.ts
Normal file
@@ -0,0 +1,402 @@
|
||||
/**
|
||||
* 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();
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user