feat: add CSC AES/QES signing (v1 instance-wide config) (#2874)

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.
This commit is contained in:
Lucas Smith
2026-06-16 23:37:34 +10:00
committed by GitHub
parent 9b59f1a273
commit d5ce222482
103 changed files with 6524 additions and 77 deletions
@@ -0,0 +1,43 @@
import { executeTspSign } from '@documenso/ee/server-only/signing/csc/execute-tsp-sign';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { procedure } from '../trpc';
import { ZCscSignEnvelopeRequestSchema, ZCscSignEnvelopeResponseSchema } from './csc-sign-envelope.types';
/**
* Internal mutation that drives the CSC TSP sign-time pipeline.
*
* `executeTspSign` does the heavy lifting (capture → batched signHash →
* embed → tx). This route wraps it in a 15s `Promise.race` so an unresponsive
* TSP surfaces as `CSC_TSP_TIMEOUT` instead of hanging the request. The
* idle-timer is a soft cap on TSP round-trip latency; the underlying tx
* keeps running on the server until it completes or errors.
*/
const SIGN_TIMEOUT_MS = 15_000;
export const cscSignEnvelopeRoute = procedure
.input(ZCscSignEnvelopeRequestSchema)
.output(ZCscSignEnvelopeResponseSchema)
.mutation(async ({ input, ctx }) => {
const result = await Promise.race([
executeTspSign({
sessionId: input.sessionId,
recipientToken: input.recipientToken,
requestMetadata: ctx.metadata.requestMetadata,
}),
new Promise<never>((_, reject) =>
setTimeout(
() =>
reject(
new AppError(AppErrorCode.CSC_TSP_TIMEOUT, {
message: 'CSC TSP did not respond within 15s.',
}),
),
SIGN_TIMEOUT_MS,
),
),
]);
return result;
});
@@ -0,0 +1,13 @@
import { z } from 'zod';
export const ZCscSignEnvelopeRequestSchema = z.object({
recipientToken: z.string().min(1),
sessionId: z.string().min(1),
});
export const ZCscSignEnvelopeResponseSchema = z.object({
outcome: z.enum(['signed', 'already_signed']),
});
export type TCscSignEnvelopeRequest = z.infer<typeof ZCscSignEnvelopeRequestSchema>;
export type TCscSignEnvelopeResponse = z.infer<typeof ZCscSignEnvelopeResponseSchema>;
@@ -2,6 +2,7 @@ import { router } from '../trpc';
import { createOrganisationEmailRoute } from './create-organisation-email';
import { createOrganisationEmailDomainRoute } from './create-organisation-email-domain';
import { createSubscriptionRoute } from './create-subscription';
import { cscSignEnvelopeRoute } from './csc-sign-envelope';
import { declineLinkOrganisationAccountRoute } from './decline-link-organisation-account';
import { deleteOrganisationEmailRoute } from './delete-organisation-email';
import { deleteOrganisationEmailDomainRoute } from './delete-organisation-email-domain';
@@ -55,4 +56,7 @@ export const enterpriseRouter = router({
get: getInvoicesRoute,
},
},
csc: {
signEnvelope: cscSignEnvelopeRoute,
},
});
@@ -1,4 +1,5 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { assertEnvelopeMutable } from '@documenso/lib/server-only/envelope/assert-envelope-mutable';
import { getEnvelopeWhereInput } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
import { UNSAFE_replaceEnvelopeItemPdf } from '@documenso/lib/server-only/envelope-item/replace-envelope-item-pdf';
import { getEnvelopeItemPermissions } from '@documenso/lib/utils/envelope';
@@ -59,6 +60,8 @@ export const replaceEnvelopeItemPdfRoute = authenticatedProcedure
});
}
assertEnvelopeMutable(envelope);
if (envelope.internalVersion !== 2) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'PDF replacement is only supported for version 2 envelopes',
@@ -1,3 +1,4 @@
import { prepareCscRecipientSigning } from '@documenso/ee/server-only/signing/csc/prepare-recipient-signing';
import { completeDocumentWithToken } from '@documenso/lib/server-only/document/complete-document-with-token';
import { rejectDocumentWithToken } from '@documenso/lib/server-only/document/reject-document-with-token';
import { createEnvelopeRecipients } from '@documenso/lib/server-only/recipient/create-envelope-recipients';
@@ -6,6 +7,9 @@ import { getRecipientById } from '@documenso/lib/server-only/recipient/get-recip
import { setDocumentRecipients } from '@documenso/lib/server-only/recipient/set-document-recipients';
import { setTemplateRecipients } from '@documenso/lib/server-only/recipient/set-template-recipients';
import { updateEnvelopeRecipients } from '@documenso/lib/server-only/recipient/update-envelope-recipients';
import { isTspEnvelope } from '@documenso/lib/types/signature-level';
import { unsafeBuildEnvelopeIdQuery } from '@documenso/lib/utils/envelope';
import { prisma } from '@documenso/prisma';
import { EnvelopeType } from '@prisma/client';
import { ZGenericSuccessResponse, ZSuccessResponseSchema } from '../schema';
@@ -13,6 +17,7 @@ import { authenticatedProcedure, procedure, router } from '../trpc';
import { findRecipientSuggestionsRoute } from './find-recipient-suggestions';
import {
ZCompleteDocumentWithTokenMutationSchema,
ZCompleteDocumentWithTokenResponseSchema,
ZCreateDocumentRecipientRequestSchema,
ZCreateDocumentRecipientResponseSchema,
ZCreateDocumentRecipientsRequestSchema,
@@ -559,6 +564,7 @@ export const recipientRouter = router({
*/
completeDocumentWithToken: procedure
.input(ZCompleteDocumentWithTokenMutationSchema)
.output(ZCompleteDocumentWithTokenResponseSchema)
.mutation(async ({ input, ctx }) => {
const { token, documentId, accessAuthOptions, nextSigner, recipientOverride } = input;
@@ -568,6 +574,25 @@ export const recipientRouter = router({
},
});
// Branch on TSP envelopes before any SES side effects: TSP recipients
// can't complete via this route — they go through the CSC sync sign
// flow (`enterprise.csc.signEnvelope`). This route returns the redirect URL
// for the credential-scope OAuth round-trip.
const envelope = await prisma.envelope.findFirstOrThrow({
where: {
...unsafeBuildEnvelopeIdQuery({ type: 'documentId', id: documentId }, EnvelopeType.DOCUMENT),
recipients: { some: { token } },
},
select: { signatureLevel: true, internalVersion: true },
});
if (isTspEnvelope(envelope)) {
return await prepareCscRecipientSigning({
recipientToken: token,
requestMetadata: ctx.metadata.requestMetadata,
});
}
await completeDocumentWithToken({
token,
id: {
@@ -580,6 +605,8 @@ export const recipientRouter = router({
userId: ctx.user?.id,
requestMetadata: ctx.metadata.requestMetadata,
});
return { status: 'SIGNED' as const };
}),
/**
@@ -178,6 +178,20 @@ export const ZCompleteDocumentWithTokenMutationSchema = z.object({
export type TCompleteDocumentWithTokenMutationSchema = z.infer<typeof ZCompleteDocumentWithTokenMutationSchema>;
/**
* Discriminated response: SES envelopes return `{ status: 'SIGNED' }` after
* the in-place completion; TSP (AES/QES) envelopes return
* `{ status: 'REDIRECT', redirectUrl }` pointing at the credential-scope
* OAuth authorize endpoint. Frontend callers can branch on `status` —
* existing callers ignored the response and remain compatible.
*/
export const ZCompleteDocumentWithTokenResponseSchema = z.discriminatedUnion('status', [
z.object({ status: z.literal('REDIRECT'), redirectUrl: z.string() }),
z.object({ status: z.literal('SIGNED') }),
]);
export type TCompleteDocumentWithTokenResponseSchema = z.infer<typeof ZCompleteDocumentWithTokenResponseSchema>;
export const ZRejectDocumentWithTokenMutationSchema = z.object({
token: z.string(),
documentId: z.number(),