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,54 @@
-- AlterTable
-- Add the column with a temporary DEFAULT so Postgres backfills existing rows
-- to 'SES' (the only level the instance supported before this migration). The
-- DEFAULT is then dropped so future INSERTs must specify signatureLevel
-- explicitly via resolveSignatureLevel — the column carries no DB-level default
-- by design (see packages/lib/server-only/signature-level/resolve-signature-level.ts).
ALTER TABLE "Envelope" ADD COLUMN "signatureLevel" TEXT NOT NULL DEFAULT 'SES';
ALTER TABLE "Envelope" ALTER COLUMN "signatureLevel" DROP DEFAULT;
-- CreateTable
CREATE TABLE "CscCredential" (
"id" TEXT NOT NULL,
"providerId" TEXT NOT NULL,
"credentialId" TEXT NOT NULL,
"certCache" BYTEA,
"signatureAlgorithm" TEXT NOT NULL,
"keyType" TEXT NOT NULL,
"digestAlgorithm" TEXT NOT NULL,
"keyLenBits" INTEGER,
"signAlgoParams" TEXT,
"serviceTokenCiphertext" BYTEA,
"serviceTokenExpiresAt" TIMESTAMP(3),
"recipientId" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "CscCredential_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "CscSession" (
"id" TEXT NOT NULL,
"envelopeId" TEXT NOT NULL,
"signingTime" TIMESTAMP(3) NOT NULL,
"itemsJson" JSONB NOT NULL,
"encryptedSad" BYTEA,
"sadExpiresAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"recipientId" INTEGER NOT NULL,
CONSTRAINT "CscSession_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "CscCredential_recipientId_key" ON "CscCredential"("recipientId");
-- CreateIndex
CREATE UNIQUE INDEX "CscSession_recipientId_key" ON "CscSession"("recipientId");
-- AddForeignKey
ALTER TABLE "CscCredential" ADD CONSTRAINT "CscCredential_recipientId_fkey" FOREIGN KEY ("recipientId") REFERENCES "Recipient"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CscSession" ADD CONSTRAINT "CscSession_recipientId_fkey" FOREIGN KEY ("recipientId") REFERENCES "Recipient"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+72 -1
View File
@@ -429,7 +429,7 @@ enum EnvelopeType {
TEMPLATE
}
/// @zod.import(["import { ZDocumentAuthOptionsSchema } from '@documenso/lib/types/document-auth';", "import { ZDocumentFormValuesSchema } from '@documenso/lib/types/document-form-values';"])
/// @zod.import(["import { ZDocumentAuthOptionsSchema } from '@documenso/lib/types/document-auth';", "import { ZDocumentFormValuesSchema } from '@documenso/lib/types/document-form-values';", "import { ZSignatureLevelSchema } from '@documenso/lib/types/signature-level';"])
model Envelope {
id String @id
secondaryId String @unique
@@ -447,6 +447,8 @@ model Envelope {
source DocumentSource
qrToken String? /// @zod.string.describe("The token for viewing the document using the QR code on the certificate.")
signatureLevel String /// [SignatureLevel] @zod.custom.use(ZSignatureLevelSchema)
internalVersion Int
useLegacyFieldInsertion Boolean @default(false)
@@ -650,6 +652,9 @@ model Recipient {
fields Field[]
signatures Signature[]
cscCredential CscCredential?
cscSession CscSession?
@@index([token])
@@index([email])
@@index([envelopeId])
@@ -717,6 +722,72 @@ model Signature {
@@index([recipientId])
}
/// Per-recipient cached credential metadata for the configured Cloud Signature
/// Consortium (CSC) provider. Holds the TSP-validated certificate chain and the
/// symmetric-encrypted service-scope access token. One row per recipient on a
/// CSC envelope; absent for SES recipients.
model CscCredential {
id String @id @default(cuid())
providerId String
credentialId String
/// TSP-validated certificate chain (length-prefixed). Nullable until the
/// service-scope OAuth callback successfully retrieves and validates it.
certCache Bytes?
/// Algorithm metadata derived from the CSC `credentials/info` response.
/// Held as flat scalars so each is independently queryable.
signatureAlgorithm String
keyType String
digestAlgorithm String
keyLenBits Int?
signAlgoParams String?
/// Service-scope access token, symmetric-encrypted at the application layer
/// before persistence. Nullable until the service-scope OAuth callback runs.
serviceTokenCiphertext Bytes?
serviceTokenExpiresAt DateTime?
recipientId Int @unique
recipient Recipient @relation(fields: [recipientId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
/// Per-recipient transient sign-time state held across the credential-scope
/// OAuth round-trip. The `recipientId @unique` constraint caps each recipient
/// to at most one in-flight session — re-clicking Sign UPSERTs the row.
/// Cleaned up transitively when the Recipient is deleted.
/// @zod.import(["import { ZCscSessionItemsSchema } from '@documenso/lib/types/csc-session';"])
model CscSession {
id String @id @default(cuid())
/// Denormalised envelope id for ergonomic lookups. No FK relation — cleanup
/// flows through the Recipient cascade.
envelopeId String
/// Pinned at prep time; stable across overlay re-renders so the captured
/// signedAttrs digest matches the bytes the TSP signs.
signingTime DateTime
/// Contract between prep and sign: an array of
/// `{ envelopeItemId, documentDataId, hashB64, ordinal }` per envelope item.
/// `documentDataId` pins the immutable bytes that produced the captured
/// digest so sign-time can re-derive the same hash against the same content.
itemsJson Json /// [CscSessionItems] @zod.custom.use(ZCscSessionItemsSchema)
/// Populated by the credential-scope OAuth callback. Both nullable at row
/// creation; set together when the SAD is exchanged.
encryptedSad Bytes?
sadExpiresAt DateTime?
createdAt DateTime @default(now())
recipientId Int @unique
recipient Recipient @relation(fields: [recipientId], references: [id], onDelete: Cascade)
}
model DocumentShareLink {
id Int @id @default(autoincrement())
email String
+5
View File
@@ -14,6 +14,7 @@ import {
FIELD_SIGNATURE_META_DEFAULT_VALUES,
FIELD_TEXT_META_DEFAULT_VALUES,
} from '@documenso/lib/types/field-meta';
import { SignatureLevel } from '@documenso/lib/types/signature-level';
import { prefixedId } from '@documenso/lib/universal/id';
import type { Team, User } from '@prisma/client';
import { nanoid } from 'nanoid';
@@ -93,6 +94,7 @@ export const seedBlankDocument = async (owner: User, teamId: number, options: Cr
id: prefixedId('envelope'),
secondaryId: documentId.formattedDocumentId,
internalVersion,
signatureLevel: SignatureLevel.SES,
type: EnvelopeType.DOCUMENT,
documentMetaId: documentMeta.id,
source: DocumentSource.DOCUMENT,
@@ -313,6 +315,7 @@ export const seedDraftDocument = async (
id: prefixedId('envelope'),
secondaryId: documentId.formattedDocumentId,
internalVersion,
signatureLevel: SignatureLevel.SES,
type: EnvelopeType.DOCUMENT,
documentMetaId: documentMeta.id,
source: DocumentSource.DOCUMENT,
@@ -407,6 +410,7 @@ export const seedPendingDocument = async (
id: prefixedId('envelope'),
secondaryId: documentId.formattedDocumentId,
internalVersion,
signatureLevel: SignatureLevel.SES,
type: EnvelopeType.DOCUMENT,
documentMetaId: documentMeta.id,
source: DocumentSource.DOCUMENT,
@@ -660,6 +664,7 @@ export const seedCompletedDocument = async (
id: prefixedId('envelope'),
secondaryId: documentId.formattedDocumentId,
internalVersion,
signatureLevel: SignatureLevel.SES,
type: EnvelopeType.DOCUMENT,
documentMetaId: documentMeta.id,
source: DocumentSource.DOCUMENT,
+5
View File
@@ -6,6 +6,7 @@ import { FIELD_META_TEST_FIELDS } from '@documenso/app-tests/constants/field-met
import { OVERFLOW_TEST_FIELDS } from '@documenso/app-tests/constants/field-overflow-pdf';
import { isBase64Image } from '@documenso/lib/constants/signatures';
import { incrementDocumentId, incrementTemplateId } from '@documenso/lib/server-only/envelope/increment-id';
import { SignatureLevel } from '@documenso/lib/types/signature-level';
import { nanoid, prefixedId } from '@documenso/lib/universal/id';
import { DIRECT_TEMPLATE_RECIPIENT_EMAIL, DIRECT_TEMPLATE_RECIPIENT_NAME } from '../../lib/constants/direct-templates';
import { prisma } from '..';
@@ -76,6 +77,7 @@ export const seedDatabase = async () => {
id: prefixedId('envelope'),
secondaryId: documentId.formattedDocumentId,
internalVersion: 1,
signatureLevel: SignatureLevel.SES,
type: EnvelopeType.DOCUMENT,
documentMetaId: documentMeta.id,
source: DocumentSource.DOCUMENT,
@@ -115,6 +117,7 @@ export const seedDatabase = async () => {
id: prefixedId('envelope'),
secondaryId: documentId.formattedDocumentId,
internalVersion: 1,
signatureLevel: SignatureLevel.SES,
type: EnvelopeType.DOCUMENT,
source: DocumentSource.DOCUMENT,
title: `Document ${i}`,
@@ -312,6 +315,7 @@ export const seedAlignmentTestDocument = async ({
id: prefixedId('envelope'),
secondaryId,
internalVersion: 2,
signatureLevel: SignatureLevel.SES,
type,
documentMetaId: documentMeta.id,
source: DocumentSource.DOCUMENT,
@@ -474,6 +478,7 @@ export const seedOverflowTestDocument = async ({
id: prefixedId('envelope'),
secondaryId,
internalVersion: 2,
signatureLevel: SignatureLevel.SES,
type: EnvelopeType.DOCUMENT,
documentMetaId: documentMeta.id,
source: DocumentSource.DOCUMENT,
+4
View File
@@ -6,6 +6,7 @@ import {
DIRECT_TEMPLATE_RECIPIENT_NAME,
} from '@documenso/lib/constants/direct-templates';
import { incrementTemplateId } from '@documenso/lib/server-only/envelope/increment-id';
import { SignatureLevel } from '@documenso/lib/types/signature-level';
import { prefixedId } from '@documenso/lib/universal/id';
import { prisma } from '..';
@@ -57,6 +58,7 @@ export const seedBlankTemplate = async (owner: User, teamId: number, options: Cr
id: prefixedId('envelope'),
secondaryId: templateId.formattedTemplateId,
internalVersion: 1,
signatureLevel: SignatureLevel.SES,
type: EnvelopeType.TEMPLATE,
title: `[TEST] Template ${key}`,
teamId,
@@ -105,6 +107,7 @@ export const seedTemplate = async (options: SeedTemplateOptions) => {
id: prefixedId('envelope'),
secondaryId: templateId.formattedTemplateId,
internalVersion: options.internalVersion ?? 1,
signatureLevel: SignatureLevel.SES,
type: EnvelopeType.TEMPLATE,
title,
envelopeItems: {
@@ -164,6 +167,7 @@ export const seedDirectTemplate = async (options: SeedTemplateOptions) => {
id: prefixedId('envelope'),
secondaryId: templateId.formattedTemplateId,
internalVersion: options.internalVersion ?? 1,
signatureLevel: SignatureLevel.SES,
type: EnvelopeType.TEMPLATE,
title,
envelopeItems: {