mirror of
https://github.com/documenso/documenso.git
synced 2026-07-24 08:54:20 +10:00
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:
+54
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user