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
+28
View File
@@ -0,0 +1,28 @@
import { z } from 'zod';
/**
* One entry in a CSC sign-time session, pinning the bytes (`documentDataId`)
* whose digest (`hashB64`) was captured at prep time for a given envelope
* item. The `ordinal` is the position of this entry in the session items array
* — it lines up with the position-ordered `signatures/signHash` response per
* CSC v1.0.4.0 §11.9.
*/
export const ZCscSessionItemSchema = z.object({
envelopeItemId: z.string(),
documentDataId: z.string(),
hashB64: z.string(),
ordinal: z.number().int().nonnegative(),
});
/**
* Contract between CSC prep and sign on `CscSession.itemsJson`.
*
* Built at prep time alongside the captured signedAttrs digests. At sign time
* the mutation re-derives each item's digest against the pinned
* `documentDataId` bytes and dispatches one batched `signatures/signHash` per
* recipient.
*/
export const ZCscSessionItemsSchema = z.array(ZCscSessionItemSchema);
export type TCscSessionItem = z.infer<typeof ZCscSessionItemSchema>;
export type TCscSessionItems = z.infer<typeof ZCscSessionItemsSchema>;
+77
View File
@@ -54,6 +54,13 @@ export const ZDocumentAuditLogTypeSchema = z.enum([
'DOCUMENT_ACCESS_AUTH_2FA_REQUESTED', // When ACCESS AUTH 2FA is requested.
'DOCUMENT_ACCESS_AUTH_2FA_VALIDATED', // When ACCESS AUTH 2FA is successfully validated.
'DOCUMENT_ACCESS_AUTH_2FA_FAILED', // When ACCESS AUTH 2FA validation fails.
// CSC / TSP signing events.
'DOCUMENT_RECIPIENT_CSC_AUTHENTICATED', // Service-scope OAuth complete; CSC credential persisted.
'DOCUMENT_RECIPIENT_CSC_AUTHENTICATION_FAILED', // Service-scope OAuth completed but TSP returned a blocking error (empty credential list / invalid cert / refused algorithm).
'DOCUMENT_RECIPIENT_CSC_SIGN_REQUESTED', // Recipient clicked Sign; CSC session created with captured per-item hashes.
'DOCUMENT_RECIPIENT_CSC_AUTHORIZED', // Credential-scope OAuth complete; SAD attached to the CSC session.
'DOCUMENT_RECIPIENT_CSC_SIGNED', // TSP returned signatures and they were embedded into the recipient's PDF bytes.
]);
export const ZDocumentAuditLogEmailTypeSchema = z.enum([
@@ -722,6 +729,71 @@ export const ZDocumentAuditLogEventRecipientExpiredSchema = z.object({
}),
});
/**
* Event: Recipient completed CSC service-scope OAuth — credential discovered, certificate persisted.
*/
export const ZDocumentAuditLogEventDocumentRecipientCscAuthenticatedSchema = z.object({
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_CSC_AUTHENTICATED),
data: ZBaseRecipientDataSchema.extend({
providerId: z.string(),
credentialId: z.string(),
signatureAlgorithm: z.string(),
digestAlgorithm: z.string(),
}),
});
/**
* Event: Recipient's CSC service-scope OAuth completed but the TSP returned a blocking error.
*/
export const ZDocumentAuditLogEventDocumentRecipientCscAuthenticationFailedSchema = z.object({
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_CSC_AUTHENTICATION_FAILED),
data: ZBaseRecipientDataSchema.extend({
providerId: z.string(),
reason: z.string(),
}),
});
/**
* Event: Recipient initiated TSP signing — CSC session created with per-item hashes.
*/
export const ZDocumentAuditLogEventDocumentRecipientCscSignRequestedSchema = z.object({
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_CSC_SIGN_REQUESTED),
data: ZBaseRecipientDataSchema.extend({
providerId: z.string(),
credentialId: z.string(),
sessionId: z.string(),
numSignatures: z.number(),
}),
});
/**
* Event: Recipient completed CSC credential-scope OAuth — SAD attached to session.
*/
export const ZDocumentAuditLogEventDocumentRecipientCscAuthorizedSchema = z.object({
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_CSC_AUTHORIZED),
data: ZBaseRecipientDataSchema.extend({
providerId: z.string(),
credentialId: z.string(),
sessionId: z.string(),
sadExpiresAt: z.coerce.date(),
}),
});
/**
* Event: TSP returned signatures and they were embedded into the recipient's PDF bytes.
*/
export const ZDocumentAuditLogEventDocumentRecipientCscSignedSchema = z.object({
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_CSC_SIGNED),
data: ZBaseRecipientDataSchema.extend({
providerId: z.string(),
credentialId: z.string(),
sessionId: z.string(),
numItemsSigned: z.number(),
signatureAlgorithm: z.string(),
digestAlgorithm: z.string(),
}),
});
export const ZDocumentAuditLogBaseSchema = z.object({
id: z.string(),
createdAt: z.date(),
@@ -770,6 +842,11 @@ export const ZDocumentAuditLogSchema = ZDocumentAuditLogBaseSchema.and(
ZDocumentAuditLogEventRecipientUpdatedSchema,
ZDocumentAuditLogEventRecipientRemovedSchema,
ZDocumentAuditLogEventRecipientExpiredSchema,
ZDocumentAuditLogEventDocumentRecipientCscAuthenticatedSchema,
ZDocumentAuditLogEventDocumentRecipientCscAuthenticationFailedSchema,
ZDocumentAuditLogEventDocumentRecipientCscSignRequestedSchema,
ZDocumentAuditLogEventDocumentRecipientCscAuthorizedSchema,
ZDocumentAuditLogEventDocumentRecipientCscSignedSchema,
]),
);
+9
View File
@@ -11,6 +11,8 @@ export const ZLicenseClaimSchema = z.object({
hipaa: z.boolean().optional(),
authenticationPortal: z.boolean().optional(),
billing: z.boolean().optional(),
instanceCscSigning: z.boolean().optional(),
cscQesSigning: z.boolean().optional(),
});
/**
@@ -43,6 +45,13 @@ export type TLicenseClaim = z.infer<typeof ZLicenseClaimSchema>;
export type TLicenseRequest = z.infer<typeof ZLicenseRequestSchema>;
export type TLicenseResponse = z.infer<typeof ZLicenseResponseSchema>;
/**
* String-literal union of every flag the licence server can grant. Adding a
* field to `ZLicenseClaimSchema` automatically extends this type so that
* `assertLicensedFor(flag)` and similar helpers stay in sync.
*/
export type LicenseFlag = keyof TLicenseClaim;
/**
* Schema for the cached license data stored in the file.
*/
+33
View File
@@ -0,0 +1,33 @@
import { z } from 'zod';
/**
* The cryptographic signature tier an envelope is signed at.
*
* - `SES` — Simple Electronic Signature; the default Documenso flow signed
* with the instance-held certificate.
* - `AES` — Advanced Electronic Signature; recipient-bound signing through a
* Cloud Signature Consortium (CSC) Trust Service Provider.
* - `QES` — Qualified Electronic Signature; the eIDAS-qualified variant of
* AES, also recipient-bound through a CSC TSP.
*
* Stored as free-form TEXT on `Envelope.signatureLevel` so the legal-tier
* taxonomy can expand without a DB enum migration. Validation lives here.
*/
export const ZSignatureLevelSchema = z.enum(['SES', 'AES', 'QES']);
export const SignatureLevel = ZSignatureLevelSchema.enum;
export type TSignatureLevel = z.infer<typeof ZSignatureLevelSchema>;
/**
* Whether an envelope's signature level routes through a Cloud Signature
* Consortium Trust Service Provider (`AES` or `QES`). The single branch point
* for TSP-vs-SES runtime divergence — seal handler, download endpoint,
* completion email, and send-time PDF prep all key off this.
*
* Accepts a raw `string` because the Prisma column is TEXT; unknown values
* are conservatively treated as non-TSP so a malformed row can't accidentally
* trigger TSP-only code paths.
*/
export const isTspEnvelope = (envelope: { signatureLevel: string }): boolean =>
envelope.signatureLevel === SignatureLevel.AES || envelope.signatureLevel === SignatureLevel.QES;
+7
View File
@@ -51,6 +51,8 @@ export const ZClaimFlagsSchema = z.object({
signingReminders: z.boolean().optional(),
cscQesSigning: z.boolean().optional(),
/**
* Controls whether an organisation is prevented from sending emails.
*
@@ -128,6 +130,11 @@ export const SUBSCRIPTION_CLAIM_FEATURE_FLAGS: Record<
key: 'signingReminders',
label: 'Signing reminders',
},
cscQesSigning: {
key: 'cscQesSigning',
label: 'QES signing',
isEnterprise: true,
},
disableEmails: {
key: 'disableEmails',
label: 'Disable emails',