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:
Chandler Copeland
2026-03-20 11:24:56 -06:00
parent fa68a1bcb4
commit 2929581ab9
3 changed files with 107 additions and 0 deletions

View 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
});
}

View 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);
});
}

View 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 };
}