feat(06-01): create signing utility library (token, audit, embed)
- token.ts: createSigningToken() + verifySigningToken() using jose HS256 - audit.ts: logAuditEvent() inserts typed audit events with server timestamp - embed-signature.ts: embedSignatureInPdf() embeds PNG sigs, returns SHA-256 hash - added SIGNING_JWT_SECRET to .env.local (random 32-char base64 secret)
This commit is contained in:
21
teressa-copeland-homes/src/lib/signing/audit.ts
Normal file
21
teressa-copeland-homes/src/lib/signing/audit.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { auditEvents, auditEventTypeEnum } from '@/lib/db/schema';
|
||||||
|
|
||||||
|
type AuditEventType = typeof auditEventTypeEnum.enumValues[number];
|
||||||
|
|
||||||
|
export async function logAuditEvent(opts: {
|
||||||
|
documentId: string;
|
||||||
|
eventType: AuditEventType;
|
||||||
|
ipAddress?: string;
|
||||||
|
userAgent?: string;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}): Promise<void> {
|
||||||
|
await db.insert(auditEvents).values({
|
||||||
|
documentId: opts.documentId,
|
||||||
|
eventType: opts.eventType,
|
||||||
|
ipAddress: opts.ipAddress ?? null,
|
||||||
|
userAgent: opts.userAgent ?? null,
|
||||||
|
metadata: opts.metadata ?? null,
|
||||||
|
// createdAt is defaultNow() — server-side only, never from client
|
||||||
|
});
|
||||||
|
}
|
||||||
54
teressa-copeland-homes/src/lib/signing/embed-signature.ts
Normal file
54
teressa-copeland-homes/src/lib/signing/embed-signature.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { PDFDocument } from '@cantoo/pdf-lib';
|
||||||
|
import { readFile, writeFile, rename } from 'node:fs/promises';
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { createReadStream } from 'node:fs';
|
||||||
|
|
||||||
|
export interface SignatureToEmbed {
|
||||||
|
fieldId: string;
|
||||||
|
dataURL: string; // 'data:image/png;base64,...' from signature_pad or typed canvas
|
||||||
|
x: number; // PDF user space (bottom-left origin, points) — from signatureFields
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
page: number; // 1-indexed
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function embedSignatureInPdf(
|
||||||
|
preparedPdfPath: string, // absolute path — ALWAYS use doc.preparedFilePath, NOT filePath
|
||||||
|
signedPdfPath: string, // absolute path to write signed output (uploads/clients/{id}/{uuid}_signed.pdf)
|
||||||
|
signatures: SignatureToEmbed[]
|
||||||
|
): Promise<string> { // returns SHA-256 hex digest (LEGAL-02)
|
||||||
|
const pdfBytes = await readFile(preparedPdfPath);
|
||||||
|
const pdfDoc = await PDFDocument.load(pdfBytes);
|
||||||
|
const pages = pdfDoc.getPages();
|
||||||
|
|
||||||
|
for (const sig of signatures) {
|
||||||
|
const page = pages[sig.page - 1];
|
||||||
|
if (!page) continue;
|
||||||
|
const pngImage = await pdfDoc.embedPng(sig.dataURL); // accepts base64 DataURL directly
|
||||||
|
page.drawImage(pngImage, {
|
||||||
|
x: sig.x,
|
||||||
|
y: sig.y,
|
||||||
|
width: sig.width,
|
||||||
|
height: sig.height,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const modifiedBytes = await pdfDoc.save();
|
||||||
|
const tmpPath = `${signedPdfPath}.tmp`;
|
||||||
|
await writeFile(tmpPath, modifiedBytes);
|
||||||
|
await rename(tmpPath, signedPdfPath); // atomic rename prevents corruption on partial write
|
||||||
|
|
||||||
|
// LEGAL-02: SHA-256 hash of final signed PDF — computed from disk after rename
|
||||||
|
return hashFile(signedPdfPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hashFile(filePath: string): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const hash = createHash('sha256');
|
||||||
|
createReadStream(filePath)
|
||||||
|
.on('data', (chunk) => hash.update(chunk))
|
||||||
|
.on('end', () => resolve(hash.digest('hex')))
|
||||||
|
.on('error', reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
32
teressa-copeland-homes/src/lib/signing/token.ts
Normal file
32
teressa-copeland-homes/src/lib/signing/token.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { SignJWT, jwtVerify } from 'jose';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { signingTokens } from '@/lib/db/schema';
|
||||||
|
|
||||||
|
const getSecret = () => new TextEncoder().encode(process.env.SIGNING_JWT_SECRET!);
|
||||||
|
|
||||||
|
export async function createSigningToken(documentId: string): Promise<{ token: string; jti: string; expiresAt: Date }> {
|
||||||
|
const jti = crypto.randomUUID();
|
||||||
|
const expiresAt = new Date(Date.now() + 72 * 60 * 60 * 1000); // 72 hours
|
||||||
|
|
||||||
|
const token = await new SignJWT({ documentId, purpose: 'sign' })
|
||||||
|
.setProtectedHeader({ alg: 'HS256' })
|
||||||
|
.setIssuedAt()
|
||||||
|
.setExpirationTime('72h')
|
||||||
|
.setJti(jti)
|
||||||
|
.sign(getSecret());
|
||||||
|
|
||||||
|
// Store token metadata for one-time-use enforcement
|
||||||
|
await db.insert(signingTokens).values({
|
||||||
|
jti,
|
||||||
|
documentId,
|
||||||
|
expiresAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { token, jti, expiresAt };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifySigningToken(token: string): Promise<{ documentId: string; jti: string; exp: number }> {
|
||||||
|
// Throws JWTExpired or JWTInvalid on failure — caller handles
|
||||||
|
const { payload } = await jwtVerify(token, getSecret());
|
||||||
|
return payload as { documentId: string; jti: string; exp: number };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user