mirror of
https://github.com/documenso/documenso.git
synced 2026-06-22 04:12:06 +10:00
d5ce222482
Adds Cloud Signature Consortium (CSC) integration for AES/QES signing against a configured TSP. v1 ships as instance-wide configuration via environment variables, with per-envelope signature level selection, license gating, and an OAuth-driven signing flow (capture + FIFO signers, SAD session, blocking/in-progress recipient pages). Includes signature level compatibility checks (role, signing order, dictate next signer), envelope mutability assertions, Prisma migration for signature level and CSC tables, and docs for the new signing certificate options.
547 lines
19 KiB
TypeScript
547 lines
19 KiB
TypeScript
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
|
import { jobs } from '@documenso/lib/jobs/client';
|
|
import { sendPendingEmail } from '@documenso/lib/server-only/document/send-pending-email';
|
|
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
|
|
import { triggerWebhook } from '@documenso/lib/server-only/webhooks/trigger/trigger-webhook';
|
|
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
|
import { mapEnvelopeToWebhookDocumentPayload, ZWebhookDocumentSchema } from '@documenso/lib/types/webhook-payload';
|
|
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
|
import { getFileServerSide } from '@documenso/lib/universal/upload/get-file.server';
|
|
import { putPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server';
|
|
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
|
|
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
|
|
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
|
|
import { prisma } from '@documenso/prisma';
|
|
import { PDF } from '@libpdf/core';
|
|
import {
|
|
type DocumentDataType,
|
|
EnvelopeType,
|
|
RecipientRole,
|
|
SendStatus,
|
|
SigningStatus,
|
|
WebhookTriggerEvents,
|
|
} from '@prisma/client';
|
|
|
|
import { type CscDigest, hashOidForDigest, policyToLibpdfSignerAlgo } from './algorithm-resolver';
|
|
import { decodeCscCertChain } from './cert-chain';
|
|
import { decryptCscToken } from './ciphers';
|
|
import { cscSignHash } from './client/signatures';
|
|
import { loadCscCredential } from './credential';
|
|
import { buildTspAnchorName } from './pdf-names';
|
|
import { consumeCscSession, loadCscSession } from './sign-session';
|
|
import { CscCaptureSigner } from './signers/capture-signer';
|
|
import { CscFifoSigner } from './signers/fifo-signer';
|
|
import { getCscTransport } from './transport';
|
|
import { resolveCscSignTimeTsa } from './tsa-resolver';
|
|
|
|
/**
|
|
* CSC TSP sign-time orchestrator.
|
|
*
|
|
* Two-pass run, both passes operating on the same prep-time-persisted PDF
|
|
* bytes (`CscSession.items[i].documentDataId` pins an immutable rendered
|
|
* orphan row — see `prepare-recipient-signing.ts`):
|
|
*
|
|
* 1. Capture re-derives each item's `signedAttrs` digest under the
|
|
* session-pinned `signingTime` and asserts it matches the prep-time hash
|
|
* bit-for-bit. Defense in depth — the bytes are identical so a mismatch
|
|
* means libpdf changed between prep and sign or the row was tampered
|
|
* with. Throws `CSC_BASE_DOCUMENT_MUTATED` on divergence.
|
|
* 2. A single batched `signatures/signHash` (§11.9) returns position-ordered
|
|
* signatures that the embed pass writes back into the same anchors via
|
|
* `CscFifoSigner`.
|
|
*
|
|
* Output bytes are in-place-copied onto `envelopeItem.documentData` (the
|
|
* row id stays stable; only `type` + `data` change) — same pattern as
|
|
* `materializeTspAnchorsForEnvelope`. The uploaded rows from
|
|
* `putPdfFileServerSide` orbit as orphans.
|
|
*
|
|
* Persistence is bundled into one outer transaction so document-content
|
|
* updates, recipient signing-status, audit log, and session consume commit
|
|
* atomically. Post-tx side effects (webhooks, emails) run after.
|
|
*/
|
|
|
|
export type ExecuteTspSignOptions = {
|
|
sessionId: string;
|
|
recipientToken: string;
|
|
requestMetadata?: RequestMetadata;
|
|
};
|
|
|
|
export type ExecuteTspSignResult = { outcome: 'signed' } | { outcome: 'already_signed' };
|
|
|
|
type CapturedItem = {
|
|
envelopeItemId: string;
|
|
recapturedDigestB64: string;
|
|
anchorName: string;
|
|
pdfBytes: Uint8Array;
|
|
};
|
|
|
|
type SignedItemDataUpdate = {
|
|
/** Existing `envelopeItem.documentDataId` — receives the in-place data update. */
|
|
envelopeItemDataId: string;
|
|
/** Payload to copy onto the existing row. */
|
|
uploadedType: DocumentDataType;
|
|
uploadedData: string;
|
|
};
|
|
|
|
export const executeTspSign = async (opts: ExecuteTspSignOptions): Promise<ExecuteTspSignResult> => {
|
|
const { sessionId, recipientToken, requestMetadata } = opts;
|
|
|
|
const session = await loadCscSession(sessionId);
|
|
|
|
if (!session) {
|
|
throw new AppError(AppErrorCode.NOT_FOUND, {
|
|
message: `CSC session "${sessionId}" not found.`,
|
|
});
|
|
}
|
|
|
|
const recipient = await getRecipientByToken({ token: recipientToken }).catch(() => null);
|
|
|
|
if (!recipient) {
|
|
throw new AppError(AppErrorCode.NOT_FOUND, {
|
|
message: `Recipient with token "${recipientToken}" not found.`,
|
|
});
|
|
}
|
|
|
|
if (recipient.id !== session.recipientId) {
|
|
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
|
message: 'CSC session does not belong to the recipient identified by token.',
|
|
});
|
|
}
|
|
|
|
// Idempotency: a 15s tRPC timeout that races with a successful sign can
|
|
// leave the client retrying after the recipient row already flipped to
|
|
// SIGNED. Return success rather than re-running.
|
|
if (recipient.signingStatus === SigningStatus.SIGNED) {
|
|
return { outcome: 'already_signed' };
|
|
}
|
|
|
|
if (!session.encryptedSad || !session.sadExpiresAt) {
|
|
throw new AppError(AppErrorCode.CSC_SAD_EXPIRED_PRE_SIGN, {
|
|
message: 'CSC session has no attached SAD — credential-scope OAuth must complete first.',
|
|
});
|
|
}
|
|
|
|
if (session.sadExpiresAt.getTime() <= Date.now()) {
|
|
throw new AppError(AppErrorCode.CSC_SAD_EXPIRED_PRE_SIGN, {
|
|
message: 'CSC SAD expired before sign-time execution.',
|
|
});
|
|
}
|
|
|
|
const sad = decryptCscToken(session.encryptedSad);
|
|
|
|
if (!sad) {
|
|
throw new AppError(AppErrorCode.CSC_SAD_EXPIRED_PRE_SIGN, {
|
|
message: 'CSC SAD decrypt failed — key rotation or row corruption.',
|
|
});
|
|
}
|
|
|
|
const credential = await loadCscCredential(recipient.id);
|
|
|
|
if (!credential) {
|
|
throw new AppError(AppErrorCode.NOT_FOUND, {
|
|
message: 'CSC credential missing at sign time.',
|
|
});
|
|
}
|
|
|
|
if (!credential.certCache) {
|
|
throw new AppError(AppErrorCode.CSC_CERT_INVALID, {
|
|
message: 'CSC credential has no persisted certificate chain.',
|
|
});
|
|
}
|
|
|
|
if (credential.keyLenBits === null) {
|
|
throw new AppError(AppErrorCode.CSC_ALGORITHM_REFUSED, {
|
|
message: 'CSC credential omits persisted keyLenBits — service-scope OAuth must re-run.',
|
|
});
|
|
}
|
|
|
|
if (!credential.serviceTokenCiphertext || !credential.serviceTokenExpiresAt) {
|
|
throw new AppError(AppErrorCode.CSC_REQUEST_FAILED, {
|
|
message: 'CSC credential has no persisted service token — recipient must re-auth.',
|
|
});
|
|
}
|
|
|
|
if (credential.serviceTokenExpiresAt.getTime() <= Date.now()) {
|
|
throw new AppError(AppErrorCode.CSC_REQUEST_FAILED, {
|
|
message: 'CSC service token expired — recipient must re-auth via service-scope OAuth.',
|
|
});
|
|
}
|
|
|
|
const serviceToken = decryptCscToken(credential.serviceTokenCiphertext);
|
|
|
|
if (!serviceToken) {
|
|
throw new AppError(AppErrorCode.CSC_REQUEST_FAILED, {
|
|
message: 'CSC service token decrypt failed — operator re-auth required.',
|
|
});
|
|
}
|
|
|
|
const chain = decodeCscCertChain(credential.certCache);
|
|
|
|
const algo = policyToLibpdfSignerAlgo({
|
|
keyType: credential.keyType as 'RSA' | 'ECDSA',
|
|
digestAlgorithm: credential.digestAlgorithm as CscDigest,
|
|
signAlgoOid: credential.signatureAlgorithm,
|
|
keyLenBits: credential.keyLenBits,
|
|
hashAlgoOid: '',
|
|
});
|
|
|
|
const envelope = await prisma.envelope.findUniqueOrThrow({
|
|
where: { id: session.envelopeId },
|
|
include: {
|
|
envelopeItems: { include: { documentData: true } },
|
|
recipients: true,
|
|
documentMeta: true,
|
|
},
|
|
});
|
|
|
|
// Capture pass: iterate session.items in order so the resulting hash array
|
|
// is position-bound to session.items[*].ordinal.
|
|
const capturedItems: CapturedItem[] = [];
|
|
|
|
for (let i = 0; i < session.items.length; i++) {
|
|
const sessionItem = session.items[i];
|
|
|
|
const envelopeItem = envelope.envelopeItems.find((item) => item.id === sessionItem.envelopeItemId);
|
|
|
|
if (!envelopeItem) {
|
|
throw new AppError(AppErrorCode.CSC_BASE_DOCUMENT_MUTATED, {
|
|
message: `Session references envelope item "${sessionItem.envelopeItemId}" not on envelope.`,
|
|
});
|
|
}
|
|
|
|
const pinnedDocumentData = await prisma.documentData.findUniqueOrThrow({
|
|
where: { id: sessionItem.documentDataId },
|
|
});
|
|
|
|
const bytes = await getFileServerSide(pinnedDocumentData);
|
|
const pdfDoc = await PDF.load(bytes);
|
|
|
|
const captureSigner = new CscCaptureSigner({
|
|
certificate: chain[0],
|
|
certificateChain: chain.slice(1),
|
|
algo,
|
|
});
|
|
|
|
const anchorName = buildTspAnchorName(recipient.id, envelopeItem.id);
|
|
|
|
// Capture pass stays at B-B even though the embed pass below is B-T:
|
|
// libpdf's B-T signature timestamp is added as a CMS *unsigned*
|
|
// attribute *after* `signer.sign()` runs over the signed-attrs digest.
|
|
// The signed-attrs builder (see CAdESDetachedBuilder.create in
|
|
// @libpdf/core) takes only (signer, documentHash, digestAlgorithm,
|
|
// signingTime) — no level-conditional attributes — so B-B and B-T
|
|
// produce byte-identical signed-attrs for the same inputs. Capturing
|
|
// at B-B avoids dragging the TSA into the dry-run.
|
|
await pdfDoc.sign({
|
|
signer: captureSigner,
|
|
fieldName: anchorName,
|
|
signingTime: session.signingTime,
|
|
level: 'B-B',
|
|
digestAlgorithm: algo.digestAlgorithm,
|
|
});
|
|
|
|
if (captureSigner.capturedDigest === null) {
|
|
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
|
message: 'CscCaptureSigner was not invoked by pdf.sign during sign-time capture.',
|
|
});
|
|
}
|
|
|
|
const recapturedDigestB64 = Buffer.from(captureSigner.capturedDigest).toString('base64');
|
|
|
|
if (recapturedDigestB64 !== sessionItem.hashB64) {
|
|
throw new AppError(AppErrorCode.CSC_BASE_DOCUMENT_MUTATED, {
|
|
message: `Re-derived signedAttrs digest at sign time diverged from prep-time hash for envelope item "${envelopeItem.id}".`,
|
|
});
|
|
}
|
|
|
|
capturedItems.push({
|
|
envelopeItemId: envelopeItem.id,
|
|
recapturedDigestB64,
|
|
anchorName,
|
|
pdfBytes: bytes,
|
|
});
|
|
}
|
|
|
|
// Defensive: session-item / captured-item position binding must hold.
|
|
for (let i = 0; i < capturedItems.length; i++) {
|
|
if (capturedItems[i].envelopeItemId !== session.items[i].envelopeItemId) {
|
|
throw new AppError(AppErrorCode.CSC_EMBED_FAILED, {
|
|
message: 'Capture-pass item ordering diverged from session-pinned ordering.',
|
|
});
|
|
}
|
|
}
|
|
|
|
if (capturedItems.length === 0) {
|
|
throw new AppError(AppErrorCode.CSC_EMBED_FAILED, {
|
|
message: 'CSC session contains no items — nothing to sign.',
|
|
});
|
|
}
|
|
|
|
const hashes = capturedItems.map((c) => c.recapturedDigestB64);
|
|
// The cscSignHash request schema requires a non-empty tuple; the explicit
|
|
// check above narrows the array literal for the type system.
|
|
const [firstHash, ...restHashes] = hashes;
|
|
|
|
const transport = await getCscTransport();
|
|
|
|
const signHashResp = await cscSignHash({
|
|
baseUrl: transport.serviceBaseUrl,
|
|
accessToken: serviceToken,
|
|
credentialID: credential.credentialId,
|
|
SAD: sad,
|
|
hash: [firstHash, ...restHashes],
|
|
signAlgo: credential.signatureAlgorithm,
|
|
hashAlgo: hashOidForDigest(algo.digestAlgorithm),
|
|
});
|
|
|
|
if (signHashResp.signatures.length !== capturedItems.length) {
|
|
throw new AppError(AppErrorCode.CSC_EMBED_FAILED, {
|
|
message: `CSC signHash returned ${signHashResp.signatures.length} signatures for ${capturedItems.length} hashes.`,
|
|
});
|
|
}
|
|
|
|
// Embed pass: per-item, reload the same prep-persisted PDF bytes and sign
|
|
// with a single-signature FIFO signer. No re-render — bytes are exactly
|
|
// the ones whose digest the TSP just authorised. Level is B-T: each
|
|
// recipient's CMS gets a TSA-attested signature timestamp embedded as an
|
|
// unsigned attribute, binding proven time to the signature itself (the
|
|
// actual eIDAS AES/QES requirement). The TSA is resolved per-recipient
|
|
// via the sign-time resolver — TSP if advertised (authorised with this
|
|
// recipient's service-scope bearer), env otherwise.
|
|
const timestampAuthority = resolveCscSignTimeTsa(transport, serviceToken);
|
|
|
|
const signedItemDataUpdates: SignedItemDataUpdate[] = [];
|
|
|
|
for (let i = 0; i < capturedItems.length; i++) {
|
|
const captured = capturedItems[i];
|
|
const sigBytes = Buffer.from(signHashResp.signatures[i], 'base64');
|
|
|
|
const pdfDoc = await PDF.load(captured.pdfBytes);
|
|
|
|
const fifoSigner = new CscFifoSigner({
|
|
certificate: chain[0],
|
|
certificateChain: chain.slice(1),
|
|
algo,
|
|
signatures: [sigBytes],
|
|
});
|
|
|
|
const signResult = await pdfDoc.sign({
|
|
signer: fifoSigner,
|
|
fieldName: captured.anchorName,
|
|
signingTime: session.signingTime,
|
|
level: 'B-T',
|
|
timestampAuthority,
|
|
digestAlgorithm: algo.digestAlgorithm,
|
|
});
|
|
|
|
const envelopeItem = envelope.envelopeItems.find((item) => item.id === captured.envelopeItemId);
|
|
|
|
if (!envelopeItem) {
|
|
throw new AppError(AppErrorCode.CSC_EMBED_FAILED, {
|
|
message: `Envelope item "${captured.envelopeItemId}" missing during embed pass.`,
|
|
});
|
|
}
|
|
|
|
const fileName = envelope.title.endsWith('.pdf') ? envelope.title : `${envelope.title || 'envelope'}.pdf`;
|
|
|
|
const uploaded = await putPdfFileServerSide(
|
|
{
|
|
name: fileName,
|
|
type: 'application/pdf',
|
|
arrayBuffer: async () => Promise.resolve(signResult.bytes),
|
|
},
|
|
envelopeItem.documentData.initialData ?? undefined,
|
|
);
|
|
|
|
// In-place data update target: the existing envelopeItem.documentDataId
|
|
// row. `uploaded.documentData` is the freshly-created row whose payload
|
|
// we'll copy on; that row stays orphan after the copy. Mirrors the
|
|
// `materializeTspAnchorsForEnvelope` pattern.
|
|
signedItemDataUpdates.push({
|
|
envelopeItemDataId: envelopeItem.documentDataId,
|
|
uploadedType: uploaded.documentData.type,
|
|
uploadedData: uploaded.documentData.data,
|
|
});
|
|
}
|
|
|
|
const legacyDocumentId = mapSecondaryIdToDocumentId(envelope.secondaryId);
|
|
|
|
// Single tx: per-item in-place data updates + recipient flip + audit log +
|
|
// session consume. Atomic across items — if any write fails, the recipient
|
|
// stays unsigned and the session row stays attached. `envelopeItem.
|
|
// documentDataId` is preserved across the run; only `documentData.{type,
|
|
// data}` changes. Mirrors `materializeTspAnchorsForEnvelope`.
|
|
await prisma.$transaction(async (tx) => {
|
|
for (const { envelopeItemDataId, uploadedType, uploadedData } of signedItemDataUpdates) {
|
|
await tx.documentData.update({
|
|
where: { id: envelopeItemDataId },
|
|
data: { type: uploadedType, data: uploadedData },
|
|
});
|
|
}
|
|
|
|
await tx.recipient.update({
|
|
where: { id: recipient.id },
|
|
data: {
|
|
signingStatus: SigningStatus.SIGNED,
|
|
signedAt: new Date(),
|
|
},
|
|
});
|
|
|
|
const authOptions = extractDocumentAuthMethods({
|
|
documentAuth: envelope.authOptions,
|
|
recipientAuth: recipient.authOptions,
|
|
});
|
|
|
|
await tx.documentAuditLog.create({
|
|
data: createDocumentAuditLogData({
|
|
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED,
|
|
envelopeId: envelope.id,
|
|
user: {
|
|
name: recipient.name,
|
|
email: recipient.email,
|
|
},
|
|
requestMetadata,
|
|
data: {
|
|
recipientEmail: recipient.email,
|
|
recipientName: recipient.name,
|
|
recipientId: recipient.id,
|
|
recipientRole: recipient.role,
|
|
actionAuth: authOptions.derivedRecipientActionAuth,
|
|
},
|
|
}),
|
|
});
|
|
|
|
await tx.documentAuditLog.create({
|
|
data: createDocumentAuditLogData({
|
|
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_CSC_SIGNED,
|
|
envelopeId: envelope.id,
|
|
user: { name: recipient.name, email: recipient.email },
|
|
requestMetadata,
|
|
data: {
|
|
recipientEmail: recipient.email,
|
|
recipientName: recipient.name,
|
|
recipientId: recipient.id,
|
|
recipientRole: recipient.role,
|
|
providerId: credential.providerId,
|
|
credentialId: credential.credentialId,
|
|
sessionId,
|
|
numItemsSigned: signedItemDataUpdates.length,
|
|
signatureAlgorithm: credential.signatureAlgorithm,
|
|
digestAlgorithm: credential.digestAlgorithm,
|
|
},
|
|
}),
|
|
});
|
|
|
|
await consumeCscSession(sessionId, tx);
|
|
});
|
|
|
|
// Post-tx side effects (webhooks, emails, next-signer advancement, seal
|
|
// job dispatch). Inlined rather than shared with the SES completion path —
|
|
// the in-tx shape diverges enough (TSP swaps documentDataIds + consumes
|
|
// the CSC session; SES doesn't) that a shared helper would obscure both.
|
|
const envelopeWithRelations = await prisma.envelope.findUniqueOrThrow({
|
|
where: { id: envelope.id },
|
|
include: { documentMeta: true, recipients: true },
|
|
});
|
|
|
|
await triggerWebhook({
|
|
event: WebhookTriggerEvents.DOCUMENT_RECIPIENT_COMPLETED,
|
|
data: ZWebhookDocumentSchema.parse(mapEnvelopeToWebhookDocumentPayload(envelopeWithRelations)),
|
|
userId: envelope.userId,
|
|
teamId: envelope.teamId,
|
|
});
|
|
|
|
await jobs.triggerJob({
|
|
name: 'send.recipient.signed.email',
|
|
payload: {
|
|
documentId: legacyDocumentId,
|
|
recipientId: recipient.id,
|
|
},
|
|
});
|
|
|
|
const pendingRecipients = await prisma.recipient.findMany({
|
|
select: {
|
|
id: true,
|
|
signingOrder: true,
|
|
role: true,
|
|
},
|
|
where: {
|
|
envelopeId: envelope.id,
|
|
signingStatus: { not: SigningStatus.SIGNED },
|
|
role: { not: RecipientRole.CC },
|
|
},
|
|
orderBy: [{ signingOrder: { sort: 'asc', nulls: 'last' } }, { id: 'asc' }],
|
|
});
|
|
|
|
if (pendingRecipients.length > 0) {
|
|
await sendPendingEmail({
|
|
id: { type: 'envelopeId', id: envelope.id },
|
|
recipientId: recipient.id,
|
|
});
|
|
|
|
// TSP envelopes are forced SEQUENTIAL at send-time; this branch always
|
|
// fires when pending recipients exist. No `nextSigner` dictation path
|
|
// — `prepareCscRecipientSigning` doesn't accept one.
|
|
const [nextRecipient] = pendingRecipients;
|
|
|
|
await prisma.recipient.update({
|
|
where: { id: nextRecipient.id },
|
|
data: {
|
|
sendStatus: SendStatus.SENT,
|
|
sentAt: new Date(),
|
|
},
|
|
});
|
|
|
|
await jobs.triggerJob({
|
|
name: 'send.signing.requested.email',
|
|
payload: {
|
|
userId: envelope.userId,
|
|
documentId: legacyDocumentId,
|
|
recipientId: nextRecipient.id,
|
|
requestMetadata,
|
|
},
|
|
});
|
|
}
|
|
|
|
const haveAllRecipientsSigned = await prisma.envelope.findFirst({
|
|
where: {
|
|
id: envelope.id,
|
|
recipients: {
|
|
every: {
|
|
OR: [{ signingStatus: SigningStatus.SIGNED }, { role: RecipientRole.CC }],
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
if (haveAllRecipientsSigned) {
|
|
await jobs.triggerJob({
|
|
name: 'internal.seal-document',
|
|
payload: {
|
|
documentId: legacyDocumentId,
|
|
requestMetadata,
|
|
},
|
|
});
|
|
}
|
|
|
|
const updatedDocument = await prisma.envelope.findFirstOrThrow({
|
|
where: {
|
|
id: envelope.id,
|
|
type: EnvelopeType.DOCUMENT,
|
|
},
|
|
include: {
|
|
documentMeta: true,
|
|
recipients: true,
|
|
},
|
|
});
|
|
|
|
await triggerWebhook({
|
|
event: WebhookTriggerEvents.DOCUMENT_SIGNED,
|
|
data: ZWebhookDocumentSchema.parse(mapEnvelopeToWebhookDocumentPayload(updatedDocument)),
|
|
userId: updatedDocument.userId,
|
|
teamId: updatedDocument.teamId ?? undefined,
|
|
});
|
|
|
|
return { outcome: 'signed' };
|
|
};
|