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
+9 -1
View File
@@ -48,7 +48,7 @@ NEXT_PRIVATE_DATABASE_URL="postgres://documenso:password@127.0.0.1:54320/documen
NEXT_PRIVATE_DIRECT_DATABASE_URL="postgres://documenso:password@127.0.0.1:54320/documenso" NEXT_PRIVATE_DIRECT_DATABASE_URL="postgres://documenso:password@127.0.0.1:54320/documenso"
# [[SIGNING]] # [[SIGNING]]
# The transport to use for document signing. Available options: local (default) | gcloud-hsm # The transport to use for document signing. Available options: local (default) | gcloud-hsm | csc
NEXT_PRIVATE_SIGNING_TRANSPORT="local" NEXT_PRIVATE_SIGNING_TRANSPORT="local"
# OPTIONAL: The passphrase to use for the local file-based signing transport. # OPTIONAL: The passphrase to use for the local file-based signing transport.
NEXT_PRIVATE_SIGNING_PASSPHRASE= NEXT_PRIVATE_SIGNING_PASSPHRASE=
@@ -70,6 +70,14 @@ NEXT_PRIVATE_SIGNING_GCLOUD_HSM_CERT_CHAIN_FILE_PATH=
NEXT_PRIVATE_SIGNING_GCLOUD_HSM_CERT_CHAIN_CONTENTS= NEXT_PRIVATE_SIGNING_GCLOUD_HSM_CERT_CHAIN_CONTENTS=
# OPTIONAL: The Google Secret Manager path to retrieve the certificate for the gcloud-hsm signing transport. # OPTIONAL: The Google Secret Manager path to retrieve the certificate for the gcloud-hsm signing transport.
NEXT_PRIVATE_SIGNING_GCLOUD_HSM_SECRET_MANAGER_CERT_PATH= NEXT_PRIVATE_SIGNING_GCLOUD_HSM_SECRET_MANAGER_CERT_PATH=
# OPTIONAL: The base URL of the Cloud Signature Consortium (CSC) provider for the csc signing transport.
NEXT_PRIVATE_SIGNING_CSC_PROVIDER_BASE_URL=
# OPTIONAL: The OAuth client ID registered with the CSC provider for the csc signing transport.
NEXT_PRIVATE_SIGNING_CSC_OAUTH_CLIENT_ID=
# OPTIONAL: The OAuth client secret registered with the CSC provider for the csc signing transport.
NEXT_PRIVATE_SIGNING_CSC_OAUTH_CLIENT_SECRET=
# OPTIONAL: Default signature level for envelopes created on a CSC instance when the caller doesn't specify one. Available options: AES (default) | QES. Explicit AES/QES requests always pass through unchanged.
NEXT_PRIVATE_SIGNING_CSC_SIGNATURE_LEVEL=
# OPTIONAL: Comma-separated list of timestamp authority URLs for PDF signing (enables LTV and archival timestamps). # OPTIONAL: Comma-separated list of timestamp authority URLs for PDF signing (enables LTV and archival timestamps).
NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY= NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY=
# OPTIONAL: Contact info to embed in PDF signatures. Defaults to the webapp URL. # OPTIONAL: Contact info to embed in PDF signatures. Defaults to the webapp URL.
@@ -186,9 +186,9 @@ Documenso requires a certificate to digitally sign documents.
### Transport Selection ### Transport Selection
| Variable | Description | Default | | Variable | Description | Default |
| -------------------------------- | ---------------------------------------- | ------- | | -------------------------------- | ------------------------------------------------- | ------- |
| `NEXT_PRIVATE_SIGNING_TRANSPORT` | Signing backend: `local` or `gcloud-hsm` | `local` | | `NEXT_PRIVATE_SIGNING_TRANSPORT` | Signing backend: `local`, `gcloud-hsm`, or `csc` | `local` |
### Local Signing ### Local Signing
@@ -210,11 +210,36 @@ Documenso requires a certificate to digitally sign documents.
| `NEXT_PRIVATE_SIGNING_GCLOUD_HSM_CERT_CHAIN_CONTENTS` | Base64-encoded certificate chain | | `NEXT_PRIVATE_SIGNING_GCLOUD_HSM_CERT_CHAIN_CONTENTS` | Base64-encoded certificate chain |
| `NEXT_PRIVATE_SIGNING_GCLOUD_HSM_SECRET_MANAGER_CERT_PATH` | Google Secret Manager path for certificate retrieval | | `NEXT_PRIVATE_SIGNING_GCLOUD_HSM_SECRET_MANAGER_CERT_PATH` | Google Secret Manager path for certificate retrieval |
### Cloud Signature Consortium (CSC)
Routes signing through a third-party Trust Service Provider for Advanced and Qualified Electronic Signatures (AES/QES). Instance-wide; set `NEXT_PRIVATE_SIGNING_TRANSPORT=csc` to enable. See [CSC (AES / QES)](/docs/self-hosting/configuration/signing-certificate/csc-qes) for the full setup walkthrough.
CSC mode requires an active [Enterprise Edition](/docs/policies/enterprise-edition) license. Without a valid license, the instance will refuse to start in `csc` mode.
| Variable | Description | Default |
| ---------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | ------- |
| `NEXT_PRIVATE_SIGNING_CSC_PROVIDER_BASE_URL` | Base URL of the CSC provider's API | |
| `NEXT_PRIVATE_SIGNING_CSC_OAUTH_CLIENT_ID` | OAuth client ID registered with the CSC provider | |
| `NEXT_PRIVATE_SIGNING_CSC_OAUTH_CLIENT_SECRET` | OAuth client secret registered with the CSC provider | |
| `NEXT_PRIVATE_SIGNING_CSC_SIGNATURE_LEVEL` | Default legal tier for new envelopes when the caller doesn't specify one. `AES` or `QES`. Explicit requests pass through. | `AES` |
The OAuth callback URL registered with the CSC provider is fixed at `${NEXT_PUBLIC_WEBAPP_URL}/api/csc/oauth/callback` — register this exact URL with the TSP.
#### Derived Public Variables
The following client-visible variable is **derived automatically** from the private transport at server startup. Do not set it manually — any value set in the environment is overwritten on boot.
| Variable | Derived from | Value |
| ------------------------------------- | -------------------------------------------------- | ------------------------------------------------- |
| `NEXT_PUBLIC_SIGNING_TRANSPORT_IS_CSC` | `NEXT_PRIVATE_SIGNING_TRANSPORT === 'csc'` | `'true'` when CSC mode is active, else `'false'` |
The authoring UI uses this flag to gate features that AES/QES envelopes cannot support (parallel signing, assistant role, dictate next signer). Deriving it from the private transport prevents the client-side flag from drifting from the real server-side configuration.
### Signature Options ### Signature Options
| Variable | Description | Default | | Variable | Description | Default |
| ------------------------------------------- | ----------------------------------------------------------- | ---------- | | ------------------------------------------- | ----------------------------------------------------------- | ---------- |
| `NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY` | Comma-separated timestamp authority URLs for LTV signatures | | | `NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY` | Comma-separated timestamp authority URLs for LTV signatures. Optional for `local` / `gcloud-hsm` (signatures omit the timestamp when unset). **Required** when `NEXT_PRIVATE_SIGNING_TRANSPORT=csc` — the instance refuses to start without it. See [CSC (AES / QES)](/docs/self-hosting/configuration/signing-certificate/csc-qes#timestamp-authority-resolution). | |
| `NEXT_PUBLIC_SIGNING_CONTACT_INFO` | Contact info embedded in PDF signatures | Webapp URL | | `NEXT_PUBLIC_SIGNING_CONTACT_INFO` | Contact info embedded in PDF signatures | Webapp URL |
| `NEXT_PRIVATE_USE_LEGACY_SIGNING_SUBFILTER` | Use `adbe.pkcs7.detached` instead of `ETSI.CAdES.detached` | `false` | | `NEXT_PRIVATE_USE_LEGACY_SIGNING_SUBFILTER` | Use `adbe.pkcs7.detached` instead of `ETSI.CAdES.detached` | `false` |
@@ -0,0 +1,213 @@
---
title: CSC (AES / QES)
description: Configure Cloud Signature Consortium signing for Advanced and Qualified Electronic Signatures via a third-party Trust Service Provider.
---
import { Callout } from 'fumadocs-ui/components/callout';
import { Step, Steps } from 'fumadocs-ui/components/steps';
The `csc` signing transport routes signatures through a third-party Trust Service Provider (TSP) using the [Cloud Signature Consortium API v1.0.4.0](https://cloudsignatureconsortium.org/). Each recipient authenticates directly with the TSP (Strong Customer Authentication) and the TSP returns a per-recipient signature bound to the document hash. Documenso assembles the resulting PAdES signature inside the PDF.
This transport enables **Advanced Electronic Signatures (AES)** and **Qualified Electronic Signatures (QES)** under eIDAS. See [Signature Levels](/docs/compliance/signature-levels) for the legal framework.
<Callout type="warn">
CSC mode is **instance-wide**: one CSC provider per Documenso install. All envelopes created
while the instance runs in `csc` mode use AES or QES. Switching `NEXT_PRIVATE_SIGNING_TRANSPORT`
is a one-way operational migration — see [Switching Transports](#switching-transports).
</Callout>
<Callout type="warn">
CSC mode requires an active [Enterprise Edition](/docs/policies/enterprise-edition) license. The
instance refuses to start in `csc` mode without it.
</Callout>
## Prerequisites
{/* prettier-ignore */}
<Steps>
<Step>
### A TSP account
Establish a relationship with a CSC-compatible Trust Service Provider. The TSP issues qualified or advanced certificates to your signers, holds the private keys in its HSM, and exposes a CSC v1.0.4.0-compliant API.
</Step>
<Step>
### OAuth client credentials
Register Documenso as an OAuth client with the TSP. You will receive a client ID and client secret, and must supply Documenso's callback URL when registering:
```
${NEXT_PUBLIC_WEBAPP_URL}/api/csc/oauth/callback
```
The callback URL is fixed — Documenso derives it from `NEXT_PUBLIC_WEBAPP_URL` and the route mount path. There is no env var to override it; ensuring the registered URL matches your instance's webapp URL exactly is the operator's responsibility.
</Step>
<Step>
### Enterprise Edition license
CSC mode is gated by the `instanceCscSigning` license flag. Without a valid Enterprise license, the transport refuses to start (`CSC_UNLICENSED`).
</Step>
<Step>
### S3 storage (strongly recommended)
CSC produces multiple `DocumentData` rows per envelope item (one per recipient signature, plus the materialised and source rows). Database-backed storage base64-inflates each row by ~33% and is impractical at meaningful PDF sizes. Configure [S3 storage](/docs/self-hosting/configuration/storage) before enabling CSC.
</Step>
</Steps>
## Environment Variables
| Variable | Description | Default |
| ---------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | ------- |
| `NEXT_PRIVATE_SIGNING_TRANSPORT` | Set to `csc` | |
| `NEXT_PRIVATE_SIGNING_CSC_PROVIDER_BASE_URL` | Base URL of the CSC provider's API | |
| `NEXT_PRIVATE_SIGNING_CSC_OAUTH_CLIENT_ID` | OAuth client ID registered with the CSC provider | |
| `NEXT_PRIVATE_SIGNING_CSC_OAUTH_CLIENT_SECRET` | OAuth client secret registered with the CSC provider | |
| `NEXT_PRIVATE_SIGNING_CSC_SIGNATURE_LEVEL` | Default legal tier for new envelopes when the caller does not specify one. `AES` or `QES`. Explicit requests always pass through. | `AES` |
| `NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY` | **Required.** Comma-separated RFC 3161 TSA URLs. Always used for B-LTA archival timestamps at seal time, and also serves as the B-T sign-time fallback when the TSP does not expose `signatures/timestamp`. The instance refuses to start in CSC mode without it. See [Timestamp Authority Resolution](#timestamp-authority-resolution). | |
<Callout type="info">
`NEXT_PUBLIC_SIGNING_TRANSPORT_IS_CSC` is set automatically from
`NEXT_PRIVATE_SIGNING_TRANSPORT` at server startup. Do not set it manually — see
[Environment Variables](/docs/self-hosting/configuration/environment#derived-public-variables).
</Callout>
## Configuration Example
```bash
NEXT_PRIVATE_SIGNING_TRANSPORT=csc
NEXT_PRIVATE_SIGNING_CSC_PROVIDER_BASE_URL=https://api.example-tsp.com/csc/v1
NEXT_PRIVATE_SIGNING_CSC_OAUTH_CLIENT_ID=documenso-prod
NEXT_PRIVATE_SIGNING_CSC_OAUTH_CLIENT_SECRET=...
NEXT_PRIVATE_SIGNING_CSC_SIGNATURE_LEVEL=QES
NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY=http://timestamp.example.com
```
Register `${NEXT_PUBLIC_WEBAPP_URL}/api/csc/oauth/callback` (e.g. `https://sign.example.com/api/csc/oauth/callback`) as the OAuth callback URL with the TSP.
## Default Signature Level
`NEXT_PRIVATE_SIGNING_CSC_SIGNATURE_LEVEL` selects the legal tier applied to envelopes that do not specify one explicitly. It is a default, not a capability gate: callers may still create AES or QES envelopes explicitly regardless of this setting.
| Configured value | Caller passes nothing | Caller passes `AES` | Caller passes `QES` |
| ---------------- | --------------------- | ------------------- | ------------------- |
| `AES` (default) | Envelope is `AES` | Envelope is `AES` | Envelope is `QES` |
| `QES` | Envelope is `QES` | Envelope is `AES` | Envelope is `QES` |
Any value other than `AES` or `QES` causes the instance to refuse to start. This prevents silent qualified-to-advanced downgrades from a typo.
## Timestamp Authority Resolution
AES/QES envelopes use TSA-attested timestamps in two distinct phases. Resolution differs per phase.
### Sign time — PAdES B-T per recipient
Each recipient's CMS embeds a signature timestamp (CMS unsigned attribute) so proven time is bound to the recipient's signature itself. Resolution order:
1. If the TSP advertises `signatures/timestamp` in its `info` response (CSC §11.10), the TSP endpoint is used. The call is authorised with **this recipient's** service-scope bearer token — the same one authorising the `signatures/signHash` call alongside it.
2. Otherwise, the first URL from `NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY` is used (RFC 3161 over HTTP).
Selection is made at boot from the discovered transport, not at runtime; there is no try-then-fall-through. If the chosen source fails, the recipient's sign attempt fails.
### Seal time — PAdES B-LTA archival
The seal-document job emits a single archival `/DocTimeStamp` over the fully-signed envelope (plus DSS for the existing signatures and the timestamp's own chain). This phase is **env-only**: the first URL from `NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY` is always used.
The archival anchor is the operator's long-term trust anchor and SHOULD point at a dedicated qualified archival TSA (e.g. DigiCert) independent of the per-recipient TSP. We deliberately do not fall back to the TSP at seal time: archive longevity should not be coupled to a TSP that may rotate or revoke, and the seal-document job has no recipient context to carry a service-scope bearer.
### Boot-time guard
The instance refuses to start in CSC mode unless `NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY` is set (`CSC_PROVIDER_NO_TSA` at transport construction). The env var is required unconditionally — even when the TSP advertises its own `signatures/timestamp`, seal-time B-LTA archival uses the env TSA. Catching this at boot prevents the failure mode where an envelope signs successfully at B-T and then hangs in `WAITING_FOR_SIGNATURE_COMPLETION` when the seal job throws.
## Switching Transports
`NEXT_PRIVATE_SIGNING_TRANSPORT` is a one-way operational migration. Existing envelopes route per the `signatureLevel` column they were created with — the runtime branching looks at the envelope, not the env var. After a switch:
- Envelopes already at `SES` continue to use the new transport for sealing, but the new transport's signer must produce SES-compatible signatures (only `local` and `gcloud-hsm` qualify).
- Envelopes already at `AES` / `QES` will fail at sign or seal time if the new transport is not `csc`.
Plan migrations during a quiet window with no in-flight envelopes.
## Behavioural Notes
CSC mode changes a number of envelope-authoring behaviours that operators should communicate to users.
### Mutation lock at distribution
For AES/QES envelopes, all authoring routes refuse mutations once the envelope leaves DRAFT. This locks the PDF before any recipient begins Strong Customer Authentication, closing the PDF-swap window that would otherwise allow an owner to replace the PDF between view and sign and break the legal "what you see is what you sign" guarantee.
In practice: edit envelope, recipients, fields, and items freely while DRAFT; once sent, no changes are accepted (including from the API).
### Sequential signing only
Parallel signing produces conflicting incremental updates over the same base PDF, breaking the per-recipient `/ByteRange` invariant. The signing order is forced to `SEQUENTIAL` on AES/QES envelopes — at the schema layer, at send time, and in the UI (the parallel-signing toggle is hidden).
### Assistant role and Dictate Next Signer disabled
Both features modify the recipient set after the envelope is sent, which is incompatible with the AES/QES mutation lock. They are hidden in the UI and rejected at the server schema layer.
### Sidecar PDFs at download
The signed PDF must remain byte-identical to what each recipient's TSP signature authorised — Documenso cannot decorate it after signing. Audit logs and the Certificate of Completion are generated on demand and delivered as separate PDFs:
- `GET /sign/{token}/download` returns the signed PDF only (or a ZIP for multi-item envelopes).
- `GET /sign/{token}/download?version=bundle` returns a ZIP containing the signed PDFs, audit log PDF, and Certificate of Completion.
- The completion email attaches all three.
## Recipient Flow
For context when supporting end users, here is what a recipient experiences on an AES/QES envelope:
1. Opens the email link, lands on the signing page.
2. Documenso redirects to the TSP for Strong Customer Authentication (first visit only; cached for the session lifetime).
3. Fills fields as normal.
4. Clicks Sign → redirected to the TSP for a second authentication round (issues a per-document Signature Activation Data token).
5. Returns to Documenso; the signing call completes within ~15 seconds.
6. Sees the standard completion screen.
If the TSP returns no eligible credentials for the recipient (e.g. they have not enrolled), they see a blocking page directing them to enrol with the TSP and retry.
## Error Codes
CSC-specific error codes surfaced through the standard error channels:
| Code | Meaning | Recovery |
| -------------------------- | ------------------------------------------------ | ----------------------------------------------------------------------- |
| `CSC_UNLICENSED` | License flag absent at transport-create | Operator: enable Enterprise Edition, restart |
| `CSC_PROVIDER_INFO_FAILED` | `info` discovery failed at startup | Operator: check TSP availability and `NEXT_PRIVATE_SIGNING_CSC_PROVIDER_BASE_URL` |
| `CSC_PROVIDER_NO_TSA` | `NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY` is unset | Operator: configure `NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY` |
| `CSC_CREDENTIAL_LIST_EMPTY`| TSP returned no credentials for the user | Recipient: enrol with the TSP |
| `CSC_CERT_INVALID` | Certificate refused at credential validation | Recipient: contact the TSP |
| `CSC_ALGORITHM_REFUSED` | Signature algorithm fails policy | Operator/recipient: TSP does not meet policy (see below) |
| `CSC_SAD_EXPIRED_PRE_SIGN` | Signature Activation Data expired before signing | Recipient: retry from Sign |
| `CSC_TSP_TIMEOUT` | 15-second synchronous timeout reached | Recipient: retry (idempotent — the TSP enforces single-use SAD binding) |
| `CSC_EMBED_FAILED` | Sign-time digest diverged from prep capture | Recipient: retry from Sign |
| `CSC_BASE_DOCUMENT_MUTATED`| Document data changed between prep and sign | Operator: investigate (structural guard violation) |
| `CSC_INSTANCE_MODE_MISMATCH`| Envelope created with wrong level for transport | Caller: use a level matching the instance transport |
| `CSC_REQUEST_FAILED` | TSP HTTP transport failure — network error, non-2xx, or malformed response | Operator: check TSP availability; carries the TSP HTTP status and error in the message |
## Algorithm Policy
Documenso refuses TSP credentials that do not meet the following minimums, at the OAuth callback boundary and again at sign time:
| Class | Allowed | Refused |
| ----- | ---------------------------------- | ------------------------------------------------------ |
| RSA | `key.len >= 2048` | Missing `key.len`, `key.len < 2048` |
| ECDSA | P-256, P-384, P-521 | Missing `key.curve`, P-192, P-224, other curves |
| Hash | SHA-256, SHA-384, SHA-512 | SHA-1, MD5 |
| Other | — | DSA |
This is the union of CSC v1.0.4.0 §11.5 requirements and current cryptographic guidance.
## Related
- [Signature Levels](/docs/compliance/signature-levels) — AES / QES legal framework
- [Signing Certificate](/docs/self-hosting/configuration/signing-certificate) — overview of all signing transports
- [Environment Variables](/docs/self-hosting/configuration/environment) — full env reference
- [Enterprise Edition](/docs/policies/enterprise-edition) — license requirements
@@ -24,6 +24,11 @@ Self-hosted Documenso instances require a signing certificate. You can generate
description="Hardware-based key protection with Google Cloud KMS." description="Hardware-based key protection with Google Cloud KMS."
href="/docs/self-hosting/configuration/signing-certificate/google-cloud-hsm" href="/docs/self-hosting/configuration/signing-certificate/google-cloud-hsm"
/> />
<Card
title="CSC (AES / QES)"
description="Route signing through a third-party Trust Service Provider for Advanced and Qualified Electronic Signatures."
href="/docs/self-hosting/configuration/signing-certificate/csc-qes"
/>
<Card <Card
title="Timestamp Server" title="Timestamp Server"
description="Add trusted timestamps and customise signature appearance." description="Add trusted timestamps and customise signature appearance."
@@ -38,7 +43,7 @@ Self-hosted Documenso instances require a signing certificate. You can generate
## Certificate Options ## Certificate Options
<Tabs items={['Self-Signed', 'CA-Issued', 'Google Cloud HSM']}> <Tabs items={['Self-Signed', 'CA-Issued', 'Google Cloud HSM', 'CSC (AES / QES)']}>
<Tab value="Self-Signed"> <Tab value="Self-Signed">
A self-signed certificate is sufficient for most use cases where your industry has no special signing regulations. A self-signed certificate is sufficient for most use cases where your industry has no special signing regulations.
@@ -79,6 +84,18 @@ For organisations requiring hardware-based key protection, Documenso supports Go
See [Google Cloud HSM](/docs/self-hosting/configuration/signing-certificate/google-cloud-hsm) for setup instructions. See [Google Cloud HSM](/docs/self-hosting/configuration/signing-certificate/google-cloud-hsm) for setup instructions.
</Tab>
<Tab value="CSC (AES / QES)">
For Advanced and Qualified Electronic Signatures under eIDAS, Documenso integrates with third-party Trust Service Providers via the Cloud Signature Consortium API. Each recipient authenticates directly with the TSP, which holds the private key and issues the signature.
- Per-recipient identity verification by an accredited TSP
- Legally equivalent to a handwritten signature within the EU (QES)
- Requires an [Enterprise Edition](/docs/policies/enterprise-edition) license
- Instance-wide setting; one CSC provider per Documenso install
See [CSC (AES / QES)](/docs/self-hosting/configuration/signing-certificate/csc-qes) for setup instructions.
</Tab> </Tab>
</Tabs> </Tabs>
@@ -1,4 +1,4 @@
{ {
"title": "Signing Certificate", "title": "Signing Certificate",
"pages": ["...index", "local", "google-cloud-hsm", "timestamp-server", "troubleshooting"] "pages": ["...index", "local", "google-cloud-hsm", "csc-qes", "timestamp-server", "troubleshooting"]
} }
@@ -13,7 +13,7 @@ import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react'; import { useLingui } from '@lingui/react';
import type { Field, Recipient } from '@prisma/client'; import type { Field, Recipient } from '@prisma/client';
import { useState } from 'react'; import { useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router'; import { useSearchParams } from 'react-router';
import { useRequiredDocumentSigningAuthContext } from '~/components/general/document-signing/document-signing-auth-provider'; import { useRequiredDocumentSigningAuthContext } from '~/components/general/document-signing/document-signing-auth-provider';
import { useRequiredDocumentSigningContext } from '~/components/general/document-signing/document-signing-provider'; import { useRequiredDocumentSigningContext } from '~/components/general/document-signing/document-signing-provider';
@@ -37,7 +37,6 @@ export const DirectTemplatePageView = ({
directTemplateRecipient, directTemplateRecipient,
directTemplateToken, directTemplateToken,
}: DirectTemplatePageViewProps) => { }: DirectTemplatePageViewProps) => {
const navigate = useNavigate();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const { _ } = useLingui(); const { _ } = useLingui();
@@ -119,7 +118,7 @@ export const DirectTemplatePageView = ({
if (redirectUrl) { if (redirectUrl) {
window.location.href = redirectUrl; window.location.href = redirectUrl;
} else { } else {
await navigate(`/sign/${token}/complete`); window.location.href = `/sign/${token}/complete`;
} }
} catch (err) { } catch (err) {
const error = AppError.parseError(err); const error = AppError.parseError(err);
@@ -0,0 +1,68 @@
import { AppErrorCode } from '@documenso/lib/errors/app-error';
import { Button } from '@documenso/ui/primitives/button';
import { Trans } from '@lingui/react/macro';
import { AlertTriangleIcon } from 'lucide-react';
export type CscRecipientBlockedPageProps = {
code: string;
recipientToken: string;
};
/**
* Terminal page rendered when the service-scope CSC OAuth callback surfaces a
* hard error the recipient can't resolve themselves (empty credential list,
* invalid cert, refused algorithm). The blocking-error cookie is read +
* cleared by the loader; this page only renders the message + retry CTA.
*
* The retry link kicks a fresh service-scope OAuth round-trip — useful when
* the TSP-side issue is transient (e.g. the recipient's admin has since
* provisioned a credential).
*/
export const CscRecipientBlockedPage = ({ code, recipientToken }: CscRecipientBlockedPageProps) => {
const retryUrl = `/api/csc/oauth/authorize?scope=service&token=${encodeURIComponent(recipientToken)}`;
return (
<div className="-mx-4 flex max-w-[100vw] flex-col items-center overflow-x-hidden px-4 pt-16 md:-mx-8 md:px-8 lg:pt-16 xl:pt-24">
<AlertTriangleIcon className="h-12 w-12 text-destructive" />
<h2 className="mt-6 max-w-[35ch] text-center font-semibold text-2xl leading-normal md:text-3xl lg:text-4xl">
{code === AppErrorCode.CSC_CREDENTIAL_LIST_EMPTY ? (
<Trans>No signing credentials available</Trans>
) : code === AppErrorCode.CSC_CERT_INVALID ? (
<Trans>Signing certificate is invalid</Trans>
) : code === AppErrorCode.CSC_ALGORITHM_REFUSED ? (
<Trans>Signing algorithm is not supported</Trans>
) : (
<Trans>Unable to start the signing flow</Trans>
)}
</h2>
<p className="mt-2.5 max-w-[60ch] text-center font-medium text-muted-foreground/60 text-sm md:text-base">
{code === AppErrorCode.CSC_CREDENTIAL_LIST_EMPTY ? (
<Trans>
Your signing provider returned no usable credentials for this account. Contact your administrator or signing
provider for assistance.
</Trans>
) : code === AppErrorCode.CSC_CERT_INVALID ? (
<Trans>
Your signing certificate is invalid, expired, or missing a required key. Contact your administrator or
signing provider for assistance.
</Trans>
) : code === AppErrorCode.CSC_ALGORITHM_REFUSED ? (
<Trans>
Your signing provider does not advertise a signing algorithm this document accepts. Contact your
administrator or signing provider for assistance.
</Trans>
) : (
<Trans>Something went wrong while preparing the remote signature. Please try again.</Trans>
)}
</p>
<Button asChild className="mt-8">
<a href={retryUrl}>
<Trans>Try again</Trans>
</a>
</Button>
</div>
);
};
@@ -0,0 +1,105 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import { Trans } from '@lingui/react/macro';
import { AlertTriangleIcon, Loader2Icon } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
export type CscRecipientSigningInProgressPageProps = {
sessionId: string;
recipientToken: string;
};
/**
* Rendered when the credential-scope OAuth callback has attached a SAD to the
* server-side `CscSession` and set the `csc_sad_session` cookie. The page
* auto-fires `enterprise.csc.signEnvelope` on mount and navigates to the
* completion page on success. On failure, it surfaces an error message and
* a retry CTA pointing at a fresh credential-scope OAuth round-trip.
*/
export const CscRecipientSigningInProgressPage = ({
sessionId,
recipientToken,
}: CscRecipientSigningInProgressPageProps) => {
const { mutateAsync: signEnvelope } = trpc.enterprise.csc.signEnvelope.useMutation();
const [error, setError] = useState<string | null>(null);
// Ref rather than state for the fire-once guard. Refs mutate synchronously,
// so React StrictMode's double-invoke of the effect sees the updated value
// on the second pass and short-circuits. A useState guard would still let
// the second effect fire because the queued setState from the first run
// hasn't been committed yet when the second one reads it — that double-fire
// races two signEnvelope calls; whichever loses sees the SAD already
// consumed and flashes "Signing failed" before the winning call's
// navigation kicks in.
const hasFiredRef = useRef(false);
useEffect(() => {
if (hasFiredRef.current) {
return;
}
hasFiredRef.current = true;
const run = async () => {
try {
await signEnvelope({ sessionId, recipientToken });
window.location.href = `/sign/${recipientToken}/complete`;
} catch (err) {
const parsed = AppError.parseError(err);
setError(parsed.code || AppErrorCode.UNKNOWN_ERROR);
}
};
void run();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const retryUrl = `/api/csc/oauth/authorize?scope=credential&session=${encodeURIComponent(sessionId)}`;
return (
<div className="-mx-4 flex max-w-[100vw] flex-col items-center overflow-x-hidden px-4 pt-16 md:-mx-8 md:px-8 lg:pt-16 xl:pt-24">
{error ? (
<>
<AlertTriangleIcon className="h-12 w-12 text-destructive" />
<h2 className="mt-6 max-w-[35ch] text-center font-semibold text-2xl leading-normal md:text-3xl lg:text-4xl">
<Trans>Signing failed</Trans>
</h2>
<p className="mt-2.5 max-w-[60ch] text-center font-medium text-muted-foreground/60 text-sm md:text-base">
{error === AppErrorCode.CSC_TSP_TIMEOUT ? (
<Trans>The signing provider did not respond in time. Please retry.</Trans>
) : error === AppErrorCode.CSC_SAD_EXPIRED_PRE_SIGN ? (
<Trans>
Your signing authorisation expired before the signature could be applied. Please reauthorise to retry.
</Trans>
) : (
<Trans>Something went wrong while applying your signature. Please retry.</Trans>
)}
</p>
<Button asChild className="mt-8">
<a href={retryUrl}>
<Trans>Reauthorise and retry</Trans>
</a>
</Button>
</>
) : (
<>
<Loader2Icon className="h-12 w-12 animate-spin text-primary" />
<h2 className="mt-6 max-w-[35ch] text-center font-semibold text-2xl leading-normal md:text-3xl lg:text-4xl">
<Trans>Applying your signature</Trans>
</h2>
<p className="mt-2.5 max-w-[60ch] text-center font-medium text-muted-foreground/60 text-sm md:text-base">
<Trans>Please don't close this tab. The signing provider is finalising your signature.</Trans>
</p>
</>
)}
</div>
);
};
@@ -27,7 +27,6 @@ import type { Field } from '@prisma/client';
import { FieldType, RecipientRole } from '@prisma/client'; import { FieldType, RecipientRole } from '@prisma/client';
import { LucideChevronDown, LucideChevronUp } from 'lucide-react'; import { LucideChevronDown, LucideChevronUp } from 'lucide-react';
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { useNavigate } from 'react-router';
import { match, P } from 'ts-pattern'; import { match, P } from 'ts-pattern';
import { DocumentSigningAttachmentsPopover } from '~/components/general/document-signing/document-signing-attachments-popover'; import { DocumentSigningAttachmentsPopover } from '~/components/general/document-signing/document-signing-attachments-popover';
@@ -84,7 +83,6 @@ export const DocumentSigningPageViewV1 = ({
? authUser.twoFactorEnabled && authUser.email === recipient.email ? authUser.twoFactorEnabled && authUser.email === recipient.email
: false; : false;
const navigate = useNavigate();
const analytics = useAnalytics(); const analytics = useAnalytics();
const [selectedSignerId, setSelectedSignerId] = useState<number | null>(allRecipients?.[0]?.id); const [selectedSignerId, setSelectedSignerId] = useState<number | null>(allRecipients?.[0]?.id);
@@ -129,7 +127,7 @@ export const DocumentSigningPageViewV1 = ({
if (documentMeta?.redirectUrl) { if (documentMeta?.redirectUrl) {
window.location.href = documentMeta.redirectUrl; window.location.href = documentMeta.redirectUrl;
} else { } else {
await navigate(`/sign/${recipient.token}/complete`); window.location.href = `/sign/${recipient.token}/complete`;
} }
}; };
@@ -17,7 +17,7 @@ import { msg } from '@lingui/core/macro';
import { Trans, useLingui } from '@lingui/react/macro'; import { Trans, useLingui } from '@lingui/react/macro';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { useNavigate, useSearchParams } from 'react-router'; import { useSearchParams } from 'react-router';
import { z } from 'zod'; import { z } from 'zod';
const ZRejectDocumentFormSchema = z.object({ const ZRejectDocumentFormSchema = z.object({
@@ -41,7 +41,6 @@ export function DocumentSigningRejectDialog({
}: DocumentSigningRejectDialogProps) { }: DocumentSigningRejectDialogProps) {
const { t } = useLingui(); const { t } = useLingui();
const { toast } = useToast(); const { toast } = useToast();
const navigate = useNavigate();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
@@ -74,7 +73,7 @@ export function DocumentSigningRejectDialog({
if (onRejected) { if (onRejected) {
await onRejected(reason); await onRejected(reason);
} else { } else {
await navigate(`/sign/${token}/rejected`); window.location.href = `/sign/${token}/rejected`;
} }
} catch (err) { } catch (err) {
toast({ toast({
@@ -38,7 +38,7 @@ export const DocumentPageViewButton = ({ envelope }: DocumentPageViewButtonProps
}) })
.with({ isRecipient: true, isPending: true, isSigned: false }, () => ( .with({ isRecipient: true, isPending: true, isSigned: false }, () => (
<Button className="w-full" asChild> <Button className="w-full" asChild>
<Link to={`/sign/${recipient?.token}`}> <a href={`/sign/${recipient?.token}`}>
{match(role) {match(role)
.with(RecipientRole.SIGNER, () => ( .with(RecipientRole.SIGNER, () => (
<> <>
@@ -58,7 +58,7 @@ export const DocumentPageViewButton = ({ envelope }: DocumentPageViewButtonProps
<Trans>View</Trans> <Trans>View</Trans>
</> </>
))} ))}
</Link> </a>
</Button> </Button>
)) ))
.with({ isComplete: false }, () => ( .with({ isComplete: false }, () => (
@@ -89,7 +89,7 @@ export const EnvelopeSignerCompleteDialog = () => {
recipientDetails?: { name: string; email: string }, recipientDetails?: { name: string; email: string },
) => { ) => {
try { try {
await completeDocument({ const result = await completeDocument({
token: recipient.token, token: recipient.token,
documentId: mapSecondaryIdToDocumentId(envelope.secondaryId), documentId: mapSecondaryIdToDocumentId(envelope.secondaryId),
accessAuthOptions, accessAuthOptions,
@@ -97,6 +97,15 @@ export const EnvelopeSignerCompleteDialog = () => {
...(nextSigner?.email && nextSigner?.name ? { nextSigner } : {}), ...(nextSigner?.email && nextSigner?.name ? { nextSigner } : {}),
}); });
// TSP envelopes can't be completed via the SES path; the mutation returns
// a credential-scope OAuth URL the recipient must follow to acquire a SAD
// before the sync sign mutation can run. Short-circuit here so the
// analytics / completion handlers don't run with a still-unsigned doc.
if (result.status === 'REDIRECT') {
window.location.href = result.redirectUrl;
return;
}
analytics.capture('App: Recipient has completed signing', { analytics.capture('App: Recipient has completed signing', {
signerId: recipient.id, signerId: recipient.id,
documentId: envelope.id, documentId: envelope.id,
@@ -119,7 +128,7 @@ export const EnvelopeSignerCompleteDialog = () => {
if (envelope.documentMeta.redirectUrl) { if (envelope.documentMeta.redirectUrl) {
window.location.href = envelope.documentMeta.redirectUrl; window.location.href = envelope.documentMeta.redirectUrl;
} else { } else {
await navigate(`/sign/${recipient.token}/complete`); window.location.href = `/sign/${recipient.token}/complete`;
} }
} catch (err) { } catch (err) {
const error = AppError.parseError(err); const error = AppError.parseError(err);
@@ -197,7 +206,7 @@ export const EnvelopeSignerCompleteDialog = () => {
if (redirectUrl) { if (redirectUrl) {
window.location.href = redirectUrl; window.location.href = redirectUrl;
} else { } else {
await navigate(`/sign/${token}/complete`); window.location.href = `/sign/${token}/complete`;
} }
} catch (err) { } catch (err) {
console.log('err', err); console.log('err', err);
@@ -66,7 +66,7 @@ export const DocumentsTableActionButton = ({ row }: DocumentsTableActionButtonPr
)) ))
.with({ isRecipient: true, isPending: true, isSigned: false }, () => ( .with({ isRecipient: true, isPending: true, isSigned: false }, () => (
<Button className="w-32" asChild> <Button className="w-32" asChild>
<Link to={`/sign/${recipient?.token}`}> <a href={`/sign/${recipient?.token}`}>
{match(role) {match(role)
.with(RecipientRole.SIGNER, () => ( .with(RecipientRole.SIGNER, () => (
<> <>
@@ -86,7 +86,7 @@ export const DocumentsTableActionButton = ({ row }: DocumentsTableActionButtonPr
<Trans>View</Trans> <Trans>View</Trans>
</> </>
))} ))}
</Link> </a>
</Button> </Button>
)) ))
.with({ isPending: true, isSigned: true }, () => ( .with({ isPending: true, isSigned: true }, () => (
@@ -105,7 +105,7 @@ export const DocumentsTableActionDropdown = ({ row, onMoveDocument }: DocumentsT
recipient?.role !== RecipientRole.CC && recipient?.role !== RecipientRole.CC &&
recipient?.role !== RecipientRole.ASSISTANT && ( recipient?.role !== RecipientRole.ASSISTANT && (
<DropdownMenuItem disabled={!recipient || isComplete} asChild> <DropdownMenuItem disabled={!recipient || isComplete} asChild>
<Link to={`/sign/${recipient?.token}`}> <a href={`/sign/${recipient?.token}`}>
{recipient?.role === RecipientRole.VIEWER && ( {recipient?.role === RecipientRole.VIEWER && (
<> <>
<EyeIcon className="mr-2 h-4 w-4" /> <EyeIcon className="mr-2 h-4 w-4" />
@@ -126,7 +126,7 @@ export const DocumentsTableActionDropdown = ({ row, onMoveDocument }: DocumentsT
<Trans>Approve</Trans> <Trans>Approve</Trans>
</> </>
)} )}
</Link> </a>
</DropdownMenuItem> </DropdownMenuItem>
)} )}
@@ -17,7 +17,7 @@ import { DocumentStatus as DocumentStatusEnum, RecipientRole, SigningStatus } fr
import { CheckCircleIcon, DownloadIcon, EyeIcon, Loader, PencilIcon } from 'lucide-react'; import { CheckCircleIcon, DownloadIcon, EyeIcon, Loader, PencilIcon } from 'lucide-react';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { useMemo, useTransition } from 'react'; import { useMemo, useTransition } from 'react';
import { Link, useSearchParams } from 'react-router'; import { useSearchParams } from 'react-router';
import { match } from 'ts-pattern'; import { match } from 'ts-pattern';
import { DocumentStatus } from '~/components/general/document/document-status'; import { DocumentStatus } from '~/components/general/document/document-status';
@@ -200,7 +200,7 @@ export const InboxTableActionButton = ({ row }: InboxTableActionButtonProps) =>
}) })
.with({ isPending: true, isSigned: false }, () => ( .with({ isPending: true, isSigned: false }, () => (
<Button className="w-32" asChild> <Button className="w-32" asChild>
<Link to={`/sign/${recipient?.token}`}> <a href={`/sign/${recipient?.token}`}>
{match(role) {match(role)
.with(RecipientRole.SIGNER, () => ( .with(RecipientRole.SIGNER, () => (
<> <>
@@ -220,7 +220,7 @@ export const InboxTableActionButton = ({ row }: InboxTableActionButtonProps) =>
<Trans>View</Trans> <Trans>View</Trans>
</> </>
))} ))}
</Link> </a>
</Button> </Button>
)) ))
.with({ isPending: true, isSigned: true }, () => ( .with({ isPending: true, isSigned: true }, () => (
@@ -1,7 +1,14 @@
import signingCelebration from '@documenso/assets/images/signing-celebration.png'; import signingCelebration from '@documenso/assets/images/signing-celebration.png';
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session'; import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
import {
buildClearCscBlockingErrorCookieHeader,
readCscBlockingErrorFromRequest,
} from '@documenso/ee/server-only/signing/csc/cookies/blocking-error-cookie';
import { readCscSadSessionFromRequest } from '@documenso/ee/server-only/signing/csc/cookies/sad-session-cookie';
import { readCscServiceSessionFromRequest } from '@documenso/ee/server-only/signing/csc/cookies/service-session-cookie';
import { EnvelopeRenderProvider } from '@documenso/lib/client-only/providers/envelope-render-provider'; import { EnvelopeRenderProvider } from '@documenso/lib/client-only/providers/envelope-render-provider';
import { useOptionalSession } from '@documenso/lib/client-only/providers/session'; import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
import { IS_INSTANCE_CSC_MODE } from '@documenso/lib/constants/app';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { loadRecipientBrandingByTeamId } from '@documenso/lib/server-only/branding/load-recipient-branding'; import { loadRecipientBrandingByTeamId } from '@documenso/lib/server-only/branding/load-recipient-branding';
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token'; import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
@@ -18,6 +25,7 @@ import { getRecipientsForAssistant } from '@documenso/lib/server-only/recipient/
import { getTeamSettings } from '@documenso/lib/server-only/team/get-team-settings'; import { getTeamSettings } from '@documenso/lib/server-only/team/get-team-settings';
import { getUserByEmail } from '@documenso/lib/server-only/user/get-user-by-email'; import { getUserByEmail } from '@documenso/lib/server-only/user/get-user-by-email';
import { DocumentAccessAuth } from '@documenso/lib/types/document-auth'; import { DocumentAccessAuth } from '@documenso/lib/types/document-auth';
import { isTspEnvelope } from '@documenso/lib/types/signature-level';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth'; import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { isRecipientExpired } from '@documenso/lib/utils/recipients'; import { isRecipientExpired } from '@documenso/lib/utils/recipients';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
@@ -30,6 +38,8 @@ import { getOptionalLoaderContext } from 'server/utils/get-loader-session';
import { match } from 'ts-pattern'; import { match } from 'ts-pattern';
import { Header as AuthenticatedHeader } from '~/components/general/app-header'; import { Header as AuthenticatedHeader } from '~/components/general/app-header';
import { CscRecipientBlockedPage } from '~/components/general/document-signing/csc-recipient-blocked-page';
import { CscRecipientSigningInProgressPage } from '~/components/general/document-signing/csc-recipient-signing-in-progress-page';
import { DocumentSigningAuthPageView } from '~/components/general/document-signing/document-signing-auth-page'; import { DocumentSigningAuthPageView } from '~/components/general/document-signing/document-signing-auth-page';
import { DocumentSigningAuthProvider } from '~/components/general/document-signing/document-signing-auth-provider'; import { DocumentSigningAuthProvider } from '~/components/general/document-signing/document-signing-auth-provider';
import { DocumentSigningPageViewV1 } from '~/components/general/document-signing/document-signing-page-view-v1'; import { DocumentSigningPageViewV1 } from '~/components/general/document-signing/document-signing-page-view-v1';
@@ -257,6 +267,58 @@ const handleV2Loader = async ({ params, request }: Route.LoaderArgs) => {
recipientAccessAuth: derivedRecipientAccessAuth, recipientAccessAuth: derivedRecipientAccessAuth,
}).catch(() => null); }).catch(() => null);
// CSC / TSP routing. TSP envelopes have three terminal recipient-page
// states beyond the normal signing UI:
// 1. `blocked` — service-scope OAuth returned a hard error (set by the
// callback as a one-shot `csc_blocking_error` cookie).
// 2. `signing-in-progress` — credential-scope OAuth completed, SAD is
// attached server-side, page auto-fires the sync sign mutation.
// 3. pre-auth — no service token yet, kick the recipient into
// service-scope OAuth.
// The fourth state (service session valid, no SAD, no blocking error) falls
// through to the normal signing UI.
if (IS_INSTANCE_CSC_MODE() && isTspEnvelope(envelope)) {
const blockingError = await readCscBlockingErrorFromRequest(request);
if (blockingError && blockingError.recipientToken === token) {
return {
isDocumentAccessValid: true,
envelopeForSigning,
csc: { state: 'blocked', code: blockingError.code } as const,
responseHeaders: { 'Set-Cookie': buildClearCscBlockingErrorCookieHeader() },
} as const;
}
const sadSessionId = await readCscSadSessionFromRequest(request);
if (sadSessionId) {
const cscSession = await prisma.cscSession.findUnique({
where: { id: sadSessionId },
});
const isSadSessionValid =
cscSession !== null &&
cscSession.recipientId === recipient.id &&
cscSession.encryptedSad !== null &&
cscSession.sadExpiresAt !== null &&
cscSession.sadExpiresAt > new Date();
if (isSadSessionValid) {
return {
isDocumentAccessValid: true,
envelopeForSigning,
csc: { state: 'signing-in-progress', sessionId: sadSessionId } as const,
} as const;
}
}
const serviceSessionToken = await readCscServiceSessionFromRequest(request);
if (serviceSessionToken !== token) {
throw redirect(`/api/csc/oauth/authorize?scope=service&token=${encodeURIComponent(token)}`);
}
}
return { return {
isDocumentAccessValid: true, isDocumentAccessValid: true,
envelopeForSigning, envelopeForSigning,
@@ -296,11 +358,22 @@ export async function loader(loaderArgs: Route.LoaderArgs) {
if (foundRecipient.envelope.internalVersion === 2) { if (foundRecipient.envelope.internalVersion === 2) {
const payloadV2 = await handleV2Loader(loaderArgs); const payloadV2 = await handleV2Loader(loaderArgs);
return superLoaderJson({ // V2 payload may carry a one-shot `Set-Cookie` header (used to clear the
version: 2, // CSC blocking-error cookie after the loader reads it). Forward it via
payload: payloadV2, // the `superLoaderJson` response init so the browser actually applies the
branding, // header. The field stays on the payload — it's just a `Max-Age=0` clear
} as const); // directive, not sensitive — and isn't read by any consumer.
const responseHeaders =
'responseHeaders' in payloadV2 && payloadV2.responseHeaders ? payloadV2.responseHeaders : undefined;
return superLoaderJson(
{
version: 2,
payload: payloadV2,
branding,
} as const,
responseHeaders ? { headers: responseHeaders } : undefined,
);
} }
const payloadV1 = await handleV1Loader(loaderArgs); const payloadV1 = await handleV1Loader(loaderArgs);
@@ -430,6 +503,19 @@ const SigningPageV2 = ({ data }: { data: Awaited<ReturnType<typeof handleV2Loade
return <DocumentSigningAuthPageView email={data.recipientEmail} emailHasAccount={!!data.recipientHasAccount} />; return <DocumentSigningAuthPageView email={data.recipientEmail} emailHasAccount={!!data.recipientHasAccount} />;
} }
if ('csc' in data && data.csc?.state === 'blocked') {
return <CscRecipientBlockedPage code={data.csc.code} recipientToken={data.envelopeForSigning.recipient.token} />;
}
if ('csc' in data && data.csc?.state === 'signing-in-progress') {
return (
<CscRecipientSigningInProgressPage
sessionId={data.csc.sessionId}
recipientToken={data.envelopeForSigning.recipient.token}
/>
);
}
const { envelope, recipientSignature, recipient } = data.envelopeForSigning; const { envelope, recipientSignature, recipient } = data.envelopeForSigning;
if (envelope.deletedAt || envelope.status === DocumentStatus.REJECTED) { if (envelope.deletedAt || envelope.status === DocumentStatus.REJECTED) {
+4
View File
@@ -1,5 +1,6 @@
import { tsRestHonoApp } from '@documenso/api/hono'; import { tsRestHonoApp } from '@documenso/api/hono';
import { auth } from '@documenso/auth/server'; import { auth } from '@documenso/auth/server';
import { csc } from '@documenso/ee/server-only/signing/csc/hono';
import { jobsClient } from '@documenso/lib/jobs/client'; import { jobsClient } from '@documenso/lib/jobs/client';
import { LicenseClient } from '@documenso/lib/server-only/license/license-client'; import { LicenseClient } from '@documenso/lib/server-only/license/license-client';
import { createRateLimitMiddleware } from '@documenso/lib/server-only/rate-limit/rate-limit-middleware'; import { createRateLimitMiddleware } from '@documenso/lib/server-only/rate-limit/rate-limit-middleware';
@@ -111,6 +112,9 @@ app.route('/api/files', filesRoute);
app.use('/api/ai/*', aiRateLimitMiddleware); app.use('/api/ai/*', aiRateLimitMiddleware);
app.route('/api/ai', aiRoute); app.route('/api/ai', aiRoute);
// CSC OAuth routes (mounted from @documenso/ee).
app.route('/api/csc', csc);
// API servers. // API servers.
app.route('/api/v1', tsRestHonoApp); app.route('/api/v1', tsRestHonoApp);
app.use('/api/jobs/*', jobsClient.getApiHandler()); app.use('/api/jobs/*', jobsClient.getApiHandler());
+10 -8
View File
@@ -15,7 +15,7 @@
"dependencies": { "dependencies": {
"@ai-sdk/google-vertex": "3.0.81", "@ai-sdk/google-vertex": "3.0.81",
"@documenso/prisma": "*", "@documenso/prisma": "*",
"@libpdf/core": "^0.3.6", "@libpdf/core": "^0.4.0",
"@lingui/conf": "^5.6.0", "@lingui/conf": "^5.6.0",
"@lingui/core": "^5.6.0", "@lingui/core": "^5.6.0",
"@marsidev/react-turnstile": "^1.5.0", "@marsidev/react-turnstile": "^1.5.0",
@@ -4661,16 +4661,16 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@libpdf/core": { "node_modules/@libpdf/core": {
"version": "0.3.6", "version": "0.4.0",
"resolved": "https://registry.npmjs.org/@libpdf/core/-/core-0.3.6.tgz", "resolved": "https://registry.npmjs.org/@libpdf/core/-/core-0.4.0.tgz",
"integrity": "sha512-VzRUXaDq+M9qrroKiipCgePK2mwKM3M6DY7B0yfXnxD4aYnUxD/nUtkcsHCBUUnJpkX9rWikdEhYa5vU8ZlReg==", "integrity": "sha512-G9nZRjf9DGDJaS/C23YWogk8akPM7O/6HfMslxVsKTKRbbbb+0szpQIetcGGUGRu7KtmBDmGDWCgz//DXSmq8A==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@noble/ciphers": "^2.2.0", "@noble/ciphers": "^2.2.0",
"@noble/hashes": "^2.2.0", "@noble/hashes": "^2.2.0",
"@scure/base": "^2.2.0", "@scure/base": "^2.2.0",
"asn1js": "^3.0.10", "asn1js": "^3.0.10",
"lru-cache": "^11.4.0", "lru-cache": "^11.5.1",
"pako": "^2.1.0", "pako": "^2.1.0",
"pkijs": "^3.4.0" "pkijs": "^3.4.0"
}, },
@@ -4724,9 +4724,9 @@
} }
}, },
"node_modules/@libpdf/core/node_modules/lru-cache": { "node_modules/@libpdf/core/node_modules/lru-cache": {
"version": "11.4.0", "version": "11.5.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.4.0.tgz", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz",
"integrity": "sha512-W+R+kFL4HgVxONq2bhXPi3bGpzGe/yEhVOp233qw9wCRtgncJ15P3bC+e4zZMu4Cq7d+WAJjXGW0uUkifhcatA==", "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==",
"license": "BlueOak-1.0.0", "license": "BlueOak-1.0.0",
"engines": { "engines": {
"node": "20 || >=22" "node": "20 || >=22"
@@ -30593,6 +30593,8 @@
"@aws-sdk/client-sesv2": "^3.998.0", "@aws-sdk/client-sesv2": "^3.998.0",
"@documenso/lib": "*", "@documenso/lib": "*",
"@documenso/prisma": "*", "@documenso/prisma": "*",
"arctic": "^3.7.0",
"hono": "^4.12.14",
"luxon": "^3.7.2", "luxon": "^3.7.2",
"react": "^18", "react": "^18",
"ts-pattern": "^5.9.0", "ts-pattern": "^5.9.0",
+1 -1
View File
@@ -88,7 +88,7 @@
"dependencies": { "dependencies": {
"@ai-sdk/google-vertex": "3.0.81", "@ai-sdk/google-vertex": "3.0.81",
"@documenso/prisma": "*", "@documenso/prisma": "*",
"@libpdf/core": "^0.3.6", "@libpdf/core": "^0.4.0",
"@lingui/conf": "^5.6.0", "@lingui/conf": "^5.6.0",
"@lingui/core": "^5.6.0", "@lingui/core": "^5.6.0",
"@prisma/extension-read-replicas": "^0.4.1", "@prisma/extension-read-replicas": "^0.4.1",
+2
View File
@@ -16,6 +16,8 @@
"@aws-sdk/client-sesv2": "^3.998.0", "@aws-sdk/client-sesv2": "^3.998.0",
"@documenso/lib": "*", "@documenso/lib": "*",
"@documenso/prisma": "*", "@documenso/prisma": "*",
"arctic": "^3.7.0",
"hono": "^4.12.14",
"luxon": "^3.7.2", "luxon": "^3.7.2",
"react": "^18", "react": "^18",
"ts-pattern": "^5.9.0", "ts-pattern": "^5.9.0",
@@ -0,0 +1,347 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { TCscCredentialsInfoResponse } from './client/types';
/**
* CSC QES V1 algorithm policy.
*
* Single OID-to-algorithm map + single helper that:
* - validates cert state (status, validity window) → `CSC_CERT_INVALID`,
* - validates the credential's key + algorithm against the spec's policy
* table (RSA ≥2048, ECDSA P-256/384/521, SHA-256/384/512) →
* `CSC_ALGORITHM_REFUSED`,
* - resolves a concrete `(signAlgo, hashAlgo)` OID pair for §11.9.
*
* Called at the service-scope OAuth callback (validation boundary) and
* re-called at sign time as a defence-in-depth pre-check. Persisted fields
* (`keyType` / `keyLenBits` / `digestAlgorithm` / `signAlgoOid`) round-trip
* through `CscCredential`.
*/
export type CscKeyType = 'RSA' | 'ECDSA';
export type CscDigest = 'SHA-256' | 'SHA-384' | 'SHA-512';
export type CscEcdsaCurve = 'P-256' | 'P-384' | 'P-521';
export type CscAlgorithmPolicy = {
keyType: CscKeyType;
keyLenBits: number;
digestAlgorithm: CscDigest;
/** OID for `signatures/signHash.signAlgo` + persisted on `CscCredential`. */
signAlgoOid: string;
/** OID for `signatures/signHash.hashAlgo`. */
hashAlgoOid: string;
/** ECDSA named curve (informational; not separately persisted). */
ecdsaCurve?: CscEcdsaCurve;
};
/**
* Default RSA digest when the TSP advertises only hash-agnostic RSA OIDs
* (plain `rsaEncryption` / RSASSA-PSS). SHA-256 matches the CSC §11.9
* sample and is universally TSP-supported.
*/
const DEFAULT_RSA_DIGEST: CscDigest = 'SHA-256';
const HASH_OID_FOR_DIGEST: Record<CscDigest, string> = {
'SHA-256': '2.16.840.1.101.3.4.2.1',
'SHA-384': '2.16.840.1.101.3.4.2.2',
'SHA-512': '2.16.840.1.101.3.4.2.3',
};
/**
* Exposed lookup for the `signatures/signHash.hashAlgo` OID corresponding to a
* resolved {@link CscDigest}. Useful at sign time when the policy's
* `hashAlgoOid` field is not in scope (e.g. when reconstructing a
* `LibpdfSignerAlgo` from a persisted `CscCredential` row).
*/
export const hashOidForDigest = (digest: CscDigest): string => HASH_OID_FOR_DIGEST[digest];
const DIGEST_STRENGTH: Record<CscDigest, number> = {
'SHA-256': 256,
'SHA-384': 384,
'SHA-512': 512,
};
const STRONG_DIGEST_SET = new Set<string>(['SHA-256', 'SHA-384', 'SHA-512']);
type AlgoOidInfo = { family: 'RSA' | 'ECDSA'; boundDigest: CscDigest | 'SHA-1' | 'MD5' | null } | { family: 'DSA' };
/**
* Source-of-truth registry for `key.algo` entries (§11.5). Anything not
* listed is treated as unknown and skipped at policy evaluation.
*/
const KEY_ALGO_OID_REGISTRY: Record<string, AlgoOidInfo> = {
// Hash-agnostic RSA — caller picks the hash via `hashAlgo`.
'1.2.840.113549.1.1.1': { family: 'RSA', boundDigest: null }, // rsaEncryption
'1.2.840.113549.1.1.10': { family: 'RSA', boundDigest: null }, // RSASSA-PSS
// Hash-bound legacy RSA combos.
'1.2.840.113549.1.1.4': { family: 'RSA', boundDigest: 'MD5' }, // md5WithRSAEncryption
'1.2.840.113549.1.1.5': { family: 'RSA', boundDigest: 'SHA-1' }, // sha1WithRSAEncryption
'1.2.840.113549.1.1.11': { family: 'RSA', boundDigest: 'SHA-256' }, // sha256WithRSAEncryption
'1.2.840.113549.1.1.12': { family: 'RSA', boundDigest: 'SHA-384' }, // sha384WithRSAEncryption
'1.2.840.113549.1.1.13': { family: 'RSA', boundDigest: 'SHA-512' }, // sha512WithRSAEncryption
// ECDSA with SHA-x (hash is always bound).
'1.2.840.10045.4.1': { family: 'ECDSA', boundDigest: 'SHA-1' }, // ecdsa-with-SHA1
'1.2.840.10045.4.3.2': { family: 'ECDSA', boundDigest: 'SHA-256' },
'1.2.840.10045.4.3.3': { family: 'ECDSA', boundDigest: 'SHA-384' },
'1.2.840.10045.4.3.4': { family: 'ECDSA', boundDigest: 'SHA-512' },
// DSA — refused outright.
'1.2.840.10040.4.1': { family: 'DSA' },
'1.2.840.10040.4.3': { family: 'DSA' }, // dsa-with-SHA1
};
/**
* ECDSA named-curve OID registry. Policy verdict (allow/refuse) is decided
* by the resolver from the resolved curve name, not encoded here.
*/
const CURVE_OID_REGISTRY: Record<string, CscEcdsaCurve | 'P-192' | 'P-224'> = {
'1.2.840.10045.3.1.7': 'P-256', // secp256r1
'1.3.132.0.34': 'P-384', // secp384r1
'1.3.132.0.35': 'P-521', // secp521r1
'1.2.840.10045.3.1.1': 'P-192', // secp192r1
'1.3.132.0.33': 'P-224', // secp224r1
};
/**
* Validate a CSC credential's cert + key/algorithm against V1 policy and
* resolve the `(signAlgo, hashAlgo)` OID pair used by `signatures/signHash`.
*
* Caller MUST fetch the credential with `certInfo: true` so `cert.validFrom`
* / `cert.validTo` are present.
*
* Throws:
* - `CSC_CERT_INVALID` for cert-state issues (status not `valid`, missing or
* malformed validity dates, current time outside the validity window).
* - `CSC_ALGORITHM_REFUSED` for key/algorithm policy failures (disabled key,
* missing `key.len`, RSA `< 2048`, ECDSA without an allowed curve, DSA, no
* acceptable digest advertised in `key.algo`).
*/
export const resolveCscAlgorithmPolicy = (credentialInfo: TCscCredentialsInfoResponse): CscAlgorithmPolicy => {
assertCertValid(credentialInfo.cert);
if (credentialInfo.key.status !== 'enabled') {
throw new AppError(AppErrorCode.CSC_ALGORITHM_REFUSED, {
message: `CSC credential key status is '${credentialInfo.key.status}'.`,
});
}
if (credentialInfo.key.len === undefined) {
throw new AppError(AppErrorCode.CSC_ALGORITHM_REFUSED, {
message: 'CSC credential omits required key.len (REQUIRED per §11.5).',
});
}
const choice = pickAlgorithmChoice(credentialInfo.key.algo);
if (choice.family === 'RSA') {
if (credentialInfo.key.len < 2048) {
throw new AppError(AppErrorCode.CSC_ALGORITHM_REFUSED, {
message: `CSC RSA credential keyLen ${credentialInfo.key.len} < 2048.`,
});
}
return {
keyType: 'RSA',
keyLenBits: credentialInfo.key.len,
digestAlgorithm: choice.digest,
signAlgoOid: choice.signAlgoOid,
hashAlgoOid: HASH_OID_FOR_DIGEST[choice.digest],
};
}
const curve = resolveEcdsaCurve(credentialInfo.key.curve);
return {
keyType: 'ECDSA',
keyLenBits: credentialInfo.key.len,
digestAlgorithm: choice.digest,
signAlgoOid: choice.signAlgoOid,
hashAlgoOid: HASH_OID_FOR_DIGEST[choice.digest],
ecdsaCurve: curve,
};
};
type AlgorithmChoice = {
family: 'RSA' | 'ECDSA';
signAlgoOid: string;
digest: CscDigest;
};
/**
* Iterate the TSP's advertised `key.algo` OIDs, drop the policy-refused
* entries, and pick the strongest survivor.
*
* Precedence: ECDSA before RSA (smaller signatures, modern); within each
* family, strongest advertised digest first. Hash-agnostic RSA OIDs pair
* with {@link DEFAULT_RSA_DIGEST}.
*/
const pickAlgorithmChoice = (algoOids: readonly string[]): AlgorithmChoice => {
const candidates: AlgorithmChoice[] = [];
for (const oid of algoOids) {
const info = KEY_ALGO_OID_REGISTRY[oid];
if (info === undefined) {
// Unknown OID — another entry in `key.algo` may still be acceptable.
continue;
}
if (info.family === 'DSA') {
continue;
}
if (info.boundDigest === null) {
candidates.push({
family: info.family,
signAlgoOid: oid,
digest: DEFAULT_RSA_DIGEST,
});
continue;
}
if (STRONG_DIGEST_SET.has(info.boundDigest)) {
candidates.push({
family: info.family,
signAlgoOid: oid,
digest: info.boundDigest as CscDigest,
});
}
}
if (candidates.length === 0) {
throw new AppError(AppErrorCode.CSC_ALGORITHM_REFUSED, {
message: `CSC credential advertises no policy-compliant key.algo OIDs (got: ${algoOids.join(', ') || '<empty>'}).`,
});
}
candidates.sort((a, b) => {
if (a.family !== b.family) {
return a.family === 'ECDSA' ? -1 : 1;
}
return DIGEST_STRENGTH[b.digest] - DIGEST_STRENGTH[a.digest];
});
return candidates[0];
};
const resolveEcdsaCurve = (curveOid: string | undefined): CscEcdsaCurve => {
if (curveOid === undefined || curveOid === '') {
throw new AppError(AppErrorCode.CSC_ALGORITHM_REFUSED, {
message: 'CSC ECDSA credential omits required key.curve.',
});
}
const named = CURVE_OID_REGISTRY[curveOid];
if (named === 'P-256' || named === 'P-384' || named === 'P-521') {
return named;
}
const detail = named ? `, named=${named}` : '';
throw new AppError(AppErrorCode.CSC_ALGORITHM_REFUSED, {
message: `CSC ECDSA credential uses refused curve (oid=${curveOid}${detail}).`,
});
};
const assertCertValid = (cert: TCscCredentialsInfoResponse['cert']): void => {
if (cert.status !== undefined && cert.status !== 'valid') {
throw new AppError(AppErrorCode.CSC_CERT_INVALID, {
message: `CSC credential certificate status is '${cert.status}'.`,
});
}
if (!cert.validFrom || !cert.validTo) {
throw new AppError(AppErrorCode.CSC_CERT_INVALID, {
message: 'CSC credential certificate omits validFrom/validTo (malformed).',
});
}
const validFromMs = parseGeneralizedTime(cert.validFrom);
const validToMs = parseGeneralizedTime(cert.validTo);
if (validFromMs === null || validToMs === null) {
throw new AppError(AppErrorCode.CSC_CERT_INVALID, {
message: `CSC credential certificate validity dates are malformed (validFrom=${cert.validFrom}, validTo=${cert.validTo}).`,
});
}
const now = Date.now();
if (now < validFromMs) {
throw new AppError(AppErrorCode.CSC_CERT_INVALID, {
message: `CSC credential certificate is not yet valid (validFrom=${cert.validFrom}).`,
});
}
if (now > validToMs) {
throw new AppError(AppErrorCode.CSC_CERT_INVALID, {
message: `CSC credential certificate has expired (validTo=${cert.validTo}).`,
});
}
};
/**
* Parse an X.509 GeneralizedTime string (`YYYYMMDDHHMMSSZ`) into epoch ms.
* Strict — returns null on any deviation from the §11.5 example format.
*/
const parseGeneralizedTime = (value: string): number | null => {
const matched = /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})Z$/.exec(value);
if (matched === null) {
return null;
}
const [, y, mo, d, h, mi, s] = matched;
const ms = Date.UTC(Number(y), Number(mo) - 1, Number(d), Number(h), Number(mi), Number(s));
return Number.isNaN(ms) ? null : ms;
};
/**
* Subset of libpdf's `Signer` interface fields derived from a `CscAlgorithmPolicy`.
* Used by `CscCaptureSigner` / `CscFifoSigner` to satisfy libpdf's signer
* contract without re-deriving the mapping at each call site. `keyLenBits`
* is carried through so the capture-signer can size its placeholder output
* appropriately for the chosen key.
*/
export type LibpdfSignerAlgo = {
keyType: 'RSA' | 'EC';
signatureAlgorithm: 'RSASSA-PKCS1-v1_5' | 'RSA-PSS' | 'ECDSA';
digestAlgorithm: CscDigest;
keyLenBits: number;
};
/**
* Translate a `CscAlgorithmPolicy` (CSC §11.5 OIDs) into libpdf's `Signer`
* algorithm tuple. RSASSA-PSS is detected by the `signAlgoOid`; everything
* else maps directly from `keyType` + `digestAlgorithm`.
*/
export const policyToLibpdfSignerAlgo = (policy: CscAlgorithmPolicy): LibpdfSignerAlgo => {
if (policy.keyType === 'ECDSA') {
return {
keyType: 'EC',
signatureAlgorithm: 'ECDSA',
digestAlgorithm: policy.digestAlgorithm,
keyLenBits: policy.keyLenBits,
};
}
// RSA — distinguish PKCS1-v1.5 from PSS by the resolved sign-algo OID.
// RSASSA-PSS OID: '1.2.840.113549.1.1.10'.
const signatureAlgorithm: 'RSASSA-PKCS1-v1_5' | 'RSA-PSS' =
policy.signAlgoOid === '1.2.840.113549.1.1.10' ? 'RSA-PSS' : 'RSASSA-PKCS1-v1_5';
return {
keyType: 'RSA',
signatureAlgorithm,
digestAlgorithm: policy.digestAlgorithm,
keyLenBits: policy.keyLenBits,
};
};
@@ -0,0 +1,122 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
/**
* Length-prefixed X.509 chain for `CscCredential.certCache`. Schema column is
* `Bytes`; this gives a self-describing binary that round-trips without
* base64 inflation. Format: u32 BE cert count, then per-cert u32 BE byte
* length + raw DER bytes.
*
* Encoding inputs come from `cscCredentialsInfo.cert.certificates`, which the
* CSC §11.5 spec defines as an array of base64-encoded DER X.509 certificates
* (leaf-first). The encoder decodes each base64 entry once at persistence
* time; the decoder is the symmetric inverse used at sign time.
*
* Pure functions, no I/O.
*/
const BASE64_REGEX = /^[A-Za-z0-9+/]+={0,2}$/;
/**
* Encode a leaf-first chain of base64-encoded DER certs into the
* length-prefixed binary form persisted on `CscCredential.certCache`.
*
* Throws `INVALID_REQUEST` when the input is empty or any entry is not valid
* base64.
*/
export const encodeCscCertChain = (certs: string[]): Uint8Array => {
if (certs.length === 0) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'CSC certificate chain encoding requires at least one certificate.',
});
}
const derBuffers: Uint8Array[] = [];
let totalDerBytes = 0;
for (const entry of certs) {
if (entry.length === 0 || !BASE64_REGEX.test(entry)) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'CSC certificate chain entry is not valid base64.',
});
}
const der = Buffer.from(entry, 'base64');
if (der.length === 0) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'CSC certificate chain entry decoded to zero bytes.',
});
}
derBuffers.push(der);
totalDerBytes += der.length;
}
// 4 bytes for the count + 4 bytes per-cert length prefix + raw DER bytes.
const totalLength = 4 + derBuffers.length * 4 + totalDerBytes;
const out = new Uint8Array(totalLength);
const view = new DataView(out.buffer, out.byteOffset, out.byteLength);
view.setUint32(0, derBuffers.length, false);
let offset = 4;
for (const der of derBuffers) {
view.setUint32(offset, der.length, false);
offset += 4;
out.set(der, offset);
offset += der.length;
}
return out;
};
/**
* Decode a length-prefixed cert chain back into an array of DER cert byte
* arrays. Inverse of {@link encodeCscCertChain}.
*
* Throws `INVALID_REQUEST` when the buffer is truncated or any per-cert
* length prefix runs off the end of the buffer.
*/
export const decodeCscCertChain = (bytes: Uint8Array): Uint8Array[] => {
if (bytes.byteLength < 4) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'CSC certificate chain buffer is too short to contain a count prefix.',
});
}
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
const count = view.getUint32(0, false);
const result: Uint8Array[] = [];
let offset = 4;
for (let i = 0; i < count; i++) {
if (offset + 4 > bytes.byteLength) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'CSC certificate chain buffer truncated at length prefix.',
});
}
const length = view.getUint32(offset, false);
offset += 4;
if (length === 0 || offset + length > bytes.byteLength) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'CSC certificate chain buffer truncated within certificate body.',
});
}
// Slice copies the underlying bytes so callers can't mutate the source.
result.push(bytes.slice(offset, offset + length));
offset += length;
}
if (offset !== bytes.byteLength) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'CSC certificate chain buffer has trailing bytes after declared chain end.',
});
}
return result;
};
@@ -0,0 +1,51 @@
import { symmetricDecrypt, symmetricEncrypt } from '@documenso/lib/universal/crypto';
import { requireEnv } from '@documenso/lib/utils/env';
import { bytesToHex, hexToBytes } from '@noble/ciphers/utils';
/**
* Bytes-based wrappers around {@link symmetricEncrypt} / {@link symmetricDecrypt}
* for the two CSC secrets stored on Prisma `Bytes` columns:
*
* - `CscCredential.serviceTokenCiphertext` — service-scope OAuth access token.
* - `CscSession.encryptedSad` — credential-scope SAD.
*
* Both use the primary `DOCUMENSO_ENCRYPTION_KEY` (same key family as 2FA
* secrets, OIDC client secrets, DKIM private keys). The underlying cipher
* returns hex; we round-trip through `bytesToHex` / `hexToBytes` so the
* persisted bytes are the raw XChaCha20-Poly1305 ciphertext (nonce + tag +
* payload), not a hex-string-as-bytes inflation.
*/
/**
* Encrypt a CSC plaintext secret (service token or SAD) for persistence.
* Throws `MISSING_ENV_VAR` on missing encryption key — encryption can't
* otherwise fail.
*/
export const encryptCscToken = (plaintext: string): Uint8Array => {
const key = requireEnv('NEXT_PRIVATE_ENCRYPTION_KEY');
const hex = symmetricEncrypt({ key, data: plaintext });
return hexToBytes(hex);
};
/**
* Decrypt a CSC ciphertext back to its UTF-8 plaintext. Returns `null` on
* any cipher-level failure (key rotation, payload tamper, row corruption)
* so the caller can map to a domain-appropriate AppError — typically
* re-auth for service tokens, `CSC_SAD_EXPIRED_PRE_SIGN` for SADs.
*
* A missing key throws (config error, must surface loudly) and is *not*
* folded into the null return.
*/
export const decryptCscToken = (ciphertext: Uint8Array): string | null => {
const key = requireEnv('NEXT_PRIVATE_ENCRYPTION_KEY');
try {
const buf = symmetricDecrypt({ key, data: bytesToHex(ciphertext) });
return Buffer.from(buf).toString('utf-8');
} catch {
return null;
}
};
@@ -0,0 +1,122 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { cscJsonPost, joinCscUrl } from './http';
import {
type TCscCredentialsInfoRequest,
type TCscCredentialsInfoResponse,
type TCscCredentialsListRequest,
type TCscCredentialsListResponse,
ZCscCredentialsInfoResponseSchema,
ZCscCredentialsListResponseSchema,
} from './types';
type CscCredentialsListOptions = TCscCredentialsListRequest & {
baseUrl: string;
/** Service-scope bearer token (CSC §11.4 + §11.9). */
accessToken: string;
signal?: AbortSignal;
};
/**
* `credentials/list` (§11.4) — list the credentialIDs the bearer token's user
* owns at the TSP.
*
* Throws `CSC_CREDENTIAL_LIST_EMPTY` when the TSP returns a successful
* response with zero credentials — the recipient needs to enrol with the TSP
* before they can sign. Other failures throw `CSC_REQUEST_FAILED`.
*
* `userID` MUST be omitted when the service authorization is user-specific
* (true for OAuth `service` scope, which is V1's only flow). The spec rejects
* the call with `invalid_request` if both are present.
*/
export const cscCredentialsList = async (opts: CscCredentialsListOptions): Promise<TCscCredentialsListResponse> => {
const { baseUrl, accessToken, signal, userID, maxResults, pageToken, clientData } = opts;
const body: Record<string, unknown> = {};
if (userID !== undefined) {
body.userID = userID;
}
if (maxResults !== undefined) {
body.maxResults = maxResults;
}
if (pageToken !== undefined) {
body.pageToken = pageToken;
}
if (clientData !== undefined) {
body.clientData = clientData;
}
const response = await cscJsonPost(
{
url: joinCscUrl({ baseUrl, path: 'credentials/list' }),
body,
accessToken,
signal,
},
ZCscCredentialsListResponseSchema,
);
if (response.credentialIDs.length === 0) {
throw new AppError(AppErrorCode.CSC_CREDENTIAL_LIST_EMPTY, {
message:
'CSC provider returned no credentials for the authenticated user. Recipient must enrol with the TSP before signing.',
});
}
return response;
};
type CscCredentialsInfoOptions = TCscCredentialsInfoRequest & {
baseUrl: string;
/** Service-scope bearer token. */
accessToken: string;
signal?: AbortSignal;
};
/**
* `credentials/info` (§11.5) — fetch credential metadata: key algorithm tuple,
* X.509 certificate chain, authorization mode, multisign capacity.
*
* Returns the parsed response verbatim. Cert validity, algorithm policy, and
* SCAL semantics are enforced by `csc/algorithm-resolver.ts` — that lives
* outside the client because it's domain logic, not transport.
*/
export const cscCredentialsInfo = async (opts: CscCredentialsInfoOptions): Promise<TCscCredentialsInfoResponse> => {
const { baseUrl, accessToken, signal, credentialID, certificates, certInfo, authInfo, lang, clientData } = opts;
const body: Record<string, unknown> = { credentialID };
if (certificates !== undefined) {
body.certificates = certificates;
}
if (certInfo !== undefined) {
body.certInfo = certInfo;
}
if (authInfo !== undefined) {
body.authInfo = authInfo;
}
if (lang !== undefined) {
body.lang = lang;
}
if (clientData !== undefined) {
body.clientData = clientData;
}
return await cscJsonPost(
{
url: joinCscUrl({ baseUrl, path: 'credentials/info' }),
body,
accessToken,
signal,
},
ZCscCredentialsInfoResponseSchema,
);
};
@@ -0,0 +1,170 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { z } from 'zod';
import { ZCscErrorResponseSchema } from './types';
const LEADING_SLASHES_REGEX = /^\/+/;
const TRAILING_SLASHES_REGEX = /\/+$/;
/**
* Low-level fetch wrapper for the JSON-bodied CSC API methods (§7.1 mandates
* `Content-Type: application/json` for all API requests).
*
* OAuth 2.0 endpoints (`oauth2/token`, `oauth2/revoke`) use
* `application/x-www-form-urlencoded` per RFC 6749 and are handled by the
* `arctic` library — see `oauth.ts` in this directory.
*
* Normalises CSC error responses (§10.1: `{ error, error_description }`)
* into {@link AppError}s carrying the upstream HTTP status in
* {@link AppError.statusCode}, so callers can discriminate without
* re-parsing the body.
*/
type JoinUrlInput = {
baseUrl: string;
path: string;
};
/**
* Join a CSC base URL with a path segment. Strips trailing/leading slashes so
* `joinCscUrl({ baseUrl: 'https://x/csc/v1/', path: '/credentials/list' })`
* yields `https://x/csc/v1/credentials/list`.
*/
export const joinCscUrl = ({ baseUrl, path }: JoinUrlInput): string => {
const cleanBaseUrl = baseUrl.replace(TRAILING_SLASHES_REGEX, ''); // Strip trailing slashes from base URL.
const cleanPath = path.replace(LEADING_SLASHES_REGEX, ''); // Strip leading slashes from path.
const url = new URL(cleanPath, `${cleanBaseUrl}/`);
return url.toString();
};
type CscRequestErrorOptions = {
url: string;
status: number;
cscError?: { error: string; error_description?: string };
cause?: unknown;
errorCode?: string;
};
const buildCscRequestError = ({
url,
status,
cscError,
cause,
errorCode = AppErrorCode.CSC_REQUEST_FAILED,
}: CscRequestErrorOptions): AppError => {
const causeMessage = cause instanceof Error ? cause.message : undefined;
const parts: string[] = [`CSC request to ${url} failed (HTTP ${status})`];
if (cscError) {
parts.push(cscError.error_description ? `${cscError.error}: ${cscError.error_description}` : cscError.error);
}
if (causeMessage) {
parts.push(causeMessage);
}
return new AppError(errorCode, {
message: parts.join(' — '),
statusCode: status,
});
};
/**
* Best-effort parse of a CSC error body. Returns `undefined` on non-JSON or
* schema mismatch so the caller still surfaces the HTTP status without
* masking it.
*/
const readCscErrorBody = async (
response: Response,
): Promise<{ error: string; error_description?: string } | undefined> => {
try {
const json = await response.json();
const parsed = ZCscErrorResponseSchema.safeParse(json);
return parsed.success ? parsed.data : undefined;
} catch {
return undefined;
}
};
type CscJsonPostOptions = {
/** Fully-qualified endpoint URL (use {@link joinCscUrl} to build it). */
url: string;
/** Decoded JSON body; serialised via `JSON.stringify`. */
body: Record<string, unknown>;
/** Bearer access token. Omit for unauthenticated calls (e.g. `info`). */
accessToken?: string;
/** Override the AppError code thrown on failure. Defaults to `CSC_REQUEST_FAILED`. */
errorCode?: string;
/**
* Optional `AbortSignal` so callers can enforce their own deadlines
* (e.g. the 15s sign-time sync timeout).
*/
signal?: AbortSignal;
};
/**
* POST a JSON body to a CSC API endpoint and parse the response against the
* supplied Zod schema.
*
* Throws {@link AppError} on:
* - network/transport error (fetch threw)
* - non-2xx HTTP response (with CSC error body folded into the message)
* - malformed JSON response
* - schema validation failure
*/
export const cscJsonPost = async <T>(opts: CscJsonPostOptions, responseSchema: z.ZodSchema<T>): Promise<T> => {
const { url, body, accessToken, errorCode, signal } = opts;
let response: Response;
try {
response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
},
body: JSON.stringify(body),
signal,
});
} catch (cause) {
throw buildCscRequestError({ url, status: 0, cause, errorCode });
}
if (!response.ok) {
const cscError = await readCscErrorBody(response);
throw buildCscRequestError({
url,
status: response.status,
cscError,
errorCode,
});
}
let json: unknown;
try {
json = await response.json();
} catch (cause) {
throw buildCscRequestError({ url, status: response.status, cause, errorCode });
}
const parsed = responseSchema.safeParse(json);
if (!parsed.success) {
throw buildCscRequestError({
url,
status: response.status,
cause: parsed.error,
errorCode,
});
}
return parsed.data;
};
@@ -0,0 +1,32 @@
/**
* CSC v1.0.4.0 HTTP client. Stateless function wrappers — one per endpoint,
* grouped by spec section. Bring your own base URL(s) and bearer token.
*
* Endpoint coverage (V1 scope):
* - §11.1 info → {@link cscInfo}
* - §11.4 credentials/list → {@link cscCredentialsList}
* - §11.5 credentials/info → {@link cscCredentialsInfo}
* - §11.9 signatures/signHash → {@link cscSignHash}
* - §11.10 signatures/timestamp → {@link cscTimestamp}
* - §8.3.2 oauth2/authorize → {@link buildCscServiceScopeAuthorizeUrl},
* {@link buildCscCredentialScopeAuthorizeUrl}
* - §8.3.3 oauth2/token → {@link exchangeCscAuthorizationCode},
* {@link refreshCscServiceToken}
* - §8.3.4 oauth2/revoke → {@link revokeCscToken}
*
* Out of scope for V1 (intentionally excluded; we use OAuth + single-sig):
* - §11.2 auth/login (HTTP Basic)
* - §11.3 auth/revoke (HTTP Basic)
* - §11.6 credentials/authorize (alternative to OAuth credential scope)
* - §11.7 credentials/extendTransaction
* - §11.8 credentials/sendOTP
*
* OAuth is delegated to `arctic` (same library `packages/auth/` uses).
*/
export * from './credentials';
export * from './http';
export * from './info';
export * from './oauth';
export * from './signatures';
export * from './types';
@@ -0,0 +1,42 @@
import { AppErrorCode } from '@documenso/lib/errors/app-error';
import { cscJsonPost, joinCscUrl } from './http';
import { type TCscInfoRequest, type TCscInfoResponse, ZCscInfoResponseSchema } from './types';
type CscInfoOptions = TCscInfoRequest & {
/**
* Base URI of the CSC service (e.g. `https://service.example.org/csc/v1`).
* Per §7.2, `info` is mounted relative to the service base URI; the OAuth
* base URI returned in `oauth2` is discovered from this call.
*/
baseUrl: string;
signal?: AbortSignal;
};
/**
* `info` (§11.1) — discovery method every CSC-conformant TSP MUST implement.
*
* Used at startup to:
*
* 1. Learn the OAuth 2.0 base URI (`oauth2`) for subsequent token / revoke
* calls. Per §11.1, this MAY differ from the API base URI.
* 2. Enumerate supported methods (`methods`) so the caller can fail fast
* when a required endpoint is absent.
* 3. Surface `signatures/timestamp` capability for the B-LTA seal step.
*
* Unauthenticated — `info` requires no bearer token. Failures throw
* `CSC_PROVIDER_INFO_FAILED` per the spec's startup-discovery error code.
*/
export const cscInfo = async (opts: CscInfoOptions): Promise<TCscInfoResponse> => {
const { baseUrl, lang, signal } = opts;
return await cscJsonPost(
{
url: joinCscUrl({ baseUrl, path: 'info' }),
body: lang ? { lang } : {},
errorCode: AppErrorCode.CSC_PROVIDER_INFO_FAILED,
signal,
},
ZCscInfoResponseSchema,
);
};
@@ -0,0 +1,321 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import {
ArcticFetchError,
CodeChallengeMethod,
generateCodeVerifier,
generateState,
OAuth2Client,
OAuth2RequestError,
type OAuth2Tokens,
UnexpectedErrorResponseBodyError,
UnexpectedResponseError,
} from 'arctic';
import { joinCscUrl } from './http';
/**
* OAuth 2.0 surface for the CSC v1.0.4.0 protocol (§8.3.2 authorize,
* §8.3.3 token, §8.3.4 revoke).
*
* Backed by `arctic` — the same library `packages/auth/` uses for sign-in
* OAuth — so PKCE + state generation, token parsing, and revocation share a
* proven implementation. CSC-specific extension parameters (`credentialID`,
* `numSignatures`, `hash`, `description`, `account_token`, `clientData`,
* `lang` — §8.3.2) layer on top of the returned `URL` via
* `searchParams.set()`.
*
* Non-standard CSC bits arctic doesn't model directly:
* - `token_type === 'SAD'` for credential-scope responses (§8.3.3). Read from
* `tokens.tokenType()` which sources from raw `data`.
* - SAD is single-use and short-lived per spec; no refresh_token is issued
* for the credential scope. Callers SHOULD NOT call `refreshAccessToken`
* with a SAD.
*
* Re-exports `generateState` and `generateCodeVerifier` for callers that
* persist these in the OAuth-flow cookie.
*/
export { generateCodeVerifier, generateState };
// ─── Client construction ─────────────────────────────────────────────────────
type CreateCscOAuthClientOptions = {
clientId: string;
clientSecret: string;
redirectUri: string;
};
/**
* Construct an `OAuth2Client` bound to the CSC TSP's OAuth registration. The
* three values come from the env (`NEXT_PRIVATE_SIGNING_CSC_OAUTH_*`).
* Stateless — instantiate per request or cache at the transport singleton
* level; arctic's client carries no per-call state.
*/
export const createCscOAuthClient = ({
clientId,
clientSecret,
redirectUri,
}: CreateCscOAuthClientOptions): OAuth2Client => {
return new OAuth2Client(clientId, clientSecret, redirectUri);
};
// ─── Authorize URL builders (§8.3.2) ─────────────────────────────────────────
type AuthorizeUrlBaseOptions = {
client: OAuth2Client;
/**
* The TSP's OAuth base URI as returned by `info.oauth2` (§11.1). The
* `oauth2/authorize` path is joined on; per §8.3.2 NOTE 1 this can live on
* a different host from the API base URI.
*/
oauthBaseUrl: string;
/** Opaque CSRF token; see {@link generateState}. Caller persists it. */
state: string;
/** PKCE verifier; see {@link generateCodeVerifier}. Caller persists it. */
codeVerifier: string;
/** Preferred response language (§11.1 `lang` parameter). */
lang?: string;
/**
* Arbitrary application-defined string echoed back at callback. WARNING per
* §8.3.2: this is forwarded verbatim to the TSP; never put secrets here.
*/
clientData?: string;
};
const applyCscAuthorizeExtras = (url: URL, opts: { lang?: string; clientData?: string }): URL => {
if (opts.lang) {
url.searchParams.set('lang', opts.lang);
}
if (opts.clientData) {
url.searchParams.set('clientData', opts.clientData);
}
return url;
};
/**
* Build the `oauth2/authorize` URL for the **service** scope. Recipient
* follows this URL to authenticate at the TSP and grant access to list
* credentials + fetch credential info.
*/
export const buildCscServiceScopeAuthorizeUrl = (opts: AuthorizeUrlBaseOptions): URL => {
const { client, oauthBaseUrl, state, codeVerifier, lang, clientData } = opts;
const url = client.createAuthorizationURLWithPKCE(
joinCscUrl({ baseUrl: oauthBaseUrl, path: 'oauth2/authorize' }),
state,
CodeChallengeMethod.S256,
codeVerifier,
['service'],
);
return applyCscAuthorizeExtras(url, { lang, clientData });
};
type CredentialScopeAuthorizeOptions = AuthorizeUrlBaseOptions & {
/** Target credential (§8.3.2 — REQUIRED for credential scope). */
credentialId: string;
/** Number of signatures this SAD will authorise (§8.3.2 — REQUIRED). */
numSignatures: number;
/**
* Standard-base64-encoded hash values the SAD will be bound to. REQUIRED for
* SCAL2 credentials (§8.3.2). The builder converts each value to base64url
* before joining with `,` per the spec — §8.3.2 mandates base64url for the
* `hash` URL parameter, but the rest of the codebase (and the
* `signatures/signHash` JSON body per §11.9) uses standard base64. Callers
* pass what `Buffer.from(...).toString('base64')` produces.
*/
hashes: string[];
/** Human-readable transaction description shown on the TSP's SCA page. */
description?: string;
/** Optional restricted-access token (JWT) some TSPs require (§8.3.2). */
accountToken?: string;
};
/**
* Convert a standard-base64 string to base64url (RFC 4648 §5). The CSC §8.3.2
* `hash` URL parameter requires base64url; TSPs reject standard base64 even
* after percent-decoding because `+`, `/`, and `=` are invalid base64url
* characters. JSON-body fields (§11.9 `signatures/signHash`) keep standard
* base64.
*/
const toBase64Url = (standardBase64: string): string =>
standardBase64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
/**
* Build the `oauth2/authorize` URL for the **credential** scope. The TSP
* binds the issued SAD to `hashes` so it can only sign those exact digests.
*
* Hash ordering in the SAD is independent of the order passed to
* `signatures/signHash` (§8.3.2) — the TSP matches by hash value, not
* position.
*/
export const buildCscCredentialScopeAuthorizeUrl = (opts: CredentialScopeAuthorizeOptions): URL => {
const {
client,
oauthBaseUrl,
state,
codeVerifier,
credentialId,
numSignatures,
hashes,
description,
accountToken,
lang,
clientData,
} = opts;
const url = client.createAuthorizationURLWithPKCE(
joinCscUrl({ baseUrl: oauthBaseUrl, path: 'oauth2/authorize' }),
state,
CodeChallengeMethod.S256,
codeVerifier,
['credential'],
);
url.searchParams.set('credentialID', credentialId);
url.searchParams.set('numSignatures', String(numSignatures));
url.searchParams.set('hash', hashes.map(toBase64Url).join(','));
if (description) {
url.searchParams.set('description', description);
}
if (accountToken) {
url.searchParams.set('account_token', accountToken);
}
return applyCscAuthorizeExtras(url, { lang, clientData });
};
// ─── Token exchange (§8.3.3) ─────────────────────────────────────────────────
type ExchangeCodeOptions = {
client: OAuth2Client;
/** OAuth base URI from `info.oauth2`. `oauth2/token` is joined on. */
oauthBaseUrl: string;
/** Authorization code from the callback's `code` query param. */
code: string;
/** Same PKCE verifier passed to the authorize URL builder. */
codeVerifier: string;
signal?: AbortSignal;
};
/**
* Exchange an authorization code for an access token. Used for both scopes;
* the response shape differs only in `token_type`:
*
* - service scope: `token_type === 'Bearer'`, optional `refresh_token`.
* - credential scope: `token_type === 'SAD'`, single-use, no refresh_token.
*
* Inspect `tokens.tokenType()` (or `tokens.data` for raw access) to
* discriminate.
*/
export const exchangeCscAuthorizationCode = async (opts: ExchangeCodeOptions): Promise<OAuth2Tokens> => {
const { client, oauthBaseUrl, code, codeVerifier } = opts;
try {
return await client.validateAuthorizationCode(
joinCscUrl({ baseUrl: oauthBaseUrl, path: 'oauth2/token' }),
code,
codeVerifier,
);
} catch (err) {
throw mapArcticError(err, 'oauth2/token');
}
};
type RefreshServiceTokenOptions = {
client: OAuth2Client;
oauthBaseUrl: string;
/** Service-scope refresh token from a prior token exchange. */
refreshToken: string;
signal?: AbortSignal;
};
/**
* Refresh a service-scope access token. Credential-scope SADs are NOT
* refreshable per §8.3.3 — only service scope issues refresh tokens.
*
* Scopes passed as `['service']` to keep the refresh narrow; the TSP may
* ignore the scope parameter on refresh per RFC 6749 §6.
*/
export const refreshCscServiceToken = async (opts: RefreshServiceTokenOptions): Promise<OAuth2Tokens> => {
const { client, oauthBaseUrl, refreshToken } = opts;
try {
return await client.refreshAccessToken(joinCscUrl({ baseUrl: oauthBaseUrl, path: 'oauth2/token' }), refreshToken, [
'service',
]);
} catch (err) {
throw mapArcticError(err, 'oauth2/token');
}
};
// ─── Revoke (§8.3.4) ─────────────────────────────────────────────────────────
type RevokeTokenOptions = {
client: OAuth2Client;
oauthBaseUrl: string;
/** Access token or refresh token to revoke. */
token: string;
signal?: AbortSignal;
};
/**
* Revoke a CSC OAuth token. Per §8.3.4, revoking a refresh token also
* invalidates every access token derived from the same grant; revoking an
* access token only invalidates that access token.
*
* `204 No Content` on success; arctic resolves the promise. Failures
* surface as `CSC_REQUEST_FAILED` via {@link mapArcticError}.
*/
export const revokeCscToken = async (opts: RevokeTokenOptions): Promise<void> => {
const { client, oauthBaseUrl, token } = opts;
try {
await client.revokeToken(joinCscUrl({ baseUrl: oauthBaseUrl, path: 'oauth2/revoke' }), token);
} catch (err) {
throw mapArcticError(err, 'oauth2/revoke');
}
};
// ─── Error normalisation ────────────────────────────────────────────────────
/**
* Translate arctic's typed exception hierarchy into AppErrors consistent with
* the rest of the CSC client (see http.ts). Preserves the HTTP status when
* arctic surfaces it.
*/
const mapArcticError = (err: unknown, endpoint: string): AppError => {
if (err instanceof OAuth2RequestError) {
return new AppError(AppErrorCode.CSC_REQUEST_FAILED, {
message: `CSC ${endpoint} rejected: ${err.code}${err.description ? `${err.description}` : ''}`,
});
}
if (err instanceof ArcticFetchError) {
return new AppError(AppErrorCode.CSC_REQUEST_FAILED, {
message: `CSC ${endpoint} fetch failed: ${err.message}`,
});
}
if (err instanceof UnexpectedResponseError) {
return new AppError(AppErrorCode.CSC_REQUEST_FAILED, {
message: `CSC ${endpoint} returned unexpected HTTP ${err.status}`,
statusCode: err.status,
});
}
if (err instanceof UnexpectedErrorResponseBodyError) {
return new AppError(AppErrorCode.CSC_REQUEST_FAILED, {
message: `CSC ${endpoint} returned HTTP ${err.status} with unparseable body`,
statusCode: err.status,
});
}
return new AppError(AppErrorCode.CSC_REQUEST_FAILED, {
message: `CSC ${endpoint} failed: ${err instanceof Error ? err.message : String(err)}`,
});
};
@@ -0,0 +1,111 @@
import { cscJsonPost, joinCscUrl } from './http';
import {
type TCscSignHashRequest,
type TCscSignHashResponse,
type TCscTimestampRequest,
type TCscTimestampResponse,
ZCscSignHashResponseSchema,
ZCscTimestampResponseSchema,
} from './types';
type CscSignHashOptions = TCscSignHashRequest & {
baseUrl: string;
/** Service-scope bearer token. The SAD (in the body) is the credential-scope grant. */
accessToken: string;
signal?: AbortSignal;
};
/**
* `signatures/signHash` (§11.9) — submit one or more pre-computed hashes for
* the TSP to sign with the credential identified by `credentialID`.
*
* Authorisation is two-layered:
* - The service-scope bearer token authenticates the API call itself.
* - The credential-scope SAD (in the JSON body) authorises the specific
* hashes — the TSP rejects with `invalid_request` ("Hash is not authorized
* by the SAD") if any hash in the array wasn't bound at SAD issuance.
*
* The returned `signatures` array is position-ordered with `hash` per §11.9.
* Callers SHALL preserve order when mapping responses back to PDF embed
* slots (the fifoSigner relies on this).
*/
export const cscSignHash = async (opts: CscSignHashOptions): Promise<TCscSignHashResponse> => {
const { baseUrl, accessToken, signal, credentialID, SAD, hash, hashAlgo, signAlgo, signAlgoParams, clientData } =
opts;
const body: Record<string, unknown> = {
credentialID,
SAD,
hash,
signAlgo,
};
if (hashAlgo !== undefined) {
body.hashAlgo = hashAlgo;
}
if (signAlgoParams !== undefined) {
body.signAlgoParams = signAlgoParams;
}
if (clientData !== undefined) {
body.clientData = clientData;
}
return await cscJsonPost(
{
url: joinCscUrl({ baseUrl, path: 'signatures/signHash' }),
body,
accessToken,
signal,
},
ZCscSignHashResponseSchema,
);
};
type CscTimestampOptions = TCscTimestampRequest & {
baseUrl: string;
/**
* Service-scope bearer token. Per §11.10 the timestamp endpoint may or may
* not require auth depending on TSP policy; the spec is silent. We send the
* token unconditionally because all known TSPs gate this endpoint.
*/
accessToken: string;
signal?: AbortSignal;
};
/**
* `signatures/timestamp` (§11.10) — request an RFC 3161 / RFC 5816 time-stamp
* token for a pre-computed hash. Driven by {@link CscTspTimestampAuthority}
* at sign time, when {@link resolveCscSignTimeTsa} selects the TSP source
* (TSP advertises `signatures/timestamp` in `info.methods`). The bearer is
* the current recipient's own service-scope token. Seal-time archival
* timestamps do not go through this endpoint — they use the env-configured
* RFC 3161 TSA directly.
*
* If `nonce` is supplied, the TSP MUST round-trip it in the token — we leave
* verification to LibPDF / our TSA helper, not this client.
*/
export const cscTimestamp = async (opts: CscTimestampOptions): Promise<TCscTimestampResponse> => {
const { baseUrl, accessToken, signal, hash, hashAlgo, nonce, clientData } = opts;
const body: Record<string, unknown> = { hash, hashAlgo };
if (nonce !== undefined) {
body.nonce = nonce;
}
if (clientData !== undefined) {
body.clientData = clientData;
}
return await cscJsonPost(
{
url: joinCscUrl({ baseUrl, path: 'signatures/timestamp' }),
body,
accessToken,
signal,
},
ZCscTimestampResponseSchema,
);
};
@@ -0,0 +1,179 @@
import { z } from 'zod';
/**
* Zod schemas + types for every CSC v1.0.4.0 request/response shape the V1
* client touches. Field names mirror the spec exactly. Unknown fields are
* silently dropped (Zod default `.strip()`); we don't `.passthrough()` to
* keep parsed objects narrow.
*
* Out-of-scope endpoints (`auth/login`, `auth/revoke`, `credentials/authorize`,
* `credentials/extendTransaction`, `credentials/sendOTP`) intentionally have
* no schemas here — V1 uses OAuth + sequential single-signature flows only.
*/
// ─── §10.1 common error envelope ─────────────────────────────────────────────
export const ZCscErrorResponseSchema = z.object({
error: z.string(),
error_description: z.string().optional(),
});
export type TCscErrorResponse = z.infer<typeof ZCscErrorResponseSchema>;
// ─── §11.1 info ──────────────────────────────────────────────────────────────
export const ZCscInfoRequestSchema = z.object({
lang: z.string().optional(),
});
export type TCscInfoRequest = z.infer<typeof ZCscInfoRequestSchema>;
export const ZCscInfoResponseSchema = z.object({
specs: z.string(),
name: z.string(),
logo: z.string(),
region: z.string(),
lang: z.string(),
description: z.string(),
authType: z.array(z.string()),
// REQUIRED Conditional — present when authType includes `oauth2code` /
// `oauth2client`, or when any credential supports `oauth2code` authMode.
// We always need it for V1, but keeping the schema permissive matches the
// spec; absence is detected at the call site.
oauth2: z.string().optional(),
methods: z.array(z.string()),
});
export type TCscInfoResponse = z.infer<typeof ZCscInfoResponseSchema>;
// ─── §11.4 credentials/list ──────────────────────────────────────────────────
export const ZCscCredentialsListRequestSchema = z.object({
// OAuth2 user-specific service auth → userID MUST be omitted (§11.4 NOTE 1).
userID: z.string().optional(),
maxResults: z.number().int().positive().optional(),
pageToken: z.string().optional(),
clientData: z.string().optional(),
});
export type TCscCredentialsListRequest = z.infer<typeof ZCscCredentialsListRequestSchema>;
export const ZCscCredentialsListResponseSchema = z.object({
credentialIDs: z.array(z.string()),
nextPageToken: z.string().optional(),
});
export type TCscCredentialsListResponse = z.infer<typeof ZCscCredentialsListResponseSchema>;
// ─── §11.5 credentials/info ──────────────────────────────────────────────────
export const ZCscCredentialsInfoRequestSchema = z.object({
credentialID: z.string(),
certificates: z.enum(['none', 'single', 'chain']).optional(),
certInfo: z.boolean().optional(),
authInfo: z.boolean().optional(),
lang: z.string().optional(),
clientData: z.string().optional(),
});
export type TCscCredentialsInfoRequest = z.infer<typeof ZCscCredentialsInfoRequestSchema>;
export const ZCscCredentialsInfoKeySchema = z.object({
status: z.enum(['enabled', 'disabled']),
algo: z.array(z.string()),
// REQUIRED per §11.5 but kept optional here so the algorithm-resolver can
// surface absence as a typed `CSC_ALGORITHM_REFUSED` (matching the spec's
// policy table) instead of a generic transport schema failure.
len: z.number().int().positive().optional(),
// REQUIRED Conditional for ECDSA per §11.5; absence handled by the resolver.
curve: z.string().optional(),
});
export const ZCscCredentialsInfoCertSchema = z.object({
status: z.enum(['valid', 'expired', 'revoked', 'suspended']).optional(),
certificates: z.array(z.string()).optional(),
issuerDN: z.string().optional(),
serialNumber: z.string().optional(),
subjectDN: z.string().optional(),
validFrom: z.string().optional(),
validTo: z.string().optional(),
});
export const ZCscCredentialsInfoPinSchema = z.object({
presence: z.enum(['true', 'false', 'optional']),
format: z.enum(['A', 'N']).optional(),
label: z.string().optional(),
description: z.string().optional(),
});
export const ZCscCredentialsInfoOtpSchema = z.object({
presence: z.enum(['true', 'false', 'optional']),
type: z.enum(['offline', 'online']).optional(),
format: z.enum(['A', 'N']).optional(),
label: z.string().optional(),
description: z.string().optional(),
ID: z.string().optional(),
provider: z.string().optional(),
});
export const ZCscCredentialsInfoResponseSchema = z.object({
description: z.string().optional(),
key: ZCscCredentialsInfoKeySchema,
cert: ZCscCredentialsInfoCertSchema,
authMode: z.enum(['implicit', 'explicit', 'oauth2code']),
SCAL: z.enum(['1', '2']).optional(),
PIN: ZCscCredentialsInfoPinSchema.optional(),
OTP: ZCscCredentialsInfoOtpSchema.optional(),
multisign: z.number().int().min(1),
lang: z.string().optional(),
});
export type TCscCredentialsInfoResponse = z.infer<typeof ZCscCredentialsInfoResponseSchema>;
// ─── §11.9 signatures/signHash ───────────────────────────────────────────────
export const ZCscSignHashRequestSchema = z.object({
credentialID: z.string(),
SAD: z.string(),
// Base64-encoded raw message digests.
hash: z.array(z.string()).nonempty(),
// REQUIRED Conditional — OID of the hash algorithm. Omit only when implied
// by signAlgo (per §11.9). The caller decides.
hashAlgo: z.string().optional(),
signAlgo: z.string(),
// REQUIRED Conditional for algorithms like RSASSA-PSS.
signAlgoParams: z.string().optional(),
clientData: z.string().optional(),
});
export type TCscSignHashRequest = z.infer<typeof ZCscSignHashRequestSchema>;
export const ZCscSignHashResponseSchema = z.object({
// Position-ordered Base64-encoded signed hashes matching the input order.
signatures: z.array(z.string()).nonempty(),
});
export type TCscSignHashResponse = z.infer<typeof ZCscSignHashResponseSchema>;
// ─── §11.10 signatures/timestamp ─────────────────────────────────────────────
export const ZCscTimestampRequestSchema = z.object({
hash: z.string(),
hashAlgo: z.string(),
// Hex-encoded random; SHALL round-trip in the timestamp token when supplied.
nonce: z.string().optional(),
clientData: z.string().optional(),
});
export type TCscTimestampRequest = z.infer<typeof ZCscTimestampRequestSchema>;
export const ZCscTimestampResponseSchema = z.object({
// Base64-encoded RFC 3161 (with RFC 5816 update) time-stamp token.
timestamp: z.string(),
});
export type TCscTimestampResponse = z.infer<typeof ZCscTimestampResponseSchema>;
// OAuth 2.0 token + revoke shapes are handled by the `arctic` library — see
// `oauth.ts` in this directory. Arctic exposes `OAuth2Tokens` (with `.data`
// available for non-standard CSC fields like `token_type === 'SAD'`).
@@ -0,0 +1,120 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { Context } from 'hono';
import { deleteCookie, getSignedCookie, setSignedCookie } from 'hono/cookie';
import { parseSigned, serialize } from 'hono/utils/cookie';
import { z } from 'zod';
import { CSC_BLOCKING_ERROR_COOKIE_NAME, cscCookieBaseOptions, getCscCookieSecret } from './shared';
/**
* `csc_blocking_error` — one-shot surface for service-scope OAuth callback
* failures the recipient can't self-resolve (empty credential list, invalid
* cert, refused algorithm, etc.). The `/sign/{token}` loader reads + clears
* it on next visit so no error state rides on URL query params.
*/
const CSC_BLOCKING_ERROR_MAX_AGE_SECONDS = 60 * 10; // 10 minutes — matches the other short-lived CSC cookies.
export const ZCscBlockingErrorPayloadSchema = z.object({
/** `AppErrorCode` value, e.g. `'CSC_CREDENTIAL_LIST_EMPTY'`. */
code: z.string().min(1),
/** Recipient token from `/sign/{token}`; loader scopes the error to its recipient. */
recipientToken: z.string().min(1),
});
export type TCscBlockingErrorPayload = z.infer<typeof ZCscBlockingErrorPayloadSchema>;
type SetCscBlockingErrorCookieOptions = {
c: Context;
payload: TCscBlockingErrorPayload;
};
export const setCscBlockingErrorCookie = async (options: SetCscBlockingErrorCookieOptions): Promise<void> => {
const { c, payload } = options;
await setSignedCookie(c, CSC_BLOCKING_ERROR_COOKIE_NAME, JSON.stringify(payload), getCscCookieSecret(), {
...cscCookieBaseOptions,
maxAge: CSC_BLOCKING_ERROR_MAX_AGE_SECONDS,
});
};
/**
* Read + validate the blocking-error cookie. Returns `null` when absent or
* signature-invalid; throws `INVALID_REQUEST` when signed-but-malformed
* (tamper-shaped, mirroring `oauth-flow-cookie.ts`).
*/
export const getCscBlockingErrorCookie = async (c: Context): Promise<TCscBlockingErrorPayload | null> => {
const raw = await getSignedCookie(c, getCscCookieSecret(), CSC_BLOCKING_ERROR_COOKIE_NAME);
if (!raw) {
return null;
}
let parsedJson: unknown;
try {
parsedJson = JSON.parse(raw);
} catch {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'CSC blocking error cookie payload is not valid JSON.',
});
}
const result = ZCscBlockingErrorPayloadSchema.safeParse(parsedJson);
if (!result.success) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'CSC blocking error cookie payload failed schema validation.',
});
}
return result.data;
};
export const clearCscBlockingErrorCookie = (c: Context): void => {
deleteCookie(c, CSC_BLOCKING_ERROR_COOKIE_NAME, cscCookieBaseOptions);
};
/**
* Remix-compatible reader: parses + HMAC-verifies the blocking-error cookie
* from a raw `Cookie` header on a standard `Request`. Returns `null` when
* absent, signature-invalid, or payload-malformed (no throw — the loader
* only uses the cookie advisorily, so a bad cookie shouldn't break the page).
*/
export const readCscBlockingErrorFromRequest = async (request: Request): Promise<TCscBlockingErrorPayload | null> => {
const cookieHeader = request.headers.get('cookie');
if (!cookieHeader) {
return null;
}
const parsed = await parseSigned(cookieHeader, getCscCookieSecret(), CSC_BLOCKING_ERROR_COOKIE_NAME);
const value = parsed[CSC_BLOCKING_ERROR_COOKIE_NAME];
if (typeof value !== 'string') {
return null;
}
try {
const json = JSON.parse(value);
const result = ZCscBlockingErrorPayloadSchema.safeParse(json);
return result.success ? result.data : null;
} catch {
return null;
}
};
/**
* Serialised `Set-Cookie` header value that expires the cookie immediately.
* Use in a Remix loader's response headers to clear the cookie after the
* loader reads it once.
*/
export const buildClearCscBlockingErrorCookieHeader = (): string => {
return serialize(CSC_BLOCKING_ERROR_COOKIE_NAME, '', {
...cscCookieBaseOptions,
maxAge: 0,
});
};
@@ -0,0 +1,85 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { Context } from 'hono';
import { deleteCookie, getSignedCookie, setSignedCookie } from 'hono/cookie';
import { z } from 'zod';
import { CSC_OAUTH_FLOW_COOKIE_NAME, cscCookieBaseOptions, getCscCookieSecret } from './shared';
/**
* `csc_oauth_flow` — single-round-trip carrier across `/api/csc/oauth/authorize`
* → TSP → `/api/csc/oauth/callback`. Holds the PKCE verifier + state plus the
* Documenso-side context (`recipientToken`, optional `sessionId`) the
* callback needs to resume the right signing flow.
*
* JSON-encoded inside a single signed cookie; structurally validated on read
* so a tampered or stale shape can't smuggle bad state into the callback.
*/
const CSC_OAUTH_FLOW_MAX_AGE_SECONDS = 60 * 10; // 10 minutes — matches /api/auth/oauth/* convention.
export const ZCscOAuthFlowPayloadSchema = z.object({
/** `'service'` for the first round-trip, `'credential'` for the SAD round-trip. */
scope: z.enum(['service', 'credential']),
/** Arctic-generated CSRF token; re-validated against `?state` at callback. */
state: z.string().min(1),
/** Arctic-generated PKCE verifier (RFC 7636); paired with the URL's `code_challenge`. */
codeVerifier: z.string().min(1),
/** Recipient signing token from `/sign/{token}`; threads recipient identity through the round-trip. */
recipientToken: z.string().min(1),
/** CSC session id — present only on `credential`-scope flows (set at prep). */
sessionId: z.string().min(1).optional(),
});
export type TCscOAuthFlowPayload = z.infer<typeof ZCscOAuthFlowPayloadSchema>;
type SetCscOAuthFlowCookieOptions = {
c: Context;
payload: TCscOAuthFlowPayload;
};
export const setCscOAuthFlowCookie = async (options: SetCscOAuthFlowCookieOptions): Promise<void> => {
const { c, payload } = options;
await setSignedCookie(c, CSC_OAUTH_FLOW_COOKIE_NAME, JSON.stringify(payload), getCscCookieSecret(), {
...cscCookieBaseOptions,
maxAge: CSC_OAUTH_FLOW_MAX_AGE_SECONDS,
});
};
/**
* Read + validate the OAuth-flow cookie. Returns `null` when the cookie is
* absent or the signature is invalid; throws `INVALID_REQUEST` when the
* payload is structurally bad (signed but malformed JSON / schema mismatch),
* since that's tamper-shaped, not a normal missing-cookie case.
*/
export const getCscOAuthFlowCookie = async (c: Context): Promise<TCscOAuthFlowPayload | null> => {
const raw = await getSignedCookie(c, getCscCookieSecret(), CSC_OAUTH_FLOW_COOKIE_NAME);
if (!raw) {
return null;
}
let parsedJson: unknown;
try {
parsedJson = JSON.parse(raw);
} catch {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'CSC OAuth flow cookie payload is not valid JSON.',
});
}
const result = ZCscOAuthFlowPayloadSchema.safeParse(parsedJson);
if (!result.success) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'CSC OAuth flow cookie payload failed schema validation.',
});
}
return result.data;
};
export const clearCscOAuthFlowCookie = (c: Context): void => {
deleteCookie(c, CSC_OAUTH_FLOW_COOKIE_NAME, cscCookieBaseOptions);
};
@@ -0,0 +1,61 @@
import type { Context } from 'hono';
import { deleteCookie, getSignedCookie, setSignedCookie } from 'hono/cookie';
import { parseSigned } from 'hono/utils/cookie';
import { CSC_SAD_SESSION_COOKIE_NAME, cscCookieBaseOptions, getCscCookieSecret } from './shared';
/**
* `csc_sad_session` — HMAC-signed `CscSession` cuid. Set after the
* credential-scope OAuth callback exchanges code → SAD; pointed at the
* server-side session row that owns the SAD + the prep-time item hashes.
*
* Lifetime mirrors the TSP-asserted SAD expiry (`sadExpiresAt`) so the cookie
* cannot outlive its server-side authorisation. Cleared by the sync sign
* mutation on success; otherwise decays naturally with the browser TTL.
*/
type SetCscSadSessionCookieOptions = {
c: Context;
sessionId: string;
/** Mirror of `CscSession.sadExpiresAt`; cookie expires no later than the SAD. */
expiresAt: Date;
};
export const setCscSadSessionCookie = async (options: SetCscSadSessionCookieOptions): Promise<void> => {
const { c, sessionId, expiresAt } = options;
await setSignedCookie(c, CSC_SAD_SESSION_COOKIE_NAME, sessionId, getCscCookieSecret(), {
...cscCookieBaseOptions,
expires: expiresAt,
});
};
export const getCscSadSessionCookie = async (c: Context): Promise<string | null> => {
const value = await getSignedCookie(c, getCscCookieSecret(), CSC_SAD_SESSION_COOKIE_NAME);
// `getSignedCookie` returns `false` on signature mismatch, `undefined` when
// the cookie is absent. Both collapse to `null` for the caller's sake.
return value ? value : null;
};
export const clearCscSadSessionCookie = (c: Context): void => {
deleteCookie(c, CSC_SAD_SESSION_COOKIE_NAME, cscCookieBaseOptions);
};
/**
* Remix-compatible reader: parses + HMAC-verifies the SAD-session cookie
* from a raw `Cookie` header on a standard `Request`. Mirrors
* `getCscSadSessionCookie` but works outside Hono's `Context`.
*/
export const readCscSadSessionFromRequest = async (request: Request): Promise<string | null> => {
const cookieHeader = request.headers.get('cookie');
if (!cookieHeader) {
return null;
}
const parsed = await parseSigned(cookieHeader, getCscCookieSecret(), CSC_SAD_SESSION_COOKIE_NAME);
const value = parsed[CSC_SAD_SESSION_COOKIE_NAME];
return typeof value === 'string' ? value : null;
};
@@ -0,0 +1,65 @@
import type { Context } from 'hono';
import { deleteCookie, getSignedCookie, setSignedCookie } from 'hono/cookie';
import { parseSigned } from 'hono/utils/cookie';
import { CSC_SERVICE_SESSION_COOKIE_NAME, cscCookieBaseOptions, getCscCookieSecret } from './shared';
/**
* `csc_service_session` — recipient-scoped attestation that this browser just
* completed a service-scope OAuth round-trip for `<recipientToken>`. The
* `/sign/{token}` loader compares the cookie value against the path token; on
* match it skips re-auth, breaking the redirect loop that would otherwise
* occur when the TSP silently re-grants from its cached SCA session.
*
* Covers the long-lived T1→T3 window (recipient on the signing page filling
* fields, before clicking Sign). `csc_sad_session` covers the much shorter
* T4→T5 window (active signing transaction); the two are complementary, not
* substitutes.
*
* TTL = TSP-asserted service-scope `expires_in` so the trust window can never
* outlive the underlying access token.
*/
type SetCscServiceSessionCookieOptions = {
c: Context;
recipientToken: string;
/** TSP service-scope `expires_in` in seconds. Mirrored as the cookie max-age. */
ttlSeconds: number;
};
export const setCscServiceSessionCookie = async (options: SetCscServiceSessionCookieOptions): Promise<void> => {
const { c, recipientToken, ttlSeconds } = options;
await setSignedCookie(c, CSC_SERVICE_SESSION_COOKIE_NAME, recipientToken, getCscCookieSecret(), {
...cscCookieBaseOptions,
maxAge: ttlSeconds,
});
};
export const getCscServiceSessionCookie = async (c: Context): Promise<string | null> => {
const value = await getSignedCookie(c, getCscCookieSecret(), CSC_SERVICE_SESSION_COOKIE_NAME);
return value ? value : null;
};
export const clearCscServiceSessionCookie = (c: Context): void => {
deleteCookie(c, CSC_SERVICE_SESSION_COOKIE_NAME, cscCookieBaseOptions);
};
/**
* Remix-compatible reader: parses + HMAC-verifies the service-session cookie
* from a raw `Cookie` header on a standard `Request`. Mirrors
* `getCscServiceSessionCookie` but works outside Hono's `Context`.
*/
export const readCscServiceSessionFromRequest = async (request: Request): Promise<string | null> => {
const cookieHeader = request.headers.get('cookie');
if (!cookieHeader) {
return null;
}
const parsed = await parseSigned(cookieHeader, getCscCookieSecret(), CSC_SERVICE_SESSION_COOKIE_NAME);
const value = parsed[CSC_SERVICE_SESSION_COOKIE_NAME];
return typeof value === 'string' ? value : null;
};
@@ -0,0 +1,46 @@
import { formatSecureCookieName, getCookieDomain, useSecureCookies } from '@documenso/lib/constants/auth';
import { requireEnv } from '@documenso/lib/utils/env';
/**
* Shared HMAC secret + base attribute set for the CSC cookies.
*
* `NEXTAUTH_SECRET` is reused so signed-cookie verification stays uniform
* across the auth + CSC surfaces. The `sameSite` conditional matches
* `sessionCookieOptions` in `@documenso/auth` so a future embedding flow
* (CSC inside an `<iframe>` on a partner host) works without a separate
* cookie-attribute regime.
*/
/** HMAC secret for hono `setSignedCookie` / `getSignedCookie`. */
export const getCscCookieSecret = (): string => requireEnv('NEXTAUTH_SECRET');
/**
* CSC cookie names; prefixed with `__Secure-` in production over HTTPS.
*
* Naming maps 1:1 to the CSC OAuth scope each cookie attests:
* - `csc_service_session` — service-scope grant (long-lived per-browser SCA
* attestation; lifetime = TSP `expires_in`).
* - `csc_sad_session` — credential-scope grant in progress (in-flight signing
* transaction; lifetime = SAD lifetime).
* - `csc_oauth_flow` — single-round-trip carrier across authorize → callback
* (scope-agnostic; both flows reuse it).
* - `csc_blocking_error` — callback failure surface; carries an unresolvable
* service-scope error (e.g. empty credential list, refused algorithm) to
* the next `/sign/{token}` loader, read-once.
*/
export const CSC_SERVICE_SESSION_COOKIE_NAME = formatSecureCookieName('csc_service_session');
export const CSC_SAD_SESSION_COOKIE_NAME = formatSecureCookieName('csc_sad_session');
export const CSC_OAUTH_FLOW_COOKIE_NAME = formatSecureCookieName('csc_oauth_flow');
export const CSC_BLOCKING_ERROR_COOKIE_NAME = formatSecureCookieName('csc_blocking_error');
/**
* Base options spread into every CSC cookie. Callers add per-cookie expiry
* (`maxAge` or `expires`) on top.
*/
export const cscCookieBaseOptions = {
httpOnly: true,
path: '/',
sameSite: useSecureCookies ? 'none' : 'lax',
secure: useSecureCookies,
domain: getCookieDomain(),
} as const;
@@ -0,0 +1,184 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
import { Prisma } from '@prisma/client';
/**
* DB helpers for `CscCredential` — the per-recipient row that holds the
* TSP-validated certificate chain, the resolved algorithm policy, and the
* encrypted service-scope access token.
*
* Lifecycle mirrors {@link sign-session.ts} but with a longer-lived row:
*
* - {@link upsertCscCredential} — service-scope OAuth callback writes the
* full credential after `credentials/info` + algorithm validation succeed.
* Re-runs replace prior bytes (cert / token rotates as the TSP refreshes).
* - {@link loadCscCredential} — sign-time fetches by `recipientId` to recover
* the persisted algorithm + encrypted service token; returns `null` when
* the recipient never completed service-scope OAuth.
*
* Encryption is the caller's job — both byte columns hold raw ciphertext
* produced by {@link encryptCscToken} so the helpers stay cipher-agnostic.
* Cascade cleanup on `Recipient` delete removes the row transitively.
*/
export type CscCredentialRow = {
id: string;
recipientId: number;
providerId: string;
credentialId: string;
certCache: Uint8Array | null;
signatureAlgorithm: string;
keyType: string;
digestAlgorithm: string;
keyLenBits: number | null;
signAlgoParams: string | null;
serviceTokenCiphertext: Uint8Array | null;
serviceTokenExpiresAt: Date | null;
createdAt: Date;
updatedAt: Date;
};
type UpsertCscCredentialInput = {
recipientId: number;
providerId: string;
credentialId: string;
/** Length-prefixed X.509 chain — produced from `cscCredentialsInfo.cert.certificates`. */
certCache: Uint8Array;
/** OID persisted from {@link CscAlgorithmPolicy.signAlgoOid}. */
signatureAlgorithm: string;
/** `'RSA'` or `'ECDSA'` from the resolved policy. */
keyType: string;
/** `'SHA-256'` / `'SHA-384'` / `'SHA-512'` from the resolved policy. */
digestAlgorithm: string;
keyLenBits: number;
/** RSASSA-PSS only; omit otherwise. */
signAlgoParams?: string;
/** Output of {@link encryptCscToken}. */
serviceTokenCiphertext: Uint8Array;
/** Mirrors the TSP's `expires_in` projected onto wall-clock. */
serviceTokenExpiresAt: Date;
};
/**
* Create or refresh the per-recipient credential row at service-scope OAuth
* callback success. Replaces every prior byte payload — a re-auth always
* supersedes the prior cert + token (TSPs may have rotated either).
*/
export const upsertCscCredential = async (input: UpsertCscCredentialInput): Promise<CscCredentialRow> => {
const {
recipientId,
providerId,
credentialId,
certCache,
signatureAlgorithm,
keyType,
digestAlgorithm,
keyLenBits,
signAlgoParams,
serviceTokenCiphertext,
serviceTokenExpiresAt,
} = input;
const row = await prisma.cscCredential.upsert({
where: { recipientId },
create: {
recipientId,
providerId,
credentialId,
certCache,
signatureAlgorithm,
keyType,
digestAlgorithm,
keyLenBits,
signAlgoParams: signAlgoParams ?? null,
serviceTokenCiphertext,
serviceTokenExpiresAt,
},
update: {
providerId,
credentialId,
certCache,
signatureAlgorithm,
keyType,
digestAlgorithm,
keyLenBits,
signAlgoParams: signAlgoParams ?? null,
serviceTokenCiphertext,
serviceTokenExpiresAt,
},
});
return toCscCredentialRow(row);
};
/**
* Fetch the credential row for a recipient. Returns `null` when absent — the
* recipient hasn't completed service-scope OAuth yet (loader path) or the
* recipient cascade fired (cleanup path). Both are normal terminal outcomes.
*/
export const loadCscCredential = async (recipientId: number): Promise<CscCredentialRow | null> => {
const row = await prisma.cscCredential.findUnique({
where: { recipientId },
});
return row ? toCscCredentialRow(row) : null;
};
/**
* Explicit delete by recipient id. Recipient-cascade handles routine cleanup;
* this helper is for operator-triggered re-auth flows (force the next visit
* to re-do service-scope OAuth even within the trust window).
*
* Throws `NOT_FOUND` when the row is already gone — semantically distinct
* from {@link loadCscCredential}'s nullable return because explicit delete
* is a deliberate operation and silent no-op would mask flow-state bugs.
*/
export const deleteCscCredential = async (recipientId: number): Promise<CscCredentialRow> => {
try {
const row = await prisma.cscCredential.delete({
where: { recipientId },
});
return toCscCredentialRow(row);
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2025') {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: `CSC credential for recipient ${recipientId} not found.`,
});
}
throw err;
}
};
const toCscCredentialRow = (row: {
id: string;
recipientId: number;
providerId: string;
credentialId: string;
certCache: Uint8Array | null;
signatureAlgorithm: string;
keyType: string;
digestAlgorithm: string;
keyLenBits: number | null;
signAlgoParams: string | null;
serviceTokenCiphertext: Uint8Array | null;
serviceTokenExpiresAt: Date | null;
createdAt: Date;
updatedAt: Date;
}): CscCredentialRow => ({
id: row.id,
recipientId: row.recipientId,
providerId: row.providerId,
credentialId: row.credentialId,
certCache: row.certCache,
signatureAlgorithm: row.signatureAlgorithm,
keyType: row.keyType,
digestAlgorithm: row.digestAlgorithm,
keyLenBits: row.keyLenBits,
signAlgoParams: row.signAlgoParams,
serviceTokenCiphertext: row.serviceTokenCiphertext,
serviceTokenExpiresAt: row.serviceTokenExpiresAt,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
});
@@ -0,0 +1,546 @@
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' };
};
@@ -0,0 +1,130 @@
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 type { CreateDocumentAuditLogDataResponse } from '@documenso/lib/utils/document-audit-logs';
import { prisma } from '@documenso/prisma';
import { HttpTimestampAuthority, PDF, type TimestampAuthority } from '@libpdf/core';
import type { DocumentData, DocumentMeta, Envelope, EnvelopeItem, Recipient, User } from '@prisma/client';
import { DocumentStatus } from '@prisma/client';
import { resolveCscSealTimeTsa } from './tsa-resolver';
/**
* TSP envelope finalisation step run from the `seal-document` job.
*
* Replaces the SES "decorate + p12 sign" pass: recipient bytes are already
* PAdES-signed by each recipient's CSC TSP, so the seal step is reduced to
* a per-item PAdES B-LTA upgrade — libpdf's `pdf.addArchivalData()` runs
* the full archive sequence (DSS for every existing signature + archival
* `/DocTimeStamp` + DSS for the timestamp's own chain), and the resulting
* bytes are copied in-place onto each `envelopeItem.documentData` row.
* `envelopeItem.documentDataId` stays stable across the whole envelope
* lifecycle (materialise → per-recipient signs → finalise) — mirrors the
* pattern used by `materializeTspAnchorsForEnvelope` and `executeTspSign`.
*
* Certificate / audit-log sidecar PDFs are intentionally NOT merged into
* the signed bytes here — they're rendered on-demand at download time so
* the signed PDF stays byte-identical to what each recipient's SAD
* authorised. Rejection and resealing are unsupported in V1 and rejected
* by the caller before this runs.
*/
export type FinalizeTspEnvelopeCompletionOptions = {
envelope: Envelope & {
documentMeta: DocumentMeta | null;
recipients: Recipient[];
envelopeItems: Array<EnvelopeItem & { documentData: DocumentData }>;
user: Pick<User, 'name' | 'email'>;
};
envelopeCompletedAuditLog: CreateDocumentAuditLogDataResponse;
requestMetadata?: RequestMetadata;
};
type ArchivedItem = {
/** Existing `envelopeItem.documentDataId` — target of the in-place update. */
envelopeItemDataId: string;
uploadedType: DocumentData['type'];
uploadedData: string;
};
export const finalizeTspEnvelopeCompletion = async (opts: FinalizeTspEnvelopeCompletionOptions): Promise<void> => {
const { envelope, envelopeCompletedAuditLog } = opts;
// Resolve the TSA up-front — fail fast if the instance is mis-configured
// before we start round-tripping PDF bytes through storage.
const tsa = resolveCscSealTimeTsa();
const timestampAuthority = buildLibpdfTsa(tsa);
const archivedItems: ArchivedItem[] = [];
for (const envelopeItem of envelope.envelopeItems) {
const pdfBytes = await getFileServerSide(envelopeItem.documentData);
const pdfDoc = await PDF.load(pdfBytes);
// PAdES B-LTA in one call. Internally:
// 1. Gather LTV (certs/OCSP/CRL) for every existing signed field and
// write a single DSS incremental update.
// 2. Add an archival `/DocTimeStamp` over the result.
// 3. Gather LTV for the new timestamp's own certificate chain.
// All three are append-only incremental updates — every prior recipient
// signature's `/ByteRange` stays valid.
const archived = await pdfDoc.addArchivalData({ timestampAuthority });
const { documentData: uploaded } = await putPdfFileServerSide(
{
name: envelopeItem.title.endsWith('.pdf') ? envelopeItem.title : `${envelopeItem.title}.pdf`,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(archived.bytes),
},
envelopeItem.documentData.initialData,
);
archivedItems.push({
envelopeItemDataId: envelopeItem.documentData.id,
uploadedType: uploaded.type,
uploadedData: uploaded.data,
});
}
// Single tx: per-item in-place data updates + envelope status flip +
// completion audit log. `envelopeItem.documentDataId` is preserved; the
// freshly-uploaded `DocumentData` rows orbit as orphans.
await prisma.$transaction(async (tx) => {
for (const { envelopeItemDataId, uploadedType, uploadedData } of archivedItems) {
await tx.documentData.update({
where: { id: envelopeItemDataId },
data: { type: uploadedType, data: uploadedData },
});
}
await tx.envelope.update({
where: { id: envelope.id },
data: {
status: DocumentStatus.COMPLETED,
completedAt: new Date(),
},
});
await tx.documentAuditLog.create({
data: envelopeCompletedAuditLog,
});
});
};
/**
* Wrap a resolved seal-time TSA config into a libpdf `TimestampAuthority`.
*
* Env only at seal time — the archival `/DocTimeStamp` is the operator's
* long-term trust anchor and SHOULD point at a dedicated qualified archival
* TSA (e.g. DigiCert) that's independent of the per-recipient TSP. We
* deliberately don't fall back to the TSP here: doing so would couple the
* archive's longevity to a TSP that may revoke or rotate without notice,
* and would require keeping a live service-scope bearer around at the
* seal-document job which has no recipient context anyway.
*
* First URL only — multi-URL fallback can layer on later via a composite
* wrapper if operators need it.
*/
const buildLibpdfTsa = (tsa: { urls: string[] }): TimestampAuthority => {
return new HttpTimestampAuthority(tsa.urls[0]);
};
@@ -0,0 +1,16 @@
import type { logger } from '@documenso/lib/utils/logger';
/**
* CSC subapp Hono context. Mirrors the subset of `apps/remix/server/router.ts`
* `HonoEnv` that CSC handlers actually read. Duplicated (rather than imported
* from `apps/remix/`) to keep the `packages/ee` → `apps/remix` dep direction
* unidirectional.
*
* Runtime contract: the remix host's middleware sets `logger` on every request
* before the CSC subapp runs; the CSC subapp does not set it itself.
*/
export type HonoCscEnv = {
Variables: {
logger: typeof logger;
};
};
@@ -0,0 +1,63 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { Hono } from 'hono';
import { HTTPException } from 'hono/http-exception';
import type { ContentfulStatusCode } from 'hono/utils/http-status';
import type { HonoCscEnv } from './context';
import { cscOAuthAuthorizeRoute } from './oauth-authorize';
import { cscOAuthCallbackRoute } from './oauth-callback';
/**
* `@documenso/ee` CSC subapp. Mount under `/api/csc` in the remix host (see
* `apps/remix/server/router.ts`). All CSC endpoints — OAuth authorize +
* callback — are composed here so the host only has to wire one route.
*
* Routes throw `AppError` freely; the `.onError` handler below normalises
* them into REST responses (mirrors `@documenso/auth/server`'s pattern).
*/
export const csc = new Hono<HonoCscEnv>()
.route('/oauth/authorize', cscOAuthAuthorizeRoute)
.route('/oauth/callback', cscOAuthCallbackRoute);
csc.onError((err, c) => {
const logger = c.get('logger');
if (err instanceof HTTPException) {
return c.json(
{
code: AppErrorCode.UNKNOWN_ERROR,
message: err.message,
statusCode: err.status,
},
err.status,
);
}
if (err instanceof AppError) {
const { status, body } = AppError.toRestAPIError(err);
logger.error({
event: 'csc.error',
code: err.code,
message: err.message,
});
return c.json(body, status as ContentfulStatusCode);
}
logger.error({
event: 'csc.unknown_error',
error: err,
});
return c.json(
{
code: AppErrorCode.UNKNOWN_ERROR,
message: 'Internal Server Error',
statusCode: 500,
},
500,
);
});
export type CscAppType = typeof csc;
@@ -0,0 +1,154 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
import { prisma } from '@documenso/prisma';
import { sValidator } from '@hono/standard-validator';
import { Hono } from 'hono';
import { z } from 'zod';
import {
buildCscCredentialScopeAuthorizeUrl,
buildCscServiceScopeAuthorizeUrl,
generateCodeVerifier,
generateState,
} from '../client/oauth';
import { setCscOAuthFlowCookie } from '../cookies/oauth-flow-cookie';
import { loadCscCredential } from '../credential';
import { loadCscSession } from '../sign-session';
import { getCscTransport } from '../transport';
import type { HonoCscEnv } from './context';
/**
* `GET /api/csc/oauth/authorize` — initiates the CSC OAuth round-trip and
* 302-redirects to the TSP's authorize URL with a signed `csc_oauth_flow`
* cookie carrying the state, PKCE verifier, and recipient context the
* callback needs to resume the flow.
*
* Branches on `?scope=service|credential`:
* - `service`: authorised by recipient token; precedes credentials/list.
* - `credential`: authorised by an active `CscSession`; binds the issued SAD
* to the per-item hashes captured at prep.
*
* Errors bubble to the parent app's `.onError` handler (see `./index.ts`).
*/
const ZAuthorizeQuerySchema = z.discriminatedUnion('scope', [
z.object({
scope: z.literal('service'),
token: z.string().min(1),
}),
z.object({
scope: z.literal('credential'),
session: z.string().min(1),
}),
]);
export const cscOAuthAuthorizeRoute = new Hono<HonoCscEnv>().get(
'/',
sValidator('query', ZAuthorizeQuerySchema),
async (c) => {
const logger = c.get('logger');
const query = c.req.valid('query');
const transport = await getCscTransport();
if (query.scope === 'service') {
const recipient = await getRecipientByToken({ token: query.token }).catch(() => null);
if (!recipient) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Recipient not found for the provided token.',
});
}
logger.info({
event: 'csc.oauth.authorize.start',
scope: 'service',
recipientId: recipient.id,
});
const state = generateState();
const codeVerifier = generateCodeVerifier();
const authorizeUrl = buildCscServiceScopeAuthorizeUrl({
client: transport.oauthClient,
oauthBaseUrl: transport.oauthBaseUrl,
state,
codeVerifier,
});
await setCscOAuthFlowCookie({
c,
payload: {
scope: 'service',
state,
codeVerifier,
recipientToken: query.token,
},
});
return c.redirect(authorizeUrl.toString(), 302);
}
const session = await loadCscSession(query.session);
if (!session) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'CSC session not found or already consumed.',
});
}
const credential = await loadCscCredential(session.recipientId);
if (!credential) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'CSC credential missing — service-scope OAuth must complete first.',
});
}
const recipient = await prisma.recipient.findUnique({
where: { id: session.recipientId },
select: { token: true },
});
if (!recipient) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Recipient not found for the CSC session.',
});
}
logger.info({
event: 'csc.oauth.authorize.start',
scope: 'credential',
recipientId: session.recipientId,
sessionId: session.id,
numSignatures: session.items.length,
});
const state = generateState();
const codeVerifier = generateCodeVerifier();
const authorizeUrl = buildCscCredentialScopeAuthorizeUrl({
client: transport.oauthClient,
oauthBaseUrl: transport.oauthBaseUrl,
state,
codeVerifier,
credentialId: credential.credentialId,
numSignatures: session.items.length,
hashes: session.items.map((item) => item.hashB64),
});
await setCscOAuthFlowCookie({
c,
payload: {
scope: 'credential',
state,
codeVerifier,
recipientToken: recipient.token,
sessionId: session.id,
},
});
return c.redirect(authorizeUrl.toString(), 302);
},
);
@@ -0,0 +1,303 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import { extractRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { formatSigningLink } from '@documenso/lib/utils/recipients';
import { prisma } from '@documenso/prisma';
import { sValidator } from '@hono/standard-validator';
import { Hono } from 'hono';
import { z } from 'zod';
import { resolveCscAlgorithmPolicy } from '../algorithm-resolver';
import { encodeCscCertChain } from '../cert-chain';
import { encryptCscToken } from '../ciphers';
import { cscCredentialsInfo, cscCredentialsList } from '../client/credentials';
import { exchangeCscAuthorizationCode } from '../client/oauth';
import { setCscBlockingErrorCookie } from '../cookies/blocking-error-cookie';
import { clearCscOAuthFlowCookie, getCscOAuthFlowCookie } from '../cookies/oauth-flow-cookie';
import { setCscSadSessionCookie } from '../cookies/sad-session-cookie';
import { setCscServiceSessionCookie } from '../cookies/service-session-cookie';
import { loadCscCredential, upsertCscCredential } from '../credential';
import { updateCscSessionWithSad } from '../sign-session';
import { getCscTransport } from '../transport';
import type { HonoCscEnv } from './context';
/**
* `GET /api/csc/oauth/callback` — landing point for the recipient's return
* from the TSP after the round-trip initiated by `oauth-authorize`. Reads
* the `csc_oauth_flow` cookie, verifies CSRF, exchanges the code, and
* branches on the cookie's `scope`:
*
* - `service`: pulls `credentials/list` + `credentials/info`, validates the
* cert + algorithm policy, persists the `CscCredential` row + service
* token, sets the `csc_service_session` cookie, and redirects to
* `/sign/{token}`. Blocking validation errors (empty list, bad cert,
* refused algorithm) round-trip via the `csc_blocking_error` cookie so the
* signing-page loader can render a stable error UI.
* - `credential`: exchanges code → SAD, stamps it onto the existing
* `CscSession`, sets the `csc_sad_session` cookie, and redirects to
* `/sign/{token}`. Credential-scope failures bubble to `.onError` — the
* recipient simply re-clicks Sign.
*
* Non-blocking errors bubble to the parent app's `.onError` (see
* `./index.ts`) — mirrors `oauth-authorize.ts`.
*/
const ZCallbackQuerySchema = z.object({
state: z.string().min(1),
code: z.string().min(1).optional(),
error: z.string().min(1).optional(),
error_description: z.string().optional(),
});
const BLOCKING_SERVICE_ERROR_CODES = new Set<string>([
AppErrorCode.CSC_CREDENTIAL_LIST_EMPTY,
AppErrorCode.CSC_CERT_INVALID,
AppErrorCode.CSC_ALGORITHM_REFUSED,
]);
const isBlockingServiceError = (code: string): boolean => BLOCKING_SERVICE_ERROR_CODES.has(code);
export const cscOAuthCallbackRoute = new Hono<HonoCscEnv>().get(
'/',
sValidator('query', ZCallbackQuerySchema),
async (c) => {
const logger = c.get('logger');
const query = c.req.valid('query');
const cookie = await getCscOAuthFlowCookie(c);
if (!cookie) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'CSC OAuth flow cookie missing or expired.',
});
}
if (query.state !== cookie.state) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'CSC OAuth callback state mismatch — possible CSRF.',
});
}
// The single-round-trip carrier is spent regardless of subsequent
// outcome; clear it now so a retry restarts from `/api/csc/oauth/authorize`.
clearCscOAuthFlowCookie(c);
if (query.error) {
throw new AppError(AppErrorCode.CSC_REQUEST_FAILED, {
message: `CSC TSP returned OAuth error: ${query.error}${query.error_description ? ' — ' + query.error_description : ''}`,
});
}
if (!query.code) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'CSC OAuth callback missing code parameter.',
});
}
const transport = await getCscTransport();
const recipient = await getRecipientByToken({ token: cookie.recipientToken }).catch(() => null);
if (!recipient) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Recipient not found for CSC OAuth flow cookie.',
});
}
if (cookie.scope === 'service') {
const tokens = await exchangeCscAuthorizationCode({
client: transport.oauthClient,
oauthBaseUrl: transport.oauthBaseUrl,
code: query.code,
codeVerifier: cookie.codeVerifier,
});
try {
const listResp = await cscCredentialsList({
baseUrl: transport.serviceBaseUrl,
accessToken: tokens.accessToken(),
});
// V1 picks the first credential per spec section "Out of scope for
// V1": multi-credential selection UI lands in a later iteration.
const credentialId = listResp.credentialIDs[0];
const infoResp = await cscCredentialsInfo({
baseUrl: transport.serviceBaseUrl,
accessToken: tokens.accessToken(),
credentialID: credentialId,
certificates: 'chain',
certInfo: true,
});
const policy = resolveCscAlgorithmPolicy(infoResp);
if (!infoResp.cert.certificates || infoResp.cert.certificates.length === 0) {
throw new AppError(AppErrorCode.CSC_CERT_INVALID, {
message: 'CSC credential info response omitted required certificate chain.',
});
}
const certCache = encodeCscCertChain(infoResp.cert.certificates);
const serviceTokenCiphertext = encryptCscToken(tokens.accessToken());
const serviceTokenExpiresAt = tokens.accessTokenExpiresAt();
await upsertCscCredential({
recipientId: recipient.id,
providerId: transport.serviceBaseUrl,
credentialId,
certCache,
signatureAlgorithm: policy.signAlgoOid,
keyType: policy.keyType,
digestAlgorithm: policy.digestAlgorithm,
keyLenBits: policy.keyLenBits,
serviceTokenCiphertext,
serviceTokenExpiresAt,
});
await setCscServiceSessionCookie({
c,
recipientToken: cookie.recipientToken,
ttlSeconds: tokens.accessTokenExpiresInSeconds(),
});
await prisma.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_CSC_AUTHENTICATED,
envelopeId: recipient.envelopeId,
user: { name: recipient.name, email: recipient.email },
requestMetadata: extractRequestMetadata(c.req.raw),
data: {
recipientEmail: recipient.email,
recipientName: recipient.name,
recipientId: recipient.id,
recipientRole: recipient.role,
providerId: transport.serviceBaseUrl,
credentialId,
signatureAlgorithm: policy.signAlgoOid,
digestAlgorithm: policy.digestAlgorithm,
},
}),
});
logger.info({
event: 'csc.oauth.callback.service.complete',
recipientId: recipient.id,
});
return c.redirect(formatSigningLink(cookie.recipientToken), 302);
} catch (err) {
if (err instanceof AppError && isBlockingServiceError(err.code)) {
await setCscBlockingErrorCookie({
c,
payload: { code: err.code, recipientToken: cookie.recipientToken },
});
await prisma.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_CSC_AUTHENTICATION_FAILED,
envelopeId: recipient.envelopeId,
user: { name: recipient.name, email: recipient.email },
requestMetadata: extractRequestMetadata(c.req.raw),
data: {
recipientEmail: recipient.email,
recipientName: recipient.name,
recipientId: recipient.id,
recipientRole: recipient.role,
providerId: transport.serviceBaseUrl,
reason: err.code,
},
}),
});
logger.warn({
event: 'csc.oauth.callback.service.blocking',
recipientId: recipient.id,
code: err.code,
});
return c.redirect(formatSigningLink(cookie.recipientToken), 302);
}
throw err;
}
}
if (!cookie.sessionId) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'CSC credential-scope OAuth callback missing sessionId in cookie.',
});
}
const tokens = await exchangeCscAuthorizationCode({
client: transport.oauthClient,
oauthBaseUrl: transport.oauthBaseUrl,
code: query.code,
codeVerifier: cookie.codeVerifier,
});
// CSC §8.3.3 says credential-scope returns `token_type === 'SAD'`. We
// don't hard-fail on a divergent label — the binding is by scope + hash,
// not by `token_type` — but we log so operator metrics can spot loose
// TSPs.
if (tokens.tokenType() !== 'SAD') {
logger.warn({
event: 'csc.oauth.callback.credential.unexpected_token_type',
actual: tokens.tokenType(),
});
}
const sadCiphertext = encryptCscToken(tokens.accessToken());
const sadExpiresAt = tokens.accessTokenExpiresAt();
await updateCscSessionWithSad({
sessionId: cookie.sessionId,
encryptedSad: sadCiphertext,
sadExpiresAt,
});
await setCscSadSessionCookie({
c,
sessionId: cookie.sessionId,
expiresAt: sadExpiresAt,
});
const credential = await loadCscCredential(recipient.id);
if (!credential) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'CSC credential missing at credential-scope callback.',
});
}
await prisma.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_CSC_AUTHORIZED,
envelopeId: recipient.envelopeId,
user: { name: recipient.name, email: recipient.email },
requestMetadata: extractRequestMetadata(c.req.raw),
data: {
recipientEmail: recipient.email,
recipientName: recipient.name,
recipientId: recipient.id,
recipientRole: recipient.role,
providerId: credential.providerId,
credentialId: credential.credentialId,
sessionId: cookie.sessionId,
sadExpiresAt,
},
}),
});
logger.info({
event: 'csc.oauth.callback.credential.complete',
recipientId: recipient.id,
sessionId: cookie.sessionId,
});
return c.redirect(formatSigningLink(cookie.recipientToken), 302);
},
);
@@ -0,0 +1,230 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { isTspEnvelope } from '@documenso/lib/types/signature-level';
import { getFileServerSide } from '@documenso/lib/universal/upload/get-file.server';
import { putPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server';
import { prisma } from '@documenso/prisma';
import { PDF } from '@libpdf/core';
import { buildTspAnchorName, buildTspStampName } from './pdf-names';
export type MaterializeTspAnchorsForEnvelopeOptions = {
envelopeId: string;
};
/**
* Pre-allocate per-recipient AcroForm signature anchors and per-page `/Stamp`
* overlay annotations on every envelope item of a TSP (AES/QES) envelope.
*
* Mutates the existing `DocumentData` row in place — the `envelopeItem.
* documentDataId` pointer is preserved across materialisation. Materialise
* is distribution housekeeping (pre-allocate fixed anchor slots before any
* recipient signs), not a content version bump, so a pointer swap +
* audit-log entry would mis-attribute the change. The new uploaded row
* created by `putPdfFileServerSide` is kept as an orphan rather than
* deleted — it preserves the standard upload mechanics (S3 PUT or BYTES_64
* encode) without a separate "copy then drop" dance.
*
* Idempotent: re-runs are no-ops when every expected anchor/stamp is
* already present. No-op for SES envelopes.
*/
export const materializeTspAnchorsForEnvelope = async ({
envelopeId,
}: MaterializeTspAnchorsForEnvelopeOptions): Promise<void> => {
const envelope = await prisma.envelope.findUnique({
where: {
id: envelopeId,
},
include: {
recipients: true,
envelopeItems: {
include: {
documentData: true,
},
},
fields: {
select: {
recipientId: true,
envelopeItemId: true,
page: true,
},
},
},
});
if (!envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: `Envelope ${envelopeId} not found`,
});
}
if (!isTspEnvelope(envelope)) {
return;
}
if (envelope.recipients.length === 0) {
return;
}
for (const envelopeItem of envelope.envelopeItems) {
const expectedAnchorNames = envelope.recipients.map((recipient) =>
buildTspAnchorName(recipient.id, envelopeItem.id),
);
const expectedStampNames: string[] = [];
for (const recipient of envelope.recipients) {
const pagesWithFields = new Set<number>();
for (const field of envelope.fields) {
if (field.recipientId === recipient.id && field.envelopeItemId === envelopeItem.id) {
pagesWithFields.add(field.page);
}
}
for (const page of pagesWithFields) {
expectedStampNames.push(buildTspStampName(recipient.id, envelopeItem.id, page));
}
}
const bytes = await getFileServerSide(envelopeItem.documentData);
const pdfDoc = await PDF.load(bytes);
if (isAlreadyMaterialised(pdfDoc, expectedAnchorNames, expectedStampNames)) {
continue;
}
// Bake operator AcroForm, annotations and OCG layers into static graphics
// so the materialised PDF is a deterministic surface. `skipSignatures`
// preserves any operator-placed signature widgets and (on re-materialise)
// the TSP anchors created previously.
pdfDoc.flattenAll({
form: {
skipSignatures: true,
},
});
const form = pdfDoc.getOrCreateForm();
if (pdfDoc.getPageCount() === 0) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: `Envelope item ${envelopeItem.id} PDF has no pages`,
});
}
// Anchors are AcroForm signature fields with no pre-attached widget.
// libpdf forbids `drawField` for signature fields — at sign time
// `pdf.sign({ fieldName })` promotes the existing field dict in place
// to a merged field/widget (Type=Annot, Subtype=Widget, P=page0,
// Rect=[0,0,0,0]) without modifying the page object. That preserves the
// per-recipient `/ByteRange` invariant across sequential signatures.
for (const anchorName of expectedAnchorNames) {
if (form.getSignatureField(anchorName)) {
continue;
}
form.createSignatureField(anchorName);
}
for (const recipient of envelope.recipients) {
const pagesWithFields = new Set<number>();
for (const field of envelope.fields) {
if (field.recipientId === recipient.id && field.envelopeItemId === envelopeItem.id) {
pagesWithFields.add(field.page);
}
}
for (const pageNumber of pagesWithFields) {
const stampName = buildTspStampName(recipient.id, envelopeItem.id, pageNumber);
const page = pdfDoc.getPage(pageNumber - 1);
if (!page) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: `Envelope item ${envelopeItem.id} missing page ${pageNumber} referenced by field`,
});
}
const existing = page.getStampAnnotations().some((stamp) => stamp.stampName === stampName);
if (existing) {
continue;
}
page.addStampAnnotation({
name: stampName,
rect: {
x: 0,
y: 0,
width: page.width,
height: page.height,
},
});
}
}
const newBytes = await pdfDoc.save({ useXRefStream: true });
// CRITICAL: persist via `putPdfFileServerSide` (raw). The normalised path
// would call `form.flatten()` without `skipSignatures` and wipe anchors.
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(newBytes),
},
envelopeItem.documentData.initialData ?? undefined,
);
// Copy the persisted bytes reference (S3 key or BYTES_64 payload) onto the
// existing DocumentData row in place. `envelopeItem.documentDataId` stays
// put — see file-level docblock for the rationale.
await prisma.documentData.update({
where: { id: envelopeItem.documentDataId },
data: {
type: uploaded.documentData.type,
data: uploaded.documentData.data,
},
});
}
};
/**
* Whole-item idempotency probe: returns true only when every expected anchor
* and stamp name is already present on the loaded PDF. Partial state is
* treated as not-materialised — the whole item is rebuilt.
*/
const isAlreadyMaterialised = (pdfDoc: PDF, expectedAnchorNames: string[], expectedStampNames: string[]): boolean => {
const form = pdfDoc.getForm();
if (!form) {
return expectedAnchorNames.length === 0 && expectedStampNames.length === 0;
}
for (const anchorName of expectedAnchorNames) {
if (!form.getSignatureField(anchorName)) {
return false;
}
}
if (expectedStampNames.length === 0) {
return true;
}
const presentStampNames = new Set<string>();
for (let i = 0; i < pdfDoc.getPageCount(); i++) {
const page = pdfDoc.getPage(i);
if (!page) {
continue;
}
for (const stamp of page.getStampAnnotations()) {
presentStampNames.add(stamp.stampName);
}
}
return expectedStampNames.every((name) => presentStampNames.has(name));
};
@@ -0,0 +1,23 @@
import { bytesToHex, utf8ToBytes } from '@noble/ciphers/utils';
import { sha1 } from '@noble/hashes/legacy';
/**
* Deterministic PDF object names for CSC TSP signing.
*
* Materialise-time and sign-time both derive these from the same
* `(recipient, item [, page])` tuple — they MUST agree byte-for-byte.
*
* Output is opaque: SHA-1(label) hex-encoded uppercase (40 chars). The PDF
* persists only the hex serial so recipient / envelope-item IDs never leak
* into the document.
*/
const hashToOpaqueSerial = (label: string): string => bytesToHex(sha1(utf8ToBytes(label))).toUpperCase();
/** AcroForm signature-field name (TSP anchor) for a recipient + envelope item. */
export const buildTspAnchorName = (recipientId: number, envelopeItemId: string): string =>
hashToOpaqueSerial(`recipient:${recipientId}|item:${envelopeItemId}`);
/** `/Stamp` annotation name for a recipient + envelope item on a specific page. */
export const buildTspStampName = (recipientId: number, envelopeItemId: string, pageNumber: number): string =>
hashToOpaqueSerial(`recipient:${recipientId}|item:${envelopeItemId}|page:${pageNumber}`);
@@ -0,0 +1,248 @@
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { TCscSessionItems } from '@documenso/lib/types/csc-session';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import { isTspEnvelope } from '@documenso/lib/types/signature-level';
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 { prisma } from '@documenso/prisma';
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import { PDF } from '@libpdf/core';
import { type CscDigest, policyToLibpdfSignerAlgo } from './algorithm-resolver';
import { decodeCscCertChain } from './cert-chain';
import { loadCscCredential } from './credential';
import { buildTspAnchorName, buildTspStampName } from './pdf-names';
import { renderRecipientOverlay } from './render-overlay';
import { upsertCscSession } from './sign-session';
import { CscCaptureSigner } from './signers/capture-signer';
/**
* CSC TSP prep-phase orchestrator.
*
* Per envelope item:
*
* 1. Render the recipient's overlay into the materialised PDF in memory.
* 2. Persist the rendered bytes as a fresh `DocumentData` row — this is the
* immutable byte-source the sign pass will load. Pinning the rendered PDF
* (rather than re-rendering at sign time) eliminates the determinism risk
* of running Konva twice across the OAuth round-trip.
* 3. Reload `pdfDoc` from the persisted bytes and dry-run `pdf.sign` with
* `CscCaptureSigner` to derive the `signedAttrs` digest — captured over
* the same bytes the sign pass will load.
*
* The resulting `{ envelopeItemId, documentDataId, hashB64, ordinal }` tuples
* are stored on `CscSession.itemsJson`. `documentDataId` pins the orphan
* rendered row, not `envelopeItem.documentDataId` — the latter stays stable
* (in-place data updates only, mirroring the materialise pattern).
*
* Sequential per item — PDF parse + libpdf sign is CPU-heavy and per-recipient
* concurrency is wasted on a single Node event loop.
*/
export type PrepareCscRecipientSigningOptions = {
/** Recipient token from `/sign/{token}` URL. */
recipientToken: string;
/** Forwarded for audit log attribution. */
requestMetadata?: RequestMetadata;
};
export type PrepareCscRecipientSigningResult = {
status: 'REDIRECT';
redirectUrl: string;
};
export const prepareCscRecipientSigning = async (
opts: PrepareCscRecipientSigningOptions,
): Promise<PrepareCscRecipientSigningResult> => {
const { recipientToken, requestMetadata } = opts;
const recipient = await prisma.recipient
.findFirst({
where: { token: recipientToken },
// `signature` must be eager-loaded — `renderRecipientOverlay` runs the
// field renderer in `export` mode, which throws `MISSING_SIGNATURE` for
// any inserted SIGNATURE field without signature data. Mirrors the
// include pattern in `seal-document.handler.ts`.
include: { fields: { include: { signature: true } } },
})
.catch(() => null);
if (!recipient) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: `Recipient with token "${recipientToken}" not found.`,
});
}
const envelope = await prisma.envelope.findUniqueOrThrow({
where: { id: recipient.envelopeId },
include: {
envelopeItems: {
include: {
documentData: true,
},
},
recipients: true,
},
});
if (!isTspEnvelope(envelope)) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'prepareCscRecipientSigning called for a non-TSP envelope.',
});
}
const credential = await loadCscCredential(recipient.id);
if (!credential) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'CSC credential missing — service-scope OAuth must complete first.',
});
}
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.',
});
}
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,
// `policyToLibpdfSignerAlgo` does not read `hashAlgoOid`; passing empty
// string keeps the synthetic policy type-correct without re-derivation.
hashAlgoOid: '',
});
// Pin a single signingTime for every per-item capture so the embed pass
// re-derives byte-identical signedAttrs digests.
const signingTime = new Date();
const items: TCscSessionItems = [];
for (const envelopeItem of envelope.envelopeItems) {
const recipientFieldsOnItem = recipient.fields.filter((field) => field.envelopeItemId === envelopeItem.id);
const pagesWithFields = new Set<number>();
for (const field of recipientFieldsOnItem) {
pagesWithFields.add(field.page);
}
const bytes = await getFileServerSide(envelopeItem.documentData);
const pdfDoc = await PDF.load(bytes);
for (const pageNumber of pagesWithFields) {
const fieldsOnPage: FieldWithSignature[] = recipientFieldsOnItem.filter((field) => field.page === pageNumber);
await renderRecipientOverlay({
pdfDoc,
stampName: buildTspStampName(recipient.id, envelopeItem.id, pageNumber),
pageNumber,
fields: fieldsOnPage,
});
}
// Persist the rendered PDF as an orphan `DocumentData` row before the
// capture pass so sign-time can load byte-identical input — eliminates
// the determinism risk of running Konva again after the OAuth round-trip.
const renderedBytes = await pdfDoc.save({ incremental: true });
const fileName = envelope.title.endsWith('.pdf') ? envelope.title : `${envelope.title || 'envelope'}.pdf`;
const renderedUpload = await putPdfFileServerSide(
{
name: fileName,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(renderedBytes),
},
envelopeItem.documentData.initialData ?? undefined,
);
// Reload from the persisted bytes so the capture pass operates on the
// exact same bytes the sign pass will fetch from storage. Skipping the
// reload would compute the digest over an in-memory incremental update
// that diverges from what `PDF.load(renderedBytes)` produces.
const capturePdfDoc = await PDF.load(renderedBytes);
const captureSigner = new CscCaptureSigner({
certificate: chain[0],
certificateChain: chain.slice(1),
algo,
});
const anchorName = buildTspAnchorName(recipient.id, envelopeItem.id);
// Capture at B-B even though the eventual embed pass is B-T. The B-T
// signature timestamp is a CMS *unsigned* attribute, added by libpdf
// after `signer.sign()` runs over the signed-attrs digest — so B-B and
// B-T produce byte-identical signed-attrs for the same `(signer,
// documentHash, digestAlgorithm, signingTime)` tuple. See the matching
// note in `execute-tsp-sign.ts`.
await capturePdfDoc.sign({
signer: captureSigner,
fieldName: anchorName,
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 prep.',
});
}
items.push({
envelopeItemId: envelopeItem.id,
documentDataId: renderedUpload.documentData.id,
hashB64: Buffer.from(captureSigner.capturedDigest).toString('base64'),
ordinal: items.length,
});
}
const session = await upsertCscSession({
recipientId: recipient.id,
envelopeId: envelope.id,
signingTime,
items,
});
await prisma.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_CSC_SIGN_REQUESTED,
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: session.id,
numSignatures: items.length,
},
}),
});
const redirectUrl = `${NEXT_PUBLIC_WEBAPP_URL()}/api/csc/oauth/authorize?scope=credential&session=${session.id}`;
return {
status: 'REDIRECT',
redirectUrl,
};
};
@@ -0,0 +1,162 @@
import { AnnotationFlags, ops, PDF, PdfArray, PdfDict, PdfName, PdfNumber } from '@libpdf/core';
// `Operator` is declared in `@libpdf/core` but not exported. Derive it from
// `ops.pushGraphicsState`'s return type instead of importing.
type LibpdfOperator = ReturnType<typeof ops.pushGraphicsState>;
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { insertFieldInPDFV2 } from '@documenso/lib/server-only/pdf/insert-field-in-pdf-v2';
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
/**
* CSC TSP recipient overlay renderer.
*
* Writes a recipient's per-page field values into the pre-allocated
* `/Stamp` annotation's normal appearance (`/AP /N`), reusing the Konva
* overlay generator that powers the SES path.
*
* SES uses `page.drawPage(embeddedPage)` to paint directly onto the page
* content stream. For TSP that would create a new page object in the
* incremental update and invalidate prior recipients' `/ByteRange`. Routing
* the same embedded FormXObject through a stamp's appearance keeps the page
* dict untouched while reusing the embed pipeline `drawPage` does.
*
* The appearance stream mirrors `drawPage`'s `x=0, y=0, scale=1, no-rotate`
* branch: a single `concatMatrix(1, 0, 0, 1, -box.x, -box.y)` compensates
* for any non-origin MediaBox on the overlay PDF before `paintXObject`. The
* stamp's `/Rect` and the appearance `/BBox` both span `[0, 0, page.width,
* page.height]`, so the PDF reader maps content 1:1 and page rotation
* applies at the page level (not inside the appearance).
*/
export type RenderRecipientOverlayOptions = {
/** The loaded PDF the stamp lives on. */
pdfDoc: PDF;
/** Stamp name from `buildTspStampName(recipientId, envelopeItemId, pageNumber)`. */
stampName: string;
/** 1-based page number. */
pageNumber: number;
/** Recipient's fields for THIS page only. */
fields: FieldWithSignature[];
};
/**
* Render `fields` into the pre-allocated `/Stamp` annotation named `stampName`
* on `pageNumber`. Mutates `pdfDoc` in place.
*
* Throws when the named stamp can't be located — every call site must have
* materialised the stamp first via `materializeTspAnchorsForEnvelope`.
*/
export const renderRecipientOverlay = async ({
pdfDoc,
stampName,
pageNumber,
fields,
}: RenderRecipientOverlayOptions): Promise<void> => {
const page = pdfDoc.getPage(pageNumber - 1);
if (!page) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: `Page ${pageNumber} not found on PDF.`,
});
}
const stamp = page.getStampAnnotations().find((annotation) => annotation.stampName === stampName);
if (!stamp) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: `TSP stamp ${stampName} not found on page ${pageNumber}.`,
});
}
const overlayBytes = await insertFieldInPDFV2({
pageWidth: page.width,
pageHeight: page.height,
fields,
});
const overlayDoc = await PDF.load(overlayBytes);
const embedded = await pdfDoc.embedPage(overlayDoc, 0);
// Bind the embedded page under a local XObject name in the appearance's
// own /Resources. Appearance streams are scoped — they can't see the
// parent page's resource dict.
const xobjectName = 'X0';
// Mirror `PDFPage.drawPage`'s no-rotation, no-scale branch:
// translateX = x - embedded.box.x * scaleX (x = 0, scaleX = 1)
// translateY = y - embedded.box.y * scaleY (y = 0, scaleY = 1)
// concatMatrix(scaleX, 0, 0, scaleY, translateX, translateY)
// Identity matrix when the overlay PDF has an origin-aligned MediaBox;
// a translate-only shift otherwise. No-op cost is negligible.
const operators: LibpdfOperator[] = [
ops.pushGraphicsState(),
ops.concatMatrix(1, 0, 0, 1, -embedded.box.x, -embedded.box.y),
ops.paintXObject(xobjectName),
ops.popGraphicsState(),
];
const contentBytes = serializeOperators(operators);
const appearanceRef = pdfDoc.createStream(
{
Type: PdfName.of('XObject'),
Subtype: PdfName.of('Form'),
FormType: PdfNumber.of(1),
BBox: new PdfArray([PdfNumber.of(0), PdfNumber.of(0), PdfNumber.of(page.width), PdfNumber.of(page.height)]),
Resources: PdfDict.of({
XObject: PdfDict.of({
[xobjectName]: embedded.ref,
}),
}),
},
contentBytes,
);
// Direct dict write — bypasses `PDFAnnotation.setNormalAppearance`, which
// (a) re-registers the stream and (b) has a no-op branch when `/AP` is
// absent on the annotation. See `node_modules/@libpdf/core/dist/index.mjs:
// 4347-4357`. The PDF reader and libpdf's `getAppearance` (index.mjs:4337)
// both follow refs transparently, so `/AP -> { N: <ref> }` is valid.
stamp.dict.set('AP', PdfDict.of({ N: appearanceRef }));
stamp.setFlag(AnnotationFlags.Print, true);
stamp.setFlag(AnnotationFlags.ReadOnly, true);
stamp.setFlag(AnnotationFlags.Locked, true);
stamp.setFlag(AnnotationFlags.LockedContents, true);
};
/**
* Serialize a content-stream operator sequence into a single byte buffer,
* newline-separated. Mirrors libpdf's internal `serializeOperators` (not
* exported from `@libpdf/core`); each `Operator.toBytes()` returns one
* operator's `operand1 operand2 ... op` slice.
*/
const serializeOperators = (operators: LibpdfOperator[]): Uint8Array => {
if (operators.length === 0) {
return new Uint8Array(0);
}
const chunks = operators.map((operator) => operator.toBytes());
let totalLength = 0;
for (const chunk of chunks) {
totalLength += chunk.length + 1; // +1 for trailing newline
}
const out = new Uint8Array(totalLength);
let offset = 0;
for (const chunk of chunks) {
out.set(chunk, offset);
offset += chunk.length;
out[offset] = 0x0a;
offset += 1;
}
return out;
};
@@ -0,0 +1,181 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { type TCscSessionItems, ZCscSessionItemsSchema } from '@documenso/lib/types/csc-session';
import { prisma } from '@documenso/prisma';
import { Prisma } from '@prisma/client';
/**
* DB helpers for `CscSession` — the per-recipient transient row that bridges
* prep, the credential-scope OAuth round-trip, and the sync sign mutation.
*
* Four operations cover the spec's lifecycle:
*
* - {@link upsertCscSession} — prep time; clears any prior SAD by writing
* `encryptedSad = null` so a re-clicked Sign starts fresh.
* - {@link updateCscSessionWithSad} — credential-scope callback; sets the
* SAD + its TSP-asserted expiry.
* - {@link loadCscSession} — authorize route, signing-page loader, sync
* mutation. Returns null on missing (cookie referenced a deleted session).
* - {@link consumeCscSession} — sync mutation success path; single-use delete
* returning the consumed row so the caller can use its data post-deletion.
*
* `itemsJson` is parsed through `ZCscSessionItemsSchema` on every read so the
* caller works with typed {@link TCscSessionItems}.
*/
export type CscSessionRow = {
id: string;
recipientId: number;
envelopeId: string;
signingTime: Date;
items: TCscSessionItems;
encryptedSad: Uint8Array | null;
sadExpiresAt: Date | null;
createdAt: Date;
};
type UpsertCscSessionInput = {
recipientId: number;
envelopeId: string;
signingTime: Date;
items: TCscSessionItems;
};
/**
* Create or refresh the per-recipient session row at prep time. The recipient
* has at most one in-flight session (`@@unique([recipientId])`); re-clicking
* Sign overwrites prior `itemsJson` + clears `encryptedSad` / `sadExpiresAt`
* so the next credential-scope callback starts from a clean SAD slot.
*/
export const upsertCscSession = async (input: UpsertCscSessionInput): Promise<CscSessionRow> => {
const { recipientId, envelopeId, signingTime, items } = input;
const row = await prisma.cscSession.upsert({
where: { recipientId },
create: {
recipientId,
envelopeId,
signingTime,
itemsJson: items,
encryptedSad: null,
sadExpiresAt: null,
},
update: {
envelopeId,
signingTime,
itemsJson: items,
encryptedSad: null,
sadExpiresAt: null,
},
});
return toCscSessionRow(row);
};
type UpdateCscSessionWithSadInput = {
sessionId: string;
encryptedSad: Uint8Array;
sadExpiresAt: Date;
};
/**
* Stamp the credential-scope SAD onto an existing session at the OAuth
* callback. Throws when the session id was already consumed or never existed
* — that's a flow-state bug the caller must surface, not silently skip.
*/
export const updateCscSessionWithSad = async (input: UpdateCscSessionWithSadInput): Promise<CscSessionRow> => {
const { sessionId, encryptedSad, sadExpiresAt } = input;
try {
const row = await prisma.cscSession.update({
where: {
id: sessionId,
},
data: {
encryptedSad: Buffer.from(encryptedSad),
sadExpiresAt,
},
});
return toCscSessionRow(row);
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2025') {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: `CSC session "${sessionId}" not found at SAD attach time.`,
});
}
throw err;
}
};
/**
* Fetch a session by id. Returns `null` when the row is absent — callers MUST
* handle the missing case (cookie outliving the row is a normal terminal
* outcome, not an error).
*/
export const loadCscSession = async (sessionId: string): Promise<CscSessionRow | null> => {
const row = await prisma.cscSession.findUnique({
where: { id: sessionId },
});
return row ? toCscSessionRow(row) : null;
};
/**
* Atomically delete the session row and return its parsed contents. Used by
* the sync mutation's success path so the caller still has the session data
* for post-sign side effects (audit log, webhook payloads).
*
* Throws `NOT_FOUND` when the row is already gone — semantically distinct
* from {@link loadCscSession}'s nullable return because consume is the
* success-path single-use closer; a missing row at that point means another
* branch raced to consume and the caller should not double-count.
*/
export const consumeCscSession = async (sessionId: string, tx?: Prisma.TransactionClient): Promise<CscSessionRow> => {
const client = tx ?? prisma;
try {
const row = await client.cscSession.delete({
where: { id: sessionId },
});
return toCscSessionRow(row);
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2025') {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: `CSC session "${sessionId}" already consumed or never existed.`,
});
}
throw err;
}
};
/**
* Project a raw Prisma `CscSession` into the helper's parsed shape. Throws
* on `itemsJson` parse failure — that's a data-integrity issue, not a
* recoverable runtime case.
*/
const toCscSessionRow = (row: {
id: string;
recipientId: number;
envelopeId: string;
signingTime: Date;
itemsJson: Prisma.JsonValue;
encryptedSad: Uint8Array | null;
sadExpiresAt: Date | null;
createdAt: Date;
}): CscSessionRow => {
const items = ZCscSessionItemsSchema.parse(row.itemsJson);
return {
id: row.id,
recipientId: row.recipientId,
envelopeId: row.envelopeId,
signingTime: row.signingTime,
items,
encryptedSad: row.encryptedSad,
sadExpiresAt: row.sadExpiresAt,
createdAt: row.createdAt,
};
};
@@ -0,0 +1,123 @@
/**
* CSC dry-run capture signer.
*
* Libpdf's signing flow expects an inline signer that hashes the
* `signedAttrs` bytes and returns a CMS signature. For the CSC §11.9
* `signatures/signHash` contract the actual signature is produced
* remotely by the TSP, so a single libpdf sign cycle has to be split
* into two passes:
*
* 1. Dry-run — drive `pdf.sign()` with this capture signer to derive
* the `signedAttrs` digest libpdf would otherwise sign. The
* resulting PDF is discarded; only `capturedDigest` matters.
* 2. Embed pass — the `CscFifoSigner` re-runs `pdf.sign()` and feeds
* the TSP-produced signature bytes back into the same byte slots.
*
* The placeholder bytes returned from `sign()` are sized to the
* chosen algorithm so libpdf's downstream CMS construction is not
* surprised by an unexpectedly short signature.
*/
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { Signer } from '@libpdf/core';
import { sha256, sha384, sha512 } from '@noble/hashes/sha2';
import type { LibpdfSignerAlgo } from '../algorithm-resolver';
type DigestAlgorithm = 'SHA-256' | 'SHA-384' | 'SHA-512';
type KeyType = 'RSA' | 'EC';
type SignatureAlgorithm = 'RSASSA-PKCS1-v1_5' | 'RSA-PSS' | 'ECDSA';
export type CscCaptureSignerOptions = {
certificate: Uint8Array;
certificateChain?: Uint8Array[];
algo: LibpdfSignerAlgo;
};
export class CscCaptureSigner implements Signer {
readonly certificate: Uint8Array;
readonly certificateChain?: Uint8Array[];
readonly keyType: KeyType;
readonly signatureAlgorithm: SignatureAlgorithm;
private readonly algo: LibpdfSignerAlgo;
/** Populated by `sign()`. `null` until libpdf calls into the signer. */
capturedDigest: Uint8Array | null = null;
constructor(options: CscCaptureSignerOptions) {
this.certificate = options.certificate;
this.certificateChain = options.certificateChain;
this.keyType = options.algo.keyType;
this.signatureAlgorithm = options.algo.signatureAlgorithm;
this.algo = options.algo;
}
/**
* Hash `data` with `algorithm` to derive the `signedAttrs` digest libpdf
* would normally sign, stash it on `capturedDigest`, then return a
* placeholder buffer sized to the chosen key so libpdf's CMS scaffolding
* accepts it. The placeholder bytes are never inspected — the resulting
* PDF is discarded after the digest is read.
*/
// biome-ignore lint/suspicious/useAwait: intentional
async sign(data: Uint8Array, algorithm: DigestAlgorithm): Promise<Uint8Array> {
if (this.capturedDigest !== null) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'CscCaptureSigner.sign() called more than once — capture signers are single-use.',
});
}
this.capturedDigest = hashData(data, algorithm);
return new Uint8Array(placeholderSize(this.algo));
}
}
const hashData = (data: Uint8Array, algorithm: DigestAlgorithm): Uint8Array => {
if (algorithm === 'SHA-256') {
return sha256(data);
}
if (algorithm === 'SHA-384') {
return sha384(data);
}
if (algorithm === 'SHA-512') {
return sha512(data);
}
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: `CscCaptureSigner.sign() called with unsupported digest algorithm '${String(algorithm)}'.`,
});
};
const placeholderSize = (algo: LibpdfSignerAlgo): number => {
if (algo.keyType === 'RSA') {
// RSA signature length === modulus length in bytes.
if (algo.keyLenBits >= 4096) {
return 512;
}
if (algo.keyLenBits >= 3072) {
return 384;
}
return 256;
}
// ECDSA DER-encoded SEQUENCE { INTEGER r, INTEGER s }. Upper bounds:
// P-256 ≈ 72 bytes, P-384 ≈ 104, P-521 ≈ 139. The dry-run PDF is
// discarded — exact size is informational, not load-bearing.
if (algo.keyLenBits >= 512) {
return 139;
}
if (algo.keyLenBits >= 384) {
return 104;
}
return 72;
};
@@ -0,0 +1,57 @@
/**
* CSC embed-pass FIFO signer.
*
* `signatures/signHash` (CSC §11.9) returns one signature per submitted
* hash, in the same position-bound order as the request `hash[]` array.
* The embed pass re-runs `pdf.sign()` once per anchor in that same order,
* so a FIFO queue of signature bytes — popped on each `sign()` call —
* is sufficient to feed libpdf without any per-anchor binding metadata.
*/
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { Signer } from '@libpdf/core';
import type { LibpdfSignerAlgo } from '../algorithm-resolver';
type DigestAlgorithm = 'SHA-256' | 'SHA-384' | 'SHA-512';
type KeyType = 'RSA' | 'EC';
type SignatureAlgorithm = 'RSASSA-PKCS1-v1_5' | 'RSA-PSS' | 'ECDSA';
export type CscFifoSignerOptions = {
certificate: Uint8Array;
certificateChain?: Uint8Array[];
algo: LibpdfSignerAlgo;
/** Base64-decoded raw signature bytes in the order produced by `signatures/signHash`. */
signatures: Uint8Array[];
};
export class CscFifoSigner implements Signer {
readonly certificate: Uint8Array;
readonly certificateChain?: Uint8Array[];
readonly keyType: KeyType;
readonly signatureAlgorithm: SignatureAlgorithm;
private readonly queue: Uint8Array[];
constructor(options: CscFifoSignerOptions) {
this.certificate = options.certificate;
this.certificateChain = options.certificateChain;
this.keyType = options.algo.keyType;
this.signatureAlgorithm = options.algo.signatureAlgorithm;
this.queue = [...options.signatures];
}
// biome-ignore lint/suspicious/useAwait: intentional
async sign(_data: Uint8Array, _algorithm: DigestAlgorithm): Promise<Uint8Array> {
const next = this.queue.shift();
if (next === undefined) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'CSC FIFO signer exhausted — more sign() calls than queued signatures.',
});
}
return next;
}
}
@@ -0,0 +1,153 @@
import { IS_INSTANCE_CSC_MODE, NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { assertLicensedFor } from '@documenso/lib/server-only/license/assert-licensed-for';
import { requireEnv } from '@documenso/lib/utils/env';
import type { OAuth2Client } from 'arctic';
import { cscInfo } from './client/info';
import { createCscOAuthClient } from './client/oauth';
import type { TCscInfoResponse } from './client/types';
import { isEnvTsaConfigured } from './tsa-resolver';
/**
* Lazily-built, globally-cached CSC transport.
*
* Boot-discovers `cscInfo` (§11.1) once, caches the OAuth base URL +
* `signatures/timestamp` capability, and exposes a configured arctic
* `OAuth2Client`. License + env + discovery are gated at construction so a
* misconfigured instance fails at the first call site, not at sign time.
*
* Cached on `globalThis` so Hono routes and Remix loaders share one instance
* across bundles (mirrors {@link LicenseClient}'s strategy).
*
* A failed build is **not** cached — the next caller retries. This keeps a
* transient discovery hiccup from permanently breaking the transport while
* still amortising the success path to one round-trip per process.
*/
const DISCOVERY_TIMEOUT_MS = 10_000;
const CSC_TIMESTAMP_METHOD = 'signatures/timestamp';
export type CscTransport = {
/** Service base URI from `NEXT_PRIVATE_SIGNING_CSC_PROVIDER_BASE_URL`. */
serviceBaseUrl: string;
/** OAuth base URI from `info.oauth2` (§11.1). MAY differ from `serviceBaseUrl`. */
oauthBaseUrl: string;
/** Pre-configured arctic client bound to the TSP's OAuth registration. */
oauthClient: OAuth2Client;
/**
* Documenso's callback URL registered with the TSP. Derived from
* `NEXT_PUBLIC_WEBAPP_URL` and the fixed `/api/csc/oauth/callback` mount —
* mirrors `packages/auth/server/config.ts` for the sign-in OAuth providers.
* Operators must register this exact URL with the TSP.
*/
oauthRedirectUri: string;
/** True when the TSP advertises `signatures/timestamp` in `info.methods`. */
supportsTimestamp: boolean;
/** Raw discovery response, exposed for callers needing other fields (`name`, `region`, `lang`). */
info: TCscInfoResponse;
};
declare global {
// eslint-disable-next-line no-var
var __documenso_csc_transport__: CscTransport | undefined;
// eslint-disable-next-line no-var
var __documenso_csc_transport_promise__: Promise<CscTransport> | undefined;
}
/**
* Get the current CSC transport, building + caching it on first call.
*
* Throws:
* - `NOT_SETUP` — instance is not in CSC mode, or a required env var is unset.
* - `CSC_UNLICENSED` — `instanceCscSigning` license flag missing.
* - `CSC_PROVIDER_INFO_FAILED` — `info` discovery failed or response omits
* the REQUIRED `oauth2` base URL.
*
* Safe to call concurrently — a second call during in-flight discovery
* awaits the same promise instead of starting a duplicate request.
*/
export const getCscTransport = async (): Promise<CscTransport> => {
if (globalThis.__documenso_csc_transport__) {
return globalThis.__documenso_csc_transport__;
}
if (!globalThis.__documenso_csc_transport_promise__) {
globalThis.__documenso_csc_transport_promise__ = buildCscTransport()
.then((transport) => {
globalThis.__documenso_csc_transport__ = transport;
return transport;
})
.finally(() => {
globalThis.__documenso_csc_transport_promise__ = undefined;
});
}
return await globalThis.__documenso_csc_transport_promise__;
};
/**
* Drop the cached transport. Intended for tests / operator-triggered reload
* after a license-key swap. Next {@link getCscTransport} call re-runs the
* full build pipeline (license + env + discovery).
*/
export const resetCscTransport = (): void => {
globalThis.__documenso_csc_transport__ = undefined;
globalThis.__documenso_csc_transport_promise__ = undefined;
};
const buildCscTransport = async (): Promise<CscTransport> => {
if (!IS_INSTANCE_CSC_MODE()) {
throw new AppError(AppErrorCode.NOT_SETUP, {
message: 'CSC transport requested but NEXT_PRIVATE_SIGNING_TRANSPORT is not "csc".',
});
}
await assertLicensedFor('instanceCscSigning', { errorCode: AppErrorCode.CSC_UNLICENSED });
const serviceBaseUrl = requireEnv('NEXT_PRIVATE_SIGNING_CSC_PROVIDER_BASE_URL');
const clientId = requireEnv('NEXT_PRIVATE_SIGNING_CSC_OAUTH_CLIENT_ID');
const clientSecret = requireEnv('NEXT_PRIVATE_SIGNING_CSC_OAUTH_CLIENT_SECRET');
const oauthRedirectUri = `${NEXT_PUBLIC_WEBAPP_URL()}/api/csc/oauth/callback`;
const oauthClient = createCscOAuthClient({ clientId, clientSecret, redirectUri: oauthRedirectUri });
const info = await cscInfo({
baseUrl: serviceBaseUrl,
signal: AbortSignal.timeout(DISCOVERY_TIMEOUT_MS),
});
if (!info.oauth2) {
throw new AppError(AppErrorCode.CSC_PROVIDER_INFO_FAILED, {
message:
'CSC TSP info response omits the required `oauth2` base URL. CSC QES V1 only supports OAuth-based authorization (§8.3) — non-OAuth TSPs are not compatible.',
});
}
const supportsTimestamp = info.methods.includes(CSC_TIMESTAMP_METHOD);
// Boot-time TSA invariant: `NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY` is
// required unconditionally in CSC mode. Sign-time B-T can use the TSP's
// own `signatures/timestamp` endpoint when advertised, but seal-time
// B-LTA archival is env-only by design (operators should pin a dedicated
// qualified archival TSA — see `resolveCscSealTimeTsa`). Without env, an
// envelope would sign successfully and then hang in
// WAITING_FOR_SIGNATURE_COMPLETION when the seal job throws. Catch the
// misconfiguration at boot instead so the instance refuses to start.
if (!isEnvTsaConfigured()) {
throw new AppError(AppErrorCode.CSC_PROVIDER_NO_TSA, {
message:
'NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY is unset. AES/QES envelopes require a TSA for B-LTA archival at seal time regardless of whether the CSC TSP advertises signatures/timestamp for B-T sign-time. Configure NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY.',
});
}
return {
serviceBaseUrl,
oauthBaseUrl: info.oauth2,
oauthClient,
oauthRedirectUri,
supportsTimestamp,
info,
};
};
@@ -0,0 +1,105 @@
import { NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY } from '@documenso/lib/constants/app';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { HttpTimestampAuthority, type TimestampAuthority } from '@libpdf/core';
import type { CscTransport } from './transport';
import { CscTspTimestampAuthority } from './tsp-timestamp-authority';
/**
* Two-phase TSA resolution for the CSC transport.
*
* Phase 1 — sign time (PAdES B-T, per recipient signature).
* Each recipient's CMS gets a signature timestamp embedded as an unsigned
* attribute. {@link resolveCscSignTimeTsa} returns a libpdf-shaped
* `TimestampAuthority` bound to either the TSP's `signatures/timestamp`
* endpoint (authorised with the recipient's own service-scope bearer) or
* the operator's env-configured RFC 3161 TSA, whichever is configured.
* TSP wins precedence so a TSP-supplied TSA is the default when the TSP
* advertises the method.
*
* Phase 2 — seal time (PAdES B-LTA archival timestamp).
* The seal-document job emits one `/DocTimeStamp` over the fully-signed
* envelope. {@link resolveCscSealTimeTsa} returns the env-configured TSA
* only — the archival anchor SHOULD be a dedicated qualified archival
* TSA, independent of the per-recipient TSP. Using the TSP here would
* couple archive longevity to a TSP that may rotate or revoke, and seal
* time has no recipient context to carry a service-scope bearer anyway.
*
* Boot-time guard: {@link buildCscTransport} asserts
* `NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY` is set unconditionally — seal
* time always needs it, so making it env-or-fail at boot also satisfies
* the sign-time fallback. The defensive throws inside the resolvers below
* should be unreachable in practice.
*/
/**
* Build a libpdf `TimestampAuthority` for a recipient's B-T sign-time
* signature timestamp.
*
* Precedence: TSP first, env fallback. Selection is made up-front based on
* the boot-discovered transport capability — we don't try TSP then fall
* through to env on a runtime error. If the chosen source fails at call
* time, the recipient's sign attempt fails (operator's recourse is to
* configure env, which then wins on the next sign).
*
* `serviceToken` is the decrypted, non-expired service-scope bearer for
* the current recipient — used only when the TSP source is selected.
*/
export const resolveCscSignTimeTsa = (transport: CscTransport, serviceToken: string): TimestampAuthority => {
if (transport.supportsTimestamp) {
return new CscTspTimestampAuthority({ transport, serviceToken });
}
const envUrls = parseTsaEnv(NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY());
if (envUrls.length > 0) {
return new HttpTimestampAuthority(envUrls[0]);
}
// Boot-time guard in `buildCscTransport` should have rejected this
// configuration before any recipient hit this code path.
throw new AppError(AppErrorCode.CSC_PROVIDER_NO_TSA, {
message:
'CSC sign-time TSA unresolved: TSP does not advertise signatures/timestamp and NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY is unset. This should have been caught by the boot-time guard in buildCscTransport.',
});
};
/**
* Resolve the seal-time archival TSA URLs (env only).
*
* Returns the parsed env list; the caller picks how to consume it (today
* `finalize-tsp-completion.ts` uses the first URL).
*/
export const resolveCscSealTimeTsa = (): { urls: string[] } => {
const envUrls = parseTsaEnv(NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY());
if (envUrls.length === 0) {
throw new AppError(AppErrorCode.CSC_PROVIDER_NO_TSA, {
message:
'CSC seal-time archival timestamps require NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY. This should have been caught by the boot-time guard in buildCscTransport — the env var is required at seal time even when the TSP advertises signatures/timestamp.',
});
}
return { urls: envUrls };
};
/**
* Cheap boot-time predicate — used by `buildCscTransport` to decide
* whether the env TSA satisfies the "at least one source must be
* configured" invariant. Keeping the env parsing in one place avoids
* drift between the guard and the resolvers.
*/
export const isEnvTsaConfigured = (): boolean => {
return parseTsaEnv(NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY()).length > 0;
};
const parseTsaEnv = (raw: string | undefined): string[] => {
if (!raw) {
return [];
}
return raw
.split(',')
.map((url) => url.trim())
.filter(Boolean);
};
@@ -0,0 +1,82 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { DigestAlgorithm, TimestampAuthority } from '@libpdf/core';
import { hashOidForDigest } from './algorithm-resolver';
import { cscTimestamp } from './client/signatures';
import type { CscTransport } from './transport';
/**
* libpdf {@link TimestampAuthority} backed by the CSC TSP's
* `signatures/timestamp` endpoint (§11.10).
*
* Used only at sign time, per recipient, when {@link resolveCscSignTimeTsa}
* selects the TSP source — that is, when the TSP advertises
* `signatures/timestamp` in `info.methods`. The token wired in is the
* current recipient's own service-scope bearer (the same one authorising
* the `signatures/signHash` call alongside it), so the timestamp gets
* attributed to the same identity that just authorised the signature.
*
* Seal-time archival timestamps do not use this class — they go through
* the env-only path in `finalize-tsp-completion.ts`.
*
* Failure semantics: a single `signatures/timestamp` call. On any error
* (HTTP, schema, expired token) we surface `CSC_PROVIDER_NO_TSA` with the
* upstream message folded in. There's no try-in-order — at sign time the
* recipient is fixed, so there's no other token to fall through to.
*/
type CscTspTimestampAuthorityOptions = {
transport: CscTransport;
/** Decrypted service-scope access token for the current recipient. */
serviceToken: string;
/** Optional deadline for the `signatures/timestamp` call. */
signal?: AbortSignal;
};
export class CscTspTimestampAuthority implements TimestampAuthority {
private readonly transport: CscTransport;
private readonly serviceToken: string;
private readonly signal?: AbortSignal;
constructor(opts: CscTspTimestampAuthorityOptions) {
this.transport = opts.transport;
this.serviceToken = opts.serviceToken;
this.signal = opts.signal;
}
/**
* Request a CSC §11.10 timestamp for the supplied digest, authorised with
* the recipient's service-scope bearer. Returns the decoded TimeStampToken
* bytes. Throws `CSC_PROVIDER_NO_TSA` carrying the upstream error message
* on failure.
*
* `algorithm` is libpdf's `DigestAlgorithm` (`SHA-256` / `SHA-384` /
* `SHA-512`), translated to the matching `hashAlgo` OID via the existing
* {@link hashOidForDigest} mapping so the spec's OID-typed payload stays
* in one place.
*/
async timestamp(digest: Uint8Array, algorithm: DigestAlgorithm): Promise<Uint8Array> {
const hash = Buffer.from(digest).toString('base64');
const hashAlgo = hashOidForDigest(algorithm);
try {
const response = await cscTimestamp({
baseUrl: this.transport.serviceBaseUrl,
accessToken: this.serviceToken,
hash,
hashAlgo,
signal: this.signal,
});
return Buffer.from(response.timestamp, 'base64');
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
throw new AppError(AppErrorCode.CSC_PROVIDER_NO_TSA, {
message: `CSC TSP timestamp endpoint refused the recipient's service token: ${message}.`,
});
}
}
}
@@ -1,3 +1,4 @@
import { IS_INSTANCE_CSC_MODE } from '@documenso/lib/constants/app';
import { ZRecipientActionAuthTypesSchema, ZRecipientAuthOptionsSchema } from '@documenso/lib/types/document-auth'; import { ZRecipientActionAuthTypesSchema, ZRecipientAuthOptionsSchema } from '@documenso/lib/types/document-auth';
import type { TEditorEnvelope } from '@documenso/lib/types/envelope-editor'; import type { TEditorEnvelope } from '@documenso/lib/types/envelope-editor';
import { ZRecipientEmailSchema } from '@documenso/lib/types/recipient'; import { ZRecipientEmailSchema } from '@documenso/lib/types/recipient';
@@ -21,11 +22,49 @@ const LocalRecipientSchema = z.object({
type TLocalRecipient = z.infer<typeof LocalRecipientSchema>; type TLocalRecipient = z.infer<typeof LocalRecipientSchema>;
export const ZEditorRecipientsFormSchema = z.object({ /**
signers: z.array(LocalRecipientSchema), * Backstop validation that mirrors the CSC-mode UI overrides in
signingOrder: z.nativeEnum(DocumentSigningOrder), * `EnvelopeEditorProvider`. If anything bypasses the disabled controls (URL
allowDictateNextSigner: z.boolean().default(false), * tampering, legacy form state, embedded host) the form refuses to submit
}); * rather than persisting values the TSP flow can't honour.
*/
export const ZEditorRecipientsFormSchema = z
.object({
signers: z.array(LocalRecipientSchema),
signingOrder: z.nativeEnum(DocumentSigningOrder),
allowDictateNextSigner: z.boolean().default(false),
})
.superRefine((data, ctx) => {
if (!IS_INSTANCE_CSC_MODE()) {
return;
}
if (data.signingOrder !== DocumentSigningOrder.SEQUENTIAL) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'CSC envelopes must use SEQUENTIAL signing order.',
path: ['signingOrder'],
});
}
if (data.allowDictateNextSigner) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'CSC envelopes do not support next-signer dictation.',
path: ['allowDictateNextSigner'],
});
}
data.signers.forEach((signer, index) => {
if (signer.role === RecipientRole.ASSISTANT) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'CSC envelopes do not support the assistant role.',
path: ['signers', index, 'role'],
});
}
});
});
export type TEditorRecipientsFormSchema = z.infer<typeof ZEditorRecipientsFormSchema>; export type TEditorRecipientsFormSchema = z.infer<typeof ZEditorRecipientsFormSchema>;
@@ -1,3 +1,4 @@
import { IS_INSTANCE_CSC_MODE } from '@documenso/lib/constants/app';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc'; import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import { import {
DEFAULT_EDITOR_CONFIG, DEFAULT_EDITOR_CONFIG,
@@ -36,6 +37,12 @@ type EnvelopeEditorProviderValue = {
isEmbedded: boolean; isEmbedded: boolean;
isDocument: boolean; isDocument: boolean;
isTemplate: boolean; isTemplate: boolean;
/**
* Whether the instance is running in CSC (Cloud Signature Consortium) mode.
* Components can branch on this for any additional CSC-only UI gating
* beyond the overrides already baked into `editorConfig`.
*/
isCscMode: boolean;
setLocalEnvelope: (localEnvelope: Partial<TEditorEnvelope>) => void; setLocalEnvelope: (localEnvelope: Partial<TEditorEnvelope>) => void;
updateEnvelope: (envelopeUpdates: UpdateEnvelopePayload) => void; updateEnvelope: (envelopeUpdates: UpdateEnvelopePayload) => void;
@@ -91,7 +98,7 @@ export const useCurrentEnvelopeEditor = () => {
export const EnvelopeEditorProvider = ({ export const EnvelopeEditorProvider = ({
children, children,
editorConfig = DEFAULT_EDITOR_CONFIG, editorConfig: providedEditorConfig = DEFAULT_EDITOR_CONFIG,
initialEnvelope, initialEnvelope,
organisationEmails, organisationEmails,
}: EnvelopeEditorProviderProps) => { }: EnvelopeEditorProviderProps) => {
@@ -103,6 +110,31 @@ export const EnvelopeEditorProvider = ({
const [envelope, _setEnvelope] = useState(initialEnvelope); const [envelope, _setEnvelope] = useState(initialEnvelope);
const [autosaveError, setAutosaveError] = useState<boolean>(false); const [autosaveError, setAutosaveError] = useState<boolean>(false);
const isCscMode = IS_INSTANCE_CSC_MODE();
/**
* CSC-mode overrides applied on top of any caller-supplied editor config.
* TSP envelopes are forced SEQUENTIAL at send-time and the sign path has no
* nextSigner dictation; the assistant role's pre-fill semantics don't map
* onto each recipient signing their own complete PDF state. Hide all three
* up-front so authors don't pick options that would get silently coerced.
*/
const editorConfig = useMemo<EnvelopeEditorConfig>(() => {
if (!isCscMode || !providedEditorConfig.recipients) {
return providedEditorConfig;
}
return {
...providedEditorConfig,
recipients: {
...providedEditorConfig.recipients,
allowConfigureSigningOrder: false,
allowConfigureDictateNextSigner: false,
allowAssistantRole: false,
},
};
}, [isCscMode, providedEditorConfig]);
const envelopeRef = useRef(initialEnvelope); const envelopeRef = useRef(initialEnvelope);
const externalFlushCallbacksRef = useRef<Map<string, () => Promise<void>>>(new Map()); const externalFlushCallbacksRef = useRef<Map<string, () => Promise<void>>>(new Map());
@@ -467,6 +499,7 @@ export const EnvelopeEditorProvider = ({
isEmbedded, isEmbedded,
isDocument: envelope.type === EnvelopeType.DOCUMENT, isDocument: envelope.type === EnvelopeType.DOCUMENT,
isTemplate: envelope.type === EnvelopeType.TEMPLATE, isTemplate: envelope.type === EnvelopeType.TEMPLATE,
isCscMode,
setLocalEnvelope, setLocalEnvelope,
getRecipientColorKey, getRecipientColorKey,
updateEnvelope, updateEnvelope,
+53
View File
@@ -1,4 +1,6 @@
import { env } from '@documenso/lib/utils/env'; import { env } from '@documenso/lib/utils/env';
import { AppError, AppErrorCode } from '../errors/app-error';
import { SignatureLevel, type TSignatureLevel } from '../types/signature-level';
export const APP_DOCUMENT_UPLOAD_SIZE_LIMIT = Number(env('NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT')) || 50; export const APP_DOCUMENT_UPLOAD_SIZE_LIMIT = Number(env('NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT')) || 50;
@@ -33,3 +35,54 @@ export const IS_AI_FEATURES_CONFIGURED = () => !!env('GOOGLE_VERTEX_PROJECT_ID')
export const NEXT_PRIVATE_USE_PLAYWRIGHT_PDF = () => env('NEXT_PRIVATE_USE_PLAYWRIGHT_PDF') === 'true'; export const NEXT_PRIVATE_USE_PLAYWRIGHT_PDF = () => env('NEXT_PRIVATE_USE_PLAYWRIGHT_PDF') === 'true';
export const NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY = () => env('NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY'); export const NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY = () => env('NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY');
/**
* Whether this Documenso instance is running in CSC (Cloud Signature Consortium) mode.
*
* CSC mode routes signing through a third-party Trust Service Provider for
* Advanced and Qualified Electronic Signatures (AES/QES). It is instance-wide
* and mutually exclusive with the other signing transports.
*/
export const IS_INSTANCE_CSC_MODE = (): boolean => {
if (typeof window === 'undefined') {
return env('NEXT_PRIVATE_SIGNING_TRANSPORT') === 'csc';
}
return env('NEXT_PUBLIC_SIGNING_TRANSPORT_IS_CSC') === 'true';
};
/**
* The default signature level applied to envelopes created on a CSC-mode
* instance when the caller doesn't specify one (or asks for `SES` and the
* resolver is in loose-coerce mode).
*
* Set via `NEXT_PRIVATE_SIGNING_CSC_SIGNATURE_LEVEL`; accepts `AES` or `QES`
* only; defaults to `AES` when unset. An explicit `AES`/`QES` request on
* envelope create still passes through unchanged — this constant only affects
* the coerced default.
*
* Throws on an invalid value rather than silently falling back. A typo here
* (e.g. `qes`) would otherwise silently downgrade qualified-tier instances
* to advanced-tier, which has legal consequences.
*
* Only consulted on CSC-mode instances. Non-CSC instances always default to
* `SES` regardless of this var.
*/
export const CSC_INSTANCE_SIGNATURE_LEVEL = (): TSignatureLevel => {
// Cast through `string | undefined` because shells can deliver
// `NEXT_PRIVATE_SIGNING_CSC_SIGNATURE_LEVEL=` as an empty string at runtime
// — the typed env signature narrows to `'AES' | 'QES' | undefined` only.
const value = env('NEXT_PRIVATE_SIGNING_CSC_SIGNATURE_LEVEL');
if (!value) {
return SignatureLevel.AES;
}
if (value !== SignatureLevel.AES && value !== SignatureLevel.QES) {
throw new AppError(AppErrorCode.NOT_SETUP, {
message: `NEXT_PRIVATE_SIGNING_CSC_SIGNATURE_LEVEL must be '${SignatureLevel.AES}' or '${SignatureLevel.QES}', got '${value}'.`,
});
}
return value;
};
+56 -1
View File
@@ -14,6 +14,7 @@ export enum AppErrorCode {
NOT_FOUND = 'NOT_FOUND', NOT_FOUND = 'NOT_FOUND',
NOT_IMPLEMENTED = 'NOT_IMPLEMENTED', NOT_IMPLEMENTED = 'NOT_IMPLEMENTED',
NOT_SETUP = 'NOT_SETUP', NOT_SETUP = 'NOT_SETUP',
MISSING_ENV_VAR = 'MISSING_ENV_VAR',
INVALID_CAPTCHA = 'INVALID_CAPTCHA', INVALID_CAPTCHA = 'INVALID_CAPTCHA',
UNAUTHORIZED = 'UNAUTHORIZED', UNAUTHORIZED = 'UNAUTHORIZED',
FORBIDDEN = 'FORBIDDEN', FORBIDDEN = 'FORBIDDEN',
@@ -27,6 +28,35 @@ export enum AppErrorCode {
ENVELOPE_COMPLETED = 'ENVELOPE_COMPLETED', ENVELOPE_COMPLETED = 'ENVELOPE_COMPLETED',
ENVELOPE_REJECTED = 'ENVELOPE_REJECTED', ENVELOPE_REJECTED = 'ENVELOPE_REJECTED',
ENVELOPE_LEGACY = 'ENVELOPE_LEGACY', ENVELOPE_LEGACY = 'ENVELOPE_LEGACY',
/**
* Authoring mutation rejected because the envelope is an AES/QES envelope
* past DRAFT — the TSP mutation lock fires at distribution to preserve
* WYSIWYS. SES envelopes never hit this code.
*/
ENVELOPE_TSP_LOCKED = 'ENVELOPE_TSP_LOCKED',
/**
* CSC (Cloud Signature Consortium) error codes. See the CSC QES V1 spec
* for the recovery taxonomy.
*/
CSC_INSTANCE_MODE_MISMATCH = 'CSC_INSTANCE_MODE_MISMATCH',
CSC_UNLICENSED = 'CSC_UNLICENSED',
CSC_PROVIDER_INFO_FAILED = 'CSC_PROVIDER_INFO_FAILED',
CSC_PROVIDER_NO_TSA = 'CSC_PROVIDER_NO_TSA',
CSC_CREDENTIAL_LIST_EMPTY = 'CSC_CREDENTIAL_LIST_EMPTY',
CSC_CERT_INVALID = 'CSC_CERT_INVALID',
CSC_ALGORITHM_REFUSED = 'CSC_ALGORITHM_REFUSED',
CSC_SAD_EXPIRED_PRE_SIGN = 'CSC_SAD_EXPIRED_PRE_SIGN',
CSC_TSP_TIMEOUT = 'CSC_TSP_TIMEOUT',
CSC_EMBED_FAILED = 'CSC_EMBED_FAILED',
CSC_BASE_DOCUMENT_MUTATED = 'CSC_BASE_DOCUMENT_MUTATED',
/**
* Generic catch-all for CSC HTTP transport failures — network error, non-2xx
* response without a more specific semantic match, malformed JSON, or
* response schema mismatch. Carries the TSP's HTTP status in `statusCode`
* and the TSP's `error` / `error_description` in the message when available.
*/
CSC_REQUEST_FAILED = 'CSC_REQUEST_FAILED',
} }
export const genericErrorCodeToTrpcErrorCodeMap: Record<string, { code: string; status: number }> = { export const genericErrorCodeToTrpcErrorCodeMap: Record<string, { code: string; status: number }> = {
@@ -39,6 +69,7 @@ export const genericErrorCodeToTrpcErrorCodeMap: Record<string, { code: string;
[AppErrorCode.NOT_FOUND]: { code: 'NOT_FOUND', status: 404 }, [AppErrorCode.NOT_FOUND]: { code: 'NOT_FOUND', status: 404 },
[AppErrorCode.NOT_IMPLEMENTED]: { code: 'INTERNAL_SERVER_ERROR', status: 501 }, [AppErrorCode.NOT_IMPLEMENTED]: { code: 'INTERNAL_SERVER_ERROR', status: 501 },
[AppErrorCode.NOT_SETUP]: { code: 'BAD_REQUEST', status: 400 }, [AppErrorCode.NOT_SETUP]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.MISSING_ENV_VAR]: { code: 'INTERNAL_SERVER_ERROR', status: 500 },
[AppErrorCode.UNAUTHORIZED]: { code: 'UNAUTHORIZED', status: 401 }, [AppErrorCode.UNAUTHORIZED]: { code: 'UNAUTHORIZED', status: 401 },
[AppErrorCode.FORBIDDEN]: { code: 'FORBIDDEN', status: 403 }, [AppErrorCode.FORBIDDEN]: { code: 'FORBIDDEN', status: 403 },
[AppErrorCode.UNKNOWN_ERROR]: { code: 'INTERNAL_SERVER_ERROR', status: 500 }, [AppErrorCode.UNKNOWN_ERROR]: { code: 'INTERNAL_SERVER_ERROR', status: 500 },
@@ -50,6 +81,23 @@ export const genericErrorCodeToTrpcErrorCodeMap: Record<string, { code: string;
[AppErrorCode.ENVELOPE_COMPLETED]: { code: 'BAD_REQUEST', status: 400 }, [AppErrorCode.ENVELOPE_COMPLETED]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.ENVELOPE_REJECTED]: { code: 'BAD_REQUEST', status: 400 }, [AppErrorCode.ENVELOPE_REJECTED]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.ENVELOPE_LEGACY]: { code: 'BAD_REQUEST', status: 400 }, [AppErrorCode.ENVELOPE_LEGACY]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.ENVELOPE_TSP_LOCKED]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.CSC_INSTANCE_MODE_MISMATCH]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.CSC_UNLICENSED]: { code: 'FORBIDDEN', status: 403 },
[AppErrorCode.CSC_PROVIDER_INFO_FAILED]: { code: 'INTERNAL_SERVER_ERROR', status: 500 },
[AppErrorCode.CSC_PROVIDER_NO_TSA]: { code: 'INTERNAL_SERVER_ERROR', status: 500 },
[AppErrorCode.CSC_CREDENTIAL_LIST_EMPTY]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.CSC_CERT_INVALID]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.CSC_ALGORITHM_REFUSED]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.CSC_SAD_EXPIRED_PRE_SIGN]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.CSC_TSP_TIMEOUT]: { code: 'TIMEOUT', status: 408 },
[AppErrorCode.CSC_EMBED_FAILED]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.CSC_BASE_DOCUMENT_MUTATED]: { code: 'INTERNAL_SERVER_ERROR', status: 500 },
// Generic transport failure — the TSP is upstream so server-side from our
// perspective; 500 keeps the caller surface conservative. The TSP's actual
// HTTP status rides along in AppError.statusCode for the few callers that
// need to discriminate (e.g. 401 → re-auth, 429 → backoff).
[AppErrorCode.CSC_REQUEST_FAILED]: { code: 'INTERNAL_SERVER_ERROR', status: 500 },
}; };
export const ZAppErrorJsonSchema = z.object({ export const ZAppErrorJsonSchema = z.object({
@@ -239,10 +287,17 @@ export class AppError extends Error {
AppErrorCode.ENVELOPE_COMPLETED, AppErrorCode.ENVELOPE_COMPLETED,
AppErrorCode.ENVELOPE_REJECTED, AppErrorCode.ENVELOPE_REJECTED,
AppErrorCode.ENVELOPE_LEGACY, AppErrorCode.ENVELOPE_LEGACY,
AppErrorCode.ENVELOPE_TSP_LOCKED,
AppErrorCode.CSC_INSTANCE_MODE_MISMATCH,
AppErrorCode.CSC_CREDENTIAL_LIST_EMPTY,
AppErrorCode.CSC_CERT_INVALID,
AppErrorCode.CSC_ALGORITHM_REFUSED,
AppErrorCode.CSC_SAD_EXPIRED_PRE_SIGN,
AppErrorCode.CSC_EMBED_FAILED,
() => 400 as const, () => 400 as const,
) )
.with(AppErrorCode.UNAUTHORIZED, () => 401 as const) .with(AppErrorCode.UNAUTHORIZED, () => 401 as const)
.with(AppErrorCode.FORBIDDEN, () => 403 as const) .with(AppErrorCode.FORBIDDEN, AppErrorCode.CSC_UNLICENSED, () => 403 as const)
.with(AppErrorCode.NOT_FOUND, () => 404 as const) .with(AppErrorCode.NOT_FOUND, () => 404 as const)
.with(AppErrorCode.NOT_IMPLEMENTED, () => 501 as const) .with(AppErrorCode.NOT_IMPLEMENTED, () => 501 as const)
.otherwise(() => 500 as const); .otherwise(() => 500 as const);
@@ -20,6 +20,7 @@ const BACKPORT_SUBSCRIPTION_CLAIM_JOB_DEFINITION_SCHEMA = z.object({
cfr21: z.literal(true).optional(), cfr21: z.literal(true).optional(),
hipaa: z.literal(true).optional(), hipaa: z.literal(true).optional(),
signingReminders: z.literal(true).optional(), signingReminders: z.literal(true).optional(),
cscQesSigning: z.literal(true).optional(),
// Do NOT backport disableEmails. // Do NOT backport disableEmails.
// Todo: Envelopes - Do we need to check? // Todo: Envelopes - Do we need to check?
// authenticationPortal & emailDomains missing here. // authenticationPortal & emailDomains missing here.
@@ -1,5 +1,6 @@
import path from 'node:path'; import path from 'node:path';
import { PDFDocument } from '@cantoo/pdf-lib'; import { PDFDocument } from '@cantoo/pdf-lib';
import { finalizeTspEnvelopeCompletion } from '@documenso/ee/server-only/signing/csc/finalize-tsp-completion';
import { addRejectionStampToPdf } from '@documenso/lib/server-only/pdf/add-rejection-stamp-to-pdf'; import { addRejectionStampToPdf } from '@documenso/lib/server-only/pdf/add-rejection-stamp-to-pdf';
import { generateAuditLogPdf } from '@documenso/lib/server-only/pdf/generate-audit-log-pdf'; import { generateAuditLogPdf } from '@documenso/lib/server-only/pdf/generate-audit-log-pdf';
import { generateCertificatePdf } from '@documenso/lib/server-only/pdf/generate-certificate-pdf'; import { generateCertificatePdf } from '@documenso/lib/server-only/pdf/generate-certificate-pdf';
@@ -22,6 +23,7 @@ import { legacy_insertFieldInPDF } from '../../../server-only/pdf/legacy-insert-
import { getTeamSettings } from '../../../server-only/team/get-team-settings'; import { getTeamSettings } from '../../../server-only/team/get-team-settings';
import { triggerWebhook } from '../../../server-only/webhooks/trigger/trigger-webhook'; import { triggerWebhook } from '../../../server-only/webhooks/trigger/trigger-webhook';
import { DOCUMENT_AUDIT_LOG_TYPE, type TDocumentAuditLog } from '../../../types/document-audit-logs'; import { DOCUMENT_AUDIT_LOG_TYPE, type TDocumentAuditLog } from '../../../types/document-audit-logs';
import { isTspEnvelope } from '../../../types/signature-level';
import { mapEnvelopeToWebhookDocumentPayload, ZWebhookDocumentSchema } from '../../../types/webhook-payload'; import { mapEnvelopeToWebhookDocumentPayload, ZWebhookDocumentSchema } from '../../../types/webhook-payload';
import { prefixedId } from '../../../universal/id'; import { prefixedId } from '../../../universal/id';
import { getFileServerSide } from '../../../universal/upload/get-file.server'; import { getFileServerSide } from '../../../universal/upload/get-file.server';
@@ -164,6 +166,33 @@ export const run = async ({ payload, io }: { payload: TSealDocumentJobDefinition
const finalEnvelopeStatus = isRejected ? DocumentStatus.REJECTED : DocumentStatus.COMPLETED; const finalEnvelopeStatus = isRejected ? DocumentStatus.REJECTED : DocumentStatus.COMPLETED;
if (isTspEnvelope(envelope)) {
if (isResealing) {
throw new AppError(AppErrorCode.NOT_SETUP, {
message: 'Re-sealing TSP envelopes is not supported — recipient signatures cannot be regenerated externally.',
});
}
if (isRejected) {
throw new AppError(AppErrorCode.NOT_SETUP, {
message:
'TSP envelope rejection is not supported in V1 — rejection stamps would invalidate PAdES signatures.',
});
}
await finalizeTspEnvelopeCompletion({
envelope,
envelopeCompletedAuditLog,
requestMetadata,
});
return {
envelopeId: envelope.id,
envelopeStatus: envelope.status,
isRejected,
};
}
// Pre-fetch all PDF data so we can read dimensions and pass it // Pre-fetch all PDF data so we can read dimensions and pass it
// to decorateAndSignPdf without fetching again. // to decorateAndSignPdf without fetching again.
const prefetchedItems = await Promise.all( const prefetchedItems = await Promise.all(
@@ -8,7 +8,10 @@ import type { SupportedLanguageCodes } from '../../constants/i18n';
import { AppError, AppErrorCode } from '../../errors/app-error'; import { AppError, AppErrorCode } from '../../errors/app-error';
import type { TDocumentEmailSettings } from '../../types/document-email'; import type { TDocumentEmailSettings } from '../../types/document-email';
import type { EnvelopeIdOptions } from '../../utils/envelope'; import type { EnvelopeIdOptions } from '../../utils/envelope';
import { assertEnvelopeMutable } from '../envelope/assert-envelope-mutable';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id'; import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
import { assertCompatibleDictateNextSigner } from '../signature-level/assert-compatible-dictate-next-signer';
import { assertCompatibleSigningOrder } from '../signature-level/assert-compatible-signing-order';
export type CreateDocumentMetaOptions = { export type CreateDocumentMetaOptions = {
userId: number; userId: number;
@@ -73,6 +76,22 @@ export const updateDocumentMeta = async ({
}); });
} }
await assertEnvelopeMutable(envelope);
if (signingOrder !== undefined) {
assertCompatibleSigningOrder({
signatureLevel: envelope.signatureLevel,
signingOrder,
});
}
if (allowDictateNextSigner !== undefined) {
assertCompatibleDictateNextSigner({
signatureLevel: envelope.signatureLevel,
allowDictateNextSigner,
});
}
const { documentMeta: originalDocumentMeta } = envelope; const { documentMeta: originalDocumentMeta } = envelope;
// Validate the emailId belongs to the organisation. // Validate the emailId belongs to the organisation.
@@ -92,6 +111,8 @@ export const updateDocumentMeta = async ({
} }
return await prisma.$transaction(async (tx) => { return await prisma.$transaction(async (tx) => {
await assertEnvelopeMutable(envelope, tx);
const upsertedDocumentMeta = await tx.documentMeta.update({ const upsertedDocumentMeta = await tx.documentMeta.update({
where: { where: {
id: envelope.documentMetaId, id: envelope.documentMetaId,
@@ -1,3 +1,4 @@
import { materializeTspAnchorsForEnvelope } from '@documenso/ee/server-only/signing/csc/materialize-anchors';
import { resolveExpiresAt } from '@documenso/lib/constants/envelope-expiration'; import { resolveExpiresAt } from '@documenso/lib/constants/envelope-expiration';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs'; import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
@@ -29,6 +30,7 @@ import {
ZRadioFieldMeta, ZRadioFieldMeta,
ZTextFieldMeta, ZTextFieldMeta,
} from '../../types/field-meta'; } from '../../types/field-meta';
import { isTspEnvelope } from '../../types/signature-level';
import { mapEnvelopeToWebhookDocumentPayload, ZWebhookDocumentSchema } from '../../types/webhook-payload'; import { mapEnvelopeToWebhookDocumentPayload, ZWebhookDocumentSchema } from '../../types/webhook-payload';
import { getFileServerSide } from '../../universal/upload/get-file.server'; import { getFileServerSide } from '../../universal/upload/get-file.server';
import { putNormalizedPdfFileServerSide } from '../../universal/upload/put-file.server'; import { putNormalizedPdfFileServerSide } from '../../universal/upload/put-file.server';
@@ -124,7 +126,26 @@ export const sendDocument = async ({ id, userId, teamId, sendEmail, requestMetad
const legacyDocumentId = mapSecondaryIdToDocumentId(envelope.secondaryId); const legacyDocumentId = mapSecondaryIdToDocumentId(envelope.secondaryId);
const signingOrder = envelope.documentMeta?.signingOrder || DocumentSigningOrder.PARALLEL; let signingOrder = envelope.documentMeta?.signingOrder || DocumentSigningOrder.PARALLEL;
if (isTspEnvelope(envelope) && signingOrder === DocumentSigningOrder.PARALLEL && envelope.documentMeta) {
console.warn(
`[CSC] Coercing signingOrder=PARALLEL → SEQUENTIAL for ${envelope.signatureLevel} envelope ${envelope.id} at send time. The schema-layer guard should have caught this earlier.`,
);
await prisma.documentMeta.update({
where: {
id: envelope.documentMeta.id,
},
data: {
signingOrder: DocumentSigningOrder.SEQUENTIAL,
},
});
signingOrder = DocumentSigningOrder.SEQUENTIAL;
envelope.documentMeta.signingOrder = DocumentSigningOrder.SEQUENTIAL;
}
let recipientsToNotify = envelope.recipients; let recipientsToNotify = envelope.recipients;
@@ -139,7 +160,7 @@ export const sendDocument = async ({ id, userId, teamId, sendEmail, requestMetad
throw new Error('Missing envelope items'); throw new Error('Missing envelope items');
} }
if (envelope.formValues) { if (envelope.formValues && envelope.status === DocumentStatus.DRAFT) {
await Promise.all( await Promise.all(
envelope.envelopeItems.map(async (envelopeItem) => { envelope.envelopeItems.map(async (envelopeItem) => {
await injectFormValuesIntoDocument(envelope, envelopeItem); await injectFormValuesIntoDocument(envelope, envelopeItem);
@@ -225,6 +246,12 @@ export const sendDocument = async ({ id, userId, teamId, sendEmail, requestMetad
} }
} }
if (isTspEnvelope(envelope) && envelope.status === DocumentStatus.DRAFT) {
await materializeTspAnchorsForEnvelope({
envelopeId: envelope.id,
});
}
const updatedEnvelope = await prisma.$transaction(async (tx) => { const updatedEnvelope = await prisma.$transaction(async (tx) => {
if (envelope.status === DocumentStatus.DRAFT) { if (envelope.status === DocumentStatus.DRAFT) {
await tx.documentAuditLog.create({ await tx.documentAuditLog.create({
@@ -6,6 +6,7 @@ import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import type { Envelope, Field, Recipient } from '@prisma/client'; import type { Envelope, Field, Recipient } from '@prisma/client';
import { assertEnvelopeMutable } from '../envelope/assert-envelope-mutable';
import { convertPlaceholdersToFieldInputs, extractPdfPlaceholders } from '../pdf/auto-place-fields'; import { convertPlaceholdersToFieldInputs, extractPdfPlaceholders } from '../pdf/auto-place-fields';
import { findRecipientByPlaceholder } from '../pdf/helpers'; import { findRecipientByPlaceholder } from '../pdf/helpers';
import { insertFormValuesInPdf } from '../pdf/insert-form-values-in-pdf'; import { insertFormValuesInPdf } from '../pdf/insert-form-values-in-pdf';
@@ -96,6 +97,8 @@ export const UNSAFE_replaceEnvelopeItemPdf = async ({
let didFieldsChange = false; let didFieldsChange = false;
const updatedEnvelopeItem = await prisma.$transaction(async (tx) => { const updatedEnvelopeItem = await prisma.$transaction(async (tx) => {
await assertEnvelopeMutable(envelope, tx);
const updatedItem = await tx.envelopeItem.update({ const updatedItem = await tx.envelopeItem.update({
where: { where: {
id: envelopeItemId, id: envelopeItemId,
@@ -0,0 +1,81 @@
import { DocumentStatus, type Envelope, type Prisma } from '@prisma/client';
import { match } from 'ts-pattern';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { isTspEnvelope } from '../../types/signature-level';
type EnvelopeMutableSnapshot = {
signatureLevel: string;
status: DocumentStatus;
};
type EnvelopeIdRef = Pick<Envelope, 'id'>;
/**
* Reject authoring mutations on an AES/QES envelope past DRAFT.
*
* The TSP mutation lock fires at distribution so the owner cannot replace the
* PDF between a recipient completing service-scope OAuth (against PDF_v1) and
* clicking Sign (now against PDF_v2). The SAD would authorise PDF_v2's digest
* while the recipient viewed PDF_v1 — a WYSIWYS break.
*
* SES envelopes pass through unchanged. The existing per-route guards still
* enforce COMPLETED/REJECTED rejection for them.
*
* Call this **twice** at every TSP-eligible authoring route:
*
* 1. Outside the transaction with the pre-fetched envelope snapshot —
* `assertEnvelopeMutable(envelope)` — fast-fail without a DB round-trip.
* 2. Inside the transaction with `tx` — `assertEnvelopeMutable(envelope, tx)`
* — re-fetches under the transaction's snapshot, closing the TOCTOU
* window against a concurrent `sendDocument` committing DRAFT → PENDING
* between the snapshot read and the mutation.
*
* Throws:
* - `ENVELOPE_TSP_LOCKED` when the envelope is PENDING (the case unique to
* the TSP lock — SES routes happily allow PENDING).
* - `ENVELOPE_COMPLETED` / `ENVELOPE_REJECTED` for those terminal states, to
* stay consistent with the existing envelope-state error vocabulary.
*/
export function assertEnvelopeMutable(envelope: EnvelopeMutableSnapshot): Promise<void>;
export function assertEnvelopeMutable(envelope: EnvelopeIdRef, tx: Prisma.TransactionClient): Promise<void>;
export async function assertEnvelopeMutable(
envelope: EnvelopeMutableSnapshot | EnvelopeIdRef,
tx?: Prisma.TransactionClient,
): Promise<void> {
if (tx) {
return await refetchAndAssert(tx, (envelope as EnvelopeIdRef).id);
}
assertSnapshotMutable(envelope as EnvelopeMutableSnapshot);
}
const refetchAndAssert = async (tx: Prisma.TransactionClient, envelopeId: string): Promise<void> => {
const refetched = await tx.envelope.findFirstOrThrow({
where: { id: envelopeId },
select: { signatureLevel: true, status: true },
});
assertSnapshotMutable(refetched);
};
const assertSnapshotMutable = (envelope: EnvelopeMutableSnapshot): void => {
if (!isTspEnvelope(envelope)) {
return;
}
if (envelope.status === DocumentStatus.DRAFT) {
return;
}
const errorCode = match(envelope.status)
.with(DocumentStatus.PENDING, () => AppErrorCode.ENVELOPE_TSP_LOCKED)
.with(DocumentStatus.COMPLETED, () => AppErrorCode.ENVELOPE_COMPLETED)
.with(DocumentStatus.REJECTED, () => AppErrorCode.ENVELOPE_REJECTED)
.otherwise(() => AppErrorCode.INVALID_REQUEST);
throw new AppError(errorCode, {
message: `Envelope is locked — AES/QES envelopes cannot be modified after leaving DRAFT (current status: ${envelope.status}).`,
});
};
@@ -29,6 +29,7 @@ import type {
import type { TDocumentFormValues } from '../../types/document-form-values'; import type { TDocumentFormValues } from '../../types/document-form-values';
import type { TEnvelopeAttachmentType } from '../../types/envelope-attachment'; import type { TEnvelopeAttachmentType } from '../../types/envelope-attachment';
import type { TFieldAndMeta } from '../../types/field-meta'; import type { TFieldAndMeta } from '../../types/field-meta';
import type { TSignatureLevel } from '../../types/signature-level';
import { mapEnvelopeToWebhookDocumentPayload, ZWebhookDocumentSchema } from '../../types/webhook-payload'; import { mapEnvelopeToWebhookDocumentPayload, ZWebhookDocumentSchema } from '../../types/webhook-payload';
import { getFileServerSide } from '../../universal/upload/get-file.server'; import { getFileServerSide } from '../../universal/upload/get-file.server';
import { putPdfFileServerSide } from '../../universal/upload/put-file.server'; import { putPdfFileServerSide } from '../../universal/upload/put-file.server';
@@ -36,6 +37,8 @@ import { extractDerivedDocumentMeta } from '../../utils/document';
import { createDocumentAuthOptions, createRecipientAuthOptions } from '../../utils/document-auth'; import { createDocumentAuthOptions, createRecipientAuthOptions } from '../../utils/document-auth';
import { buildTeamWhereQuery } from '../../utils/teams'; import { buildTeamWhereQuery } from '../../utils/teams';
import { incrementDocumentId, incrementTemplateId } from '../envelope/increment-id'; import { incrementDocumentId, incrementTemplateId } from '../envelope/increment-id';
import { assertCompatibleRecipientRole } from '../signature-level/assert-compatible-recipient-role';
import { resolveSignatureLevel } from '../signature-level/resolve-signature-level';
import { assertOrganisationRatesAndLimits } from '../rate-limit/assert-organisation-rates-and-limits'; import { assertOrganisationRatesAndLimits } from '../rate-limit/assert-organisation-rates-and-limits';
import { getTeamSettings } from '../team/get-team-settings'; import { getTeamSettings } from '../team/get-team-settings';
import { assertUserNotDisabledById } from '../user/assert-user-not-disabled'; import { assertUserNotDisabledById } from '../user/assert-user-not-disabled';
@@ -89,6 +92,7 @@ export type CreateEnvelopeOptions = {
recipients?: CreateEnvelopeRecipientOptions[]; recipients?: CreateEnvelopeRecipientOptions[];
folderId?: string; folderId?: string;
delegatedDocumentOwner?: string; delegatedDocumentOwner?: string;
signatureLevel?: TSignatureLevel;
}; };
attachments?: Array<{ attachments?: Array<{
label: string; label: string;
@@ -137,8 +141,14 @@ export const createEnvelope = async ({
publicDescription, publicDescription,
visibility: visibilityOverride, visibility: visibilityOverride,
delegatedDocumentOwner, delegatedDocumentOwner,
signatureLevel: requestedSignatureLevel,
} = data; } = data;
const signatureLevel = resolveSignatureLevel({
requested: requestedSignatureLevel,
strict: true,
});
const team = await prisma.team.findFirst({ const team = await prisma.team.findFirst({
where: buildTeamWhereQuery({ teamId, userId }), where: buildTeamWhereQuery({ teamId, userId }),
include: { include: {
@@ -195,6 +205,17 @@ export const createEnvelope = async ({
}); });
} }
// CSC / TSP signing flows assume the V2 envelope shape: per-recipient
// anchors, materialised PDF lineage, sequential signing, mutation lock.
// The legacy V1 (Document) model can't carry that state, so AES/QES on V1
// is structurally unsupported and must fail at create time — not later at
// sign or seal time when the cause is harder to attribute.
if (signatureLevel !== 'SES' && internalVersion === 1) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: `Envelopes signed at '${signatureLevel}' require internalVersion=2; the legacy V1 envelope shape cannot host TSP signing.`,
});
}
let envelopeItems = data.envelopeItems; let envelopeItems = data.envelopeItems;
// Todo: Envelopes - Remove // Todo: Envelopes - Remove
@@ -255,6 +276,10 @@ export const createEnvelope = async ({
}); });
} }
for (const recipient of data.recipients ?? []) {
assertCompatibleRecipientRole({ signatureLevel, role: recipient.role });
}
const visibility = visibilityOverride || settings.documentVisibility; const visibility = visibilityOverride || settings.documentVisibility;
const emailId = meta?.emailId; const emailId = meta?.emailId;
@@ -311,10 +336,14 @@ export const createEnvelope = async ({
const [documentMeta, secondaryId, delegatedOwner] = await Promise.all([ const [documentMeta, secondaryId, delegatedOwner] = await Promise.all([
prisma.documentMeta.create({ prisma.documentMeta.create({
data: extractDerivedDocumentMeta(settings, { data: extractDerivedDocumentMeta(
...meta, settings,
timezone: timezoneToUse, {
}), ...meta,
timezone: timezoneToUse,
},
signatureLevel,
),
}), }),
type === EnvelopeType.DOCUMENT type === EnvelopeType.DOCUMENT
? incrementDocumentId().then((v) => v.formattedDocumentId) ? incrementDocumentId().then((v) => v.formattedDocumentId)
@@ -331,6 +360,7 @@ export const createEnvelope = async ({
internalVersion, internalVersion,
type, type,
title, title,
signatureLevel,
qrToken: prefixedId('qr'), qrToken: prefixedId('qr'),
externalId, externalId,
envelopeItems: { envelopeItems: {
@@ -4,11 +4,13 @@ import pMap from 'p-map';
import { omit } from 'remeda'; import { omit } from 'remeda';
import { AppError, AppErrorCode } from '../../errors/app-error'; import { AppError, AppErrorCode } from '../../errors/app-error';
import { ZSignatureLevelSchema } from '../../types/signature-level';
import { mapEnvelopeToWebhookDocumentPayload, ZWebhookDocumentSchema } from '../../types/webhook-payload'; import { mapEnvelopeToWebhookDocumentPayload, ZWebhookDocumentSchema } from '../../types/webhook-payload';
import { nanoid, prefixedId } from '../../universal/id'; import { nanoid, prefixedId } from '../../universal/id';
import type { EnvelopeIdOptions } from '../../utils/envelope'; import type { EnvelopeIdOptions } from '../../utils/envelope';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id'; import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
import { incrementDocumentId, incrementTemplateId } from '../envelope/increment-id'; import { incrementDocumentId, incrementTemplateId } from '../envelope/increment-id';
import { resolveSignatureLevel } from '../signature-level/resolve-signature-level';
import { assertOrganisationRatesAndLimits } from '../rate-limit/assert-organisation-rates-and-limits'; import { assertOrganisationRatesAndLimits } from '../rate-limit/assert-organisation-rates-and-limits';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook'; import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
@@ -40,6 +42,7 @@ export const duplicateEnvelope = async ({ id, userId, teamId, overrides }: Dupli
title: true, title: true,
userId: true, userId: true,
internalVersion: true, internalVersion: true,
signatureLevel: true,
templateType: true, templateType: true,
publicTitle: true, publicTitle: true,
publicDescription: true, publicDescription: true,
@@ -116,12 +119,21 @@ export const duplicateEnvelope = async ({ id, userId, teamId, overrides }: Dupli
? 'PRIVATE' ? 'PRIVATE'
: (envelope.templateType ?? undefined); : (envelope.templateType ?? undefined);
// The source level is a free-form TEXT column — parse defensively before
// handing to the resolver. Coerce (not strict) because instance mode may have
// changed since the source envelope was created.
const duplicatedSignatureLevel = resolveSignatureLevel({
requested: ZSignatureLevelSchema.parse(envelope.signatureLevel),
strict: false,
});
const duplicatedEnvelope = await prisma.envelope.create({ const duplicatedEnvelope = await prisma.envelope.create({
data: { data: {
id: prefixedId('envelope'), id: prefixedId('envelope'),
secondaryId, secondaryId,
type: targetType, type: targetType,
internalVersion: envelope.internalVersion, internalVersion: envelope.internalVersion,
signatureLevel: duplicatedSignatureLevel,
userId, userId,
teamId, teamId,
title: envelope.title + ' (copy)', title: envelope.title + ' (copy)',
@@ -36,6 +36,7 @@ export const ZEnvelopeForSigningResponse = z.object({
authOptions: true, authOptions: true,
userId: true, userId: true,
teamId: true, teamId: true,
signatureLevel: true,
}).extend({ }).extend({
documentMeta: DocumentMetaSchema.pick({ documentMeta: DocumentMetaSchema.pick({
signingOrder: true, signingOrder: true,
@@ -15,7 +15,10 @@ import { createDocumentAuthOptions, extractDocumentAuthMethods } from '../../uti
import type { EnvelopeIdOptions } from '../../utils/envelope'; import type { EnvelopeIdOptions } from '../../utils/envelope';
import { buildTeamWhereQuery, canAccessTeamDocument } from '../../utils/teams'; import { buildTeamWhereQuery, canAccessTeamDocument } from '../../utils/teams';
import { recomputeNextReminderForEnvelope } from '../recipient/update-recipient-next-reminder'; import { recomputeNextReminderForEnvelope } from '../recipient/update-recipient-next-reminder';
import { assertCompatibleDictateNextSigner } from '../signature-level/assert-compatible-dictate-next-signer';
import { assertCompatibleSigningOrder } from '../signature-level/assert-compatible-signing-order';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook'; import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
import { assertEnvelopeMutable } from './assert-envelope-mutable';
import { getEnvelopeWhereInput } from './get-envelope-by-id'; import { getEnvelopeWhereInput } from './get-envelope-by-id';
export type UpdateEnvelopeOptions = { export type UpdateEnvelopeOptions = {
@@ -76,6 +79,22 @@ export const updateEnvelope = async ({
}); });
} }
assertEnvelopeMutable(envelope);
if (meta.signingOrder !== undefined) {
assertCompatibleSigningOrder({
signatureLevel: envelope.signatureLevel,
signingOrder: meta.signingOrder,
});
}
if (meta.allowDictateNextSigner !== undefined) {
assertCompatibleDictateNextSigner({
signatureLevel: envelope.signatureLevel,
allowDictateNextSigner: meta.allowDictateNextSigner,
});
}
if (envelope.type !== EnvelopeType.TEMPLATE && (data.publicTitle || data.publicDescription || data.templateType)) { if (envelope.type !== EnvelopeType.TEMPLATE && (data.publicTitle || data.publicDescription || data.templateType)) {
throw new AppError(AppErrorCode.INVALID_BODY, { throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'You cannot update the template fields for document type envelopes', message: 'You cannot update the template fields for document type envelopes',
@@ -297,6 +316,8 @@ export const updateEnvelope = async ({
// } // }
const updatedEnvelope = await prisma.$transaction(async (tx) => { const updatedEnvelope = await prisma.$transaction(async (tx) => {
await assertEnvelopeMutable(envelope, tx);
const result = await tx.envelope.update({ const result = await tx.envelope.update({
where: { where: {
id: envelope.id, id: envelope.id,
@@ -12,6 +12,7 @@ import { AppError, AppErrorCode } from '../../errors/app-error';
import type { EnvelopeIdOptions } from '../../utils/envelope'; import type { EnvelopeIdOptions } from '../../utils/envelope';
import { mapFieldToLegacyField } from '../../utils/fields'; import { mapFieldToLegacyField } from '../../utils/fields';
import { canRecipientFieldsBeModified } from '../../utils/recipients'; import { canRecipientFieldsBeModified } from '../../utils/recipients';
import { assertEnvelopeMutable } from '../envelope/assert-envelope-mutable';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id'; import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
import { type BoundingBox, whiteoutRegions } from '../pdf/auto-place-fields'; import { type BoundingBox, whiteoutRegions } from '../pdf/auto-place-fields';
@@ -93,6 +94,8 @@ export const createEnvelopeFields = async ({
}); });
} }
assertEnvelopeMutable(envelope);
if (envelope.type === EnvelopeType.DOCUMENT && envelope.completedAt) { if (envelope.type === EnvelopeType.DOCUMENT && envelope.completedAt) {
throw new AppError(AppErrorCode.INVALID_REQUEST, { throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Envelope already complete', message: 'Envelope already complete',
@@ -242,6 +245,8 @@ export const createEnvelopeFields = async ({
}); });
const createdFields = await prisma.$transaction(async (tx) => { const createdFields = await prisma.$transaction(async (tx) => {
await assertEnvelopeMutable(envelope, tx);
const newlyCreatedFields = await tx.field.createManyAndReturn({ const newlyCreatedFields = await tx.field.createManyAndReturn({
data: validatedFields.map((field) => ({ data: validatedFields.map((field) => ({
type: field.type, type: field.type,
@@ -6,6 +6,7 @@ import { EnvelopeType } from '@prisma/client';
import { AppError, AppErrorCode } from '../../errors/app-error'; import { AppError, AppErrorCode } from '../../errors/app-error';
import { canRecipientFieldsBeModified } from '../../utils/recipients'; import { canRecipientFieldsBeModified } from '../../utils/recipients';
import { assertEnvelopeMutable } from '../envelope/assert-envelope-mutable';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id'; import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
export interface DeleteDocumentFieldOptions { export interface DeleteDocumentFieldOptions {
@@ -59,6 +60,8 @@ export const deleteDocumentField = async ({ userId, teamId, fieldId, requestMeta
}); });
} }
assertEnvelopeMutable(envelope);
if (envelope.completedAt) { if (envelope.completedAt) {
throw new AppError(AppErrorCode.INVALID_REQUEST, { throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Document already complete', message: 'Document already complete',
@@ -81,6 +84,8 @@ export const deleteDocumentField = async ({ userId, teamId, fieldId, requestMeta
} }
return await prisma.$transaction(async (tx) => { return await prisma.$transaction(async (tx) => {
await assertEnvelopeMutable(envelope, tx);
const deletedField = await tx.field.delete({ const deletedField = await tx.field.delete({
where: { where: {
id: fieldId, id: fieldId,
@@ -9,6 +9,7 @@ import { AppError, AppErrorCode } from '../../errors/app-error';
import type { EnvelopeIdOptions } from '../../utils/envelope'; import type { EnvelopeIdOptions } from '../../utils/envelope';
import { mapFieldToLegacyField } from '../../utils/fields'; import { mapFieldToLegacyField } from '../../utils/fields';
import { canRecipientFieldsBeModified } from '../../utils/recipients'; import { canRecipientFieldsBeModified } from '../../utils/recipients';
import { assertEnvelopeMutable } from '../envelope/assert-envelope-mutable';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id'; import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
export interface UpdateEnvelopeFieldsOptions { export interface UpdateEnvelopeFieldsOptions {
@@ -60,6 +61,8 @@ export const updateEnvelopeFields = async ({
}); });
} }
assertEnvelopeMutable(envelope);
if (envelope.completedAt) { if (envelope.completedAt) {
throw new AppError(AppErrorCode.INVALID_REQUEST, { throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Envelope already complete', message: 'Envelope already complete',
@@ -115,6 +118,8 @@ export const updateEnvelopeFields = async ({
}); });
const updatedFields = await prisma.$transaction(async (tx) => { const updatedFields = await prisma.$transaction(async (tx) => {
await assertEnvelopeMutable(envelope, tx);
return await Promise.all( return await Promise.all(
fieldsToUpdate.map(async ({ originalField, updateData, recipientEmail }) => { fieldsToUpdate.map(async ({ originalField, updateData, recipientEmail }) => {
const updatedField = await tx.field.update({ const updatedField = await tx.field.update({
@@ -0,0 +1,69 @@
import { AppError, AppErrorCode } from '../../errors/app-error';
import type { LicenseFlag, TCachedLicense } from '../../types/license';
import { env } from '../../utils/env';
import { LicenseClient } from './license-client';
type AssertLicensedForOptions = {
/**
* Override the AppError code thrown when the assertion fails.
*
* Defaults to `AppErrorCode.FORBIDDEN`. Callers that need a more specific
* surface — for example the CSC transport throwing `CSC_UNLICENSED` at
* transport-create time — pass their own code here.
*/
errorCode?: string;
/**
* Override the AppError message thrown when the assertion fails.
*/
message?: string;
};
/**
* Assert the configured Documenso licence grants `flag`. Reads the
* {@link LicenseClient} cache; never re-pings the licence server.
*
* - No `NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY` → throws. No licensing intent.
* - Key set, claim unverifiable (no client, null cache, read throws,
* `license: null`) → passes. Mirrors how org-claim gates keep running on
* last known state when the licence server is unreachable; paying
* operators shouldn't be locked out by transient infra.
* - Key set, claim loaded and denies the flag (bad standing or flag falsy)
* → throws.
*/
export const assertLicensedFor = async (flag: LicenseFlag, options?: AssertLicensedForOptions): Promise<void> => {
const denied = (): AppError =>
new AppError(options?.errorCode ?? AppErrorCode.FORBIDDEN, {
message: options?.message ?? `License does not include the "${flag}" feature.`,
});
// No licence key configured = no licensing intent. Fail closed unconditionally
// so unlicensed instances cannot reach gated features simply because the
// licence cache is empty.
if (!env('NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY')) {
throw denied();
}
let cached: TCachedLicense | null = null;
const licenseClient = LicenseClient.getInstance();
if (licenseClient) {
cached = await licenseClient?.getCachedLicense().catch(() => null);
}
// Licence key is configured but we have no positively-verified claim to
// check. Fail-open — see block comment for the full set of conditions and
// rationale.
if (!cached?.license) {
return;
}
const inGoodStanding = cached.derivedStatus === 'ACTIVE' || cached.derivedStatus === 'PAST_DUE';
const flagGranted = Boolean(cached.license.flags[flag]);
if (!inGoodStanding || !flagGranted) {
throw denied();
}
};
@@ -10,7 +10,9 @@ import { EnvelopeType, RecipientRole, SendStatus, SigningStatus } from '@prisma/
import { AppError, AppErrorCode } from '../../errors/app-error'; import { AppError, AppErrorCode } from '../../errors/app-error';
import type { EnvelopeIdOptions } from '../../utils/envelope'; import type { EnvelopeIdOptions } from '../../utils/envelope';
import { mapRecipientToLegacyRecipient } from '../../utils/recipients'; import { mapRecipientToLegacyRecipient } from '../../utils/recipients';
import { assertEnvelopeMutable } from '../envelope/assert-envelope-mutable';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id'; import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
import { assertCompatibleRecipientRole } from '../signature-level/assert-compatible-recipient-role';
export interface CreateEnvelopeRecipientsOptions { export interface CreateEnvelopeRecipientsOptions {
userId: number; userId: number;
@@ -63,6 +65,8 @@ export const createEnvelopeRecipients = async ({
}); });
} }
assertEnvelopeMutable(envelope);
if (envelope.completedAt) { if (envelope.completedAt) {
throw new AppError(AppErrorCode.INVALID_REQUEST, { throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Envelope already complete', message: 'Envelope already complete',
@@ -80,12 +84,21 @@ export const createEnvelopeRecipients = async ({
}); });
} }
for (const recipient of recipientsToCreate) {
assertCompatibleRecipientRole({
signatureLevel: envelope.signatureLevel,
role: recipient.role,
});
}
const normalizedRecipients = recipientsToCreate.map((recipient) => ({ const normalizedRecipients = recipientsToCreate.map((recipient) => ({
...recipient, ...recipient,
email: recipient.email.toLowerCase(), email: recipient.email.toLowerCase(),
})); }));
const createdRecipients = await prisma.$transaction(async (tx) => { const createdRecipients = await prisma.$transaction(async (tx) => {
await assertEnvelopeMutable(envelope, tx);
return await Promise.all( return await Promise.all(
normalizedRecipients.map(async (recipient) => { normalizedRecipients.map(async (recipient) => {
const authOptions = createRecipientAuthOptions({ const authOptions = createRecipientAuthOptions({
@@ -16,6 +16,7 @@ import { canRecipientBeModified, isRecipientEmailValidForSending } from '../../u
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n'; import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { buildTeamWhereQuery } from '../../utils/teams'; import { buildTeamWhereQuery } from '../../utils/teams';
import { getEmailContext } from '../email/get-email-context'; import { getEmailContext } from '../email/get-email-context';
import { assertEnvelopeMutable } from '../envelope/assert-envelope-mutable';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id'; import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
import { assertOrganisationRatesAndLimits } from '../rate-limit/assert-organisation-rates-and-limits'; import { assertOrganisationRatesAndLimits } from '../rate-limit/assert-organisation-rates-and-limits';
@@ -72,6 +73,8 @@ export const deleteEnvelopeRecipient = async ({
}); });
} }
assertEnvelopeMutable(envelope);
if (envelope.completedAt) { if (envelope.completedAt) {
throw new AppError(AppErrorCode.INVALID_REQUEST, { throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Document already complete', message: 'Document already complete',
@@ -109,6 +112,8 @@ export const deleteEnvelopeRecipient = async ({
}); });
const deletedRecipient = await prisma.$transaction(async (tx) => { const deletedRecipient = await prisma.$transaction(async (tx) => {
await assertEnvelopeMutable(envelope, tx);
if (envelope.type === EnvelopeType.DOCUMENT) { if (envelope.type === EnvelopeType.DOCUMENT) {
await tx.documentAuditLog.create({ await tx.documentAuditLog.create({
data: createDocumentAuditLogData({ data: createDocumentAuditLogData({
@@ -22,7 +22,9 @@ import { logger } from '../../utils/logger';
import { canRecipientBeModified, isRecipientEmailValidForSending } from '../../utils/recipients'; import { canRecipientBeModified, isRecipientEmailValidForSending } from '../../utils/recipients';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n'; import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { getEmailContext } from '../email/get-email-context'; import { getEmailContext } from '../email/get-email-context';
import { assertEnvelopeMutable } from '../envelope/assert-envelope-mutable';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id'; import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
import { assertCompatibleRecipientRole } from '../signature-level/assert-compatible-recipient-role';
import { assertOrganisationRatesAndLimits } from '../rate-limit/assert-organisation-rates-and-limits'; import { assertOrganisationRatesAndLimits } from '../rate-limit/assert-organisation-rates-and-limits';
export interface SetDocumentRecipientsOptions { export interface SetDocumentRecipientsOptions {
@@ -80,6 +82,8 @@ export const setDocumentRecipients = async ({
throw new Error('Document not found'); throw new Error('Document not found');
} }
assertEnvelopeMutable(envelope);
if (envelope.completedAt) { if (envelope.completedAt) {
throw new Error('Document already complete'); throw new Error('Document already complete');
} }
@@ -105,6 +109,13 @@ export const setDocumentRecipients = async ({
}); });
} }
for (const recipient of recipients) {
assertCompatibleRecipientRole({
signatureLevel: envelope.signatureLevel,
role: recipient.role,
});
}
const normalizedRecipients = recipients.map((recipient) => ({ const normalizedRecipients = recipients.map((recipient) => ({
...recipient, ...recipient,
email: recipient.email.toLowerCase(), email: recipient.email.toLowerCase(),
@@ -139,6 +150,8 @@ export const setDocumentRecipients = async ({
}); });
const persistedRecipients = await prisma.$transaction(async (tx) => { const persistedRecipients = await prisma.$transaction(async (tx) => {
await assertEnvelopeMutable(envelope, tx);
return await Promise.all( return await Promise.all(
linkedRecipients.map(async (recipient) => { linkedRecipients.map(async (recipient) => {
let authOptions = ZRecipientAuthOptionsSchema.parse(recipient._persisted?.authOptions); let authOptions = ZRecipientAuthOptionsSchema.parse(recipient._persisted?.authOptions);
@@ -12,6 +12,7 @@ import { nanoid } from '../../universal/id';
import { createRecipientAuthOptions } from '../../utils/document-auth'; import { createRecipientAuthOptions } from '../../utils/document-auth';
import { type EnvelopeIdOptions, mapSecondaryIdToTemplateId } from '../../utils/envelope'; import { type EnvelopeIdOptions, mapSecondaryIdToTemplateId } from '../../utils/envelope';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id'; import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
import { assertCompatibleRecipientRole } from '../signature-level/assert-compatible-recipient-role';
export type SetTemplateRecipientsOptions = { export type SetTemplateRecipientsOptions = {
userId: number; userId: number;
@@ -60,6 +61,13 @@ export const setTemplateRecipients = async ({ userId, teamId, id, recipients }:
}); });
} }
for (const recipient of recipients) {
assertCompatibleRecipientRole({
signatureLevel: envelope.signatureLevel,
role: recipient.role,
});
}
const normalizedRecipients = recipients.map((recipient) => { const normalizedRecipients = recipients.map((recipient) => {
// Force replace any changes to the name or email of the direct recipient. // Force replace any changes to the name or email of the direct recipient.
if (envelope.directLink && recipient.id === envelope.directLink.directTemplateRecipientId) { if (envelope.directLink && recipient.id === envelope.directLink.directTemplateRecipientId) {
@@ -12,7 +12,9 @@ import { extractLegacyIds } from '../../universal/id';
import type { EnvelopeIdOptions } from '../../utils/envelope'; import type { EnvelopeIdOptions } from '../../utils/envelope';
import { mapFieldToLegacyField } from '../../utils/fields'; import { mapFieldToLegacyField } from '../../utils/fields';
import { canRecipientBeModified } from '../../utils/recipients'; import { canRecipientBeModified } from '../../utils/recipients';
import { assertEnvelopeMutable } from '../envelope/assert-envelope-mutable';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id'; import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
import { assertCompatibleRecipientRole } from '../signature-level/assert-compatible-recipient-role';
export interface UpdateEnvelopeRecipientsOptions { export interface UpdateEnvelopeRecipientsOptions {
userId: number; userId: number;
@@ -67,6 +69,8 @@ export const updateEnvelopeRecipients = async ({
}); });
} }
assertEnvelopeMutable(envelope);
if (envelope.completedAt) { if (envelope.completedAt) {
throw new AppError(AppErrorCode.INVALID_REQUEST, { throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Envelope already complete', message: 'Envelope already complete',
@@ -84,6 +88,17 @@ export const updateEnvelopeRecipients = async ({
}); });
} }
for (const recipient of recipients) {
if (recipient.role === undefined) {
continue;
}
assertCompatibleRecipientRole({
signatureLevel: envelope.signatureLevel,
role: recipient.role,
});
}
const recipientsToUpdate = recipients.map((recipient) => { const recipientsToUpdate = recipients.map((recipient) => {
const originalRecipient = envelope.recipients.find((existingRecipient) => existingRecipient.id === recipient.id); const originalRecipient = envelope.recipients.find((existingRecipient) => existingRecipient.id === recipient.id);
@@ -106,6 +121,8 @@ export const updateEnvelopeRecipients = async ({
}); });
const updatedRecipients = await prisma.$transaction(async (tx) => { const updatedRecipients = await prisma.$transaction(async (tx) => {
await assertEnvelopeMutable(envelope, tx);
return await Promise.all( return await Promise.all(
recipientsToUpdate.map(async ({ originalRecipient, updateData }) => { recipientsToUpdate.map(async ({ originalRecipient, updateData }) => {
let authOptions = ZRecipientAuthOptionsSchema.parse(originalRecipient.authOptions); let authOptions = ZRecipientAuthOptionsSchema.parse(originalRecipient.authOptions);
@@ -0,0 +1,35 @@
import { AppError, AppErrorCode } from '../../errors/app-error';
import { isTspEnvelope } from '../../types/signature-level';
type AssertCompatibleDictateNextSignerOptions = {
signatureLevel: string;
allowDictateNextSigner: boolean | null | undefined;
};
/**
* Reject `allowDictateNextSigner = true` on AES/QES envelopes.
*
* The TSP sign path has no nextSigner dictation — `prepareCscRecipientSigning`
* doesn't accept one and `executeTspSign` always advances to the strict
* SEQUENTIAL next signer. Allowing the flag to persist on a TSP envelope
* would advertise a UX feature the sign-time flow silently drops.
*
* SES envelopes pass through unchanged. A `null` / `undefined` / `false`
* value also passes through.
*/
export const assertCompatibleDictateNextSigner = ({
signatureLevel,
allowDictateNextSigner,
}: AssertCompatibleDictateNextSignerOptions): void => {
if (!isTspEnvelope({ signatureLevel })) {
return;
}
if (allowDictateNextSigner !== true) {
return;
}
throw new AppError(AppErrorCode.INVALID_BODY, {
message: `Envelopes signed at '${signatureLevel}' do not support next-signer dictation — the TSP sign path always advances to the strict SEQUENTIAL next recipient.`,
});
};
@@ -0,0 +1,33 @@
import { RecipientRole } from '@prisma/client';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { isTspEnvelope } from '../../types/signature-level';
type AssertCompatibleRecipientRoleOptions = {
signatureLevel: string;
role: RecipientRole;
};
/**
* Reject `RecipientRole.ASSISTANT` on AES/QES envelopes.
*
* Assistant recipients pre-fill fields on behalf of downstream signers. The
* TSP flow signs each recipient's complete PDF state with their own CSC
* credential, so an assistant role has no sign-time identity to bind to and
* `prepareCscRecipientSigning` has no handler for it.
*
* SES envelopes pass through unchanged.
*/
export const assertCompatibleRecipientRole = ({ signatureLevel, role }: AssertCompatibleRecipientRoleOptions): void => {
if (!isTspEnvelope({ signatureLevel })) {
return;
}
if (role === RecipientRole.ASSISTANT) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: `Envelopes signed at '${signatureLevel}' do not support the ASSISTANT role — the TSP flow signs each recipient's bytes with their own CSC credential and has no sign-time path for an assistant.`,
});
}
return;
};
@@ -0,0 +1,41 @@
import { DocumentSigningOrder } from '@prisma/client';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { isTspEnvelope } from '../../types/signature-level';
type AssertCompatibleSigningOrderOptions = {
signatureLevel: string;
signingOrder: DocumentSigningOrder | null | undefined;
};
/**
* Reject `signingOrder = PARALLEL` on AES/QES envelopes.
*
* Parallel signing produces conflicting incremental PDF updates over the
* same base state, breaking the per-recipient `/ByteRange` invariant that
* lets each TSP signature verify independently. Sequential is the only safe
* order for TSP-signed envelopes.
*
* SES envelopes pass through unchanged — PARALLEL remains the SES default.
* A `null` / `undefined` signingOrder also passes through (the create-envelope
* caller decides the default).
*
* Schema-layer guard. {@link sendDocument} re-coerces at distribution time
* as a defence-in-depth backstop.
*/
export const assertCompatibleSigningOrder = ({
signatureLevel,
signingOrder,
}: AssertCompatibleSigningOrderOptions): void => {
if (!isTspEnvelope({ signatureLevel })) {
return;
}
if (signingOrder !== DocumentSigningOrder.PARALLEL) {
return;
}
throw new AppError(AppErrorCode.INVALID_BODY, {
message: `Envelopes signed at '${signatureLevel}' require signingOrder=SEQUENTIAL — PARALLEL breaks the per-recipient /ByteRange invariant required for TSP signatures to verify independently.`,
});
};
@@ -0,0 +1,87 @@
import { CSC_INSTANCE_SIGNATURE_LEVEL, IS_INSTANCE_CSC_MODE } from '../../constants/app';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { SignatureLevel, type TSignatureLevel } from '../../types/signature-level';
type ResolveSignatureLevelOptions = {
/**
* The signature level the caller wants the envelope created at. Optional;
* when omitted the resolver returns the instance-mode default (`SES` for
* non-CSC instances, `AES` for CSC instances).
*/
requested?: TSignatureLevel;
/**
* When `true`, a conflict between `requested` and the current instance mode
* throws `CSC_INSTANCE_MODE_MISMATCH` rather than being silently coerced.
* When `false` (default), the resolver coerces incompatible inputs to the
* instance default without throwing.
*
* Omitting `requested` is accepted in both modes — the resolver returns the
* instance default rather than throwing.
*
* Use `strict: true` at call sites that take the level from external input
* (e.g. a public API) where silent coercion would mask caller mistakes.
*/
strict?: boolean;
};
/**
* Resolve the signature level for a new envelope.
*
* Server-only. Reads the `NEXT_PRIVATE_SIGNING_TRANSPORT` env var via
* {@link IS_INSTANCE_CSC_MODE} so call sites do not have to thread the
* instance mode through their own arguments. On CSC instances the coerced
* default also reads {@link CSC_INSTANCE_SIGNATURE_LEVEL} so operators can
* pick `AES` (default) or `QES` per their TSP capability.
*
* Source of truth for the `Envelope.signatureLevel` write at create-time. The
* column has no DB default by design — every caller flows through here so the
* instance-mode contract is enforced consistently.
*
* Coerce mode (default, `strict: false`):
*
* | Instance | requested | Result |
* |----------|----------------|-------------------------------------|
* | non-CSC | omitted | `SES` |
* | non-CSC | `SES` | `SES` |
* | non-CSC | `AES` / `QES` | `SES` (coerced) |
* | CSC | omitted | `CSC_INSTANCE_SIGNATURE_LEVEL()` |
* | CSC | `SES` | `CSC_INSTANCE_SIGNATURE_LEVEL()` |
* | CSC | `AES` / `QES` | passes through |
*
* Strict mode (`strict: true`): same instance defaults for the omitted case,
* but any conflict between `requested` and the instance mode throws
* `CSC_INSTANCE_MODE_MISMATCH` instead of silently coercing.
*
* Note: on CSC instances an explicit `AES`/`QES` request always passes
* through, even when it disagrees with `CSC_INSTANCE_SIGNATURE_LEVEL`. The
* env var sets the *default* legal tier; it doesn't restrict what callers
* can ask for. Cert-capability checks live at the TSP boundary.
*/
export const resolveSignatureLevel = ({
requested,
strict = false,
}: ResolveSignatureLevelOptions = {}): TSignatureLevel => {
const isCscInstance = IS_INSTANCE_CSC_MODE();
const instanceDefault = isCscInstance ? CSC_INSTANCE_SIGNATURE_LEVEL() : SignatureLevel.SES;
if (requested === undefined) {
return instanceDefault;
}
const isCompatible = isCscInstance ? requested !== SignatureLevel.SES : requested === SignatureLevel.SES;
if (isCompatible) {
return requested;
}
if (strict) {
throw new AppError(AppErrorCode.CSC_INSTANCE_MODE_MISMATCH, {
message: isCscInstance
? `signatureLevel '${requested}' is not supported on a CSC-mode instance — every recipient must sign through the configured Trust Service Provider.`
: `signatureLevel '${requested}' is not supported on a non-CSC instance — only 'SES' is permitted unless the CSC signing transport is configured.`,
});
}
return instanceDefault;
};
@@ -0,0 +1,36 @@
import { DocumentSigningOrder } from '@prisma/client';
import { isTspEnvelope } from '../../types/signature-level';
import { assertCompatibleSigningOrder } from './assert-compatible-signing-order';
type ResolveSigningOrderOptions = {
signatureLevel: string;
requested?: DocumentSigningOrder | null;
};
/**
* Resolve the persisted `signingOrder` for a new envelope's meta.
*
* - Explicit `requested` value: validated via
* {@link assertCompatibleSigningOrder} (throws on TSP + `PARALLEL`) and
* returned as-is.
* - Omitted `requested`: returns the level-appropriate default —
* `SEQUENTIAL` for AES/QES (the TSP `/ByteRange` invariant requires it),
* `PARALLEL` for SES (preserves existing SES default behaviour).
*
* Use at every create-time call site instead of the bare `|| PARALLEL`
* fallback. Mirrors {@link resolveSignatureLevel} in shape — the two pair
* up to keep create-time defaulting + TSP-mode coercion uniform.
*/
export const resolveSigningOrder = ({
signatureLevel,
requested,
}: ResolveSigningOrderOptions): DocumentSigningOrder => {
if (requested) {
assertCompatibleSigningOrder({ signatureLevel, signingOrder: requested });
return requested;
}
return isTspEnvelope({ signatureLevel }) ? DocumentSigningOrder.SEQUENTIAL : DocumentSigningOrder.PARALLEL;
};
@@ -25,6 +25,7 @@ import type { TRecipientActionAuthTypes } from '../../types/document-auth';
import { DocumentAccessAuth, ZRecipientAuthOptionsSchema } from '../../types/document-auth'; import { DocumentAccessAuth, ZRecipientAuthOptionsSchema } from '../../types/document-auth';
import { extractDerivedDocumentEmailSettings } from '../../types/document-email'; import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
import { ZFieldMetaSchema } from '../../types/field-meta'; import { ZFieldMetaSchema } from '../../types/field-meta';
import { ZSignatureLevelSchema } from '../../types/signature-level';
import { mapEnvelopeToWebhookDocumentPayload, ZWebhookDocumentSchema } from '../../types/webhook-payload'; import { mapEnvelopeToWebhookDocumentPayload, ZWebhookDocumentSchema } from '../../types/webhook-payload';
import type { ApiRequestMetadata } from '../../universal/extract-request-metadata'; import type { ApiRequestMetadata } from '../../universal/extract-request-metadata';
import { getFileServerSide } from '../../universal/upload/get-file.server'; import { getFileServerSide } from '../../universal/upload/get-file.server';
@@ -43,6 +44,7 @@ import { sendDocument } from '../document/send-document';
import { validateFieldAuth } from '../document/validate-field-auth'; import { validateFieldAuth } from '../document/validate-field-auth';
import { incrementDocumentId } from '../envelope/increment-id'; import { incrementDocumentId } from '../envelope/increment-id';
import { assertOrganisationRatesAndLimits } from '../rate-limit/assert-organisation-rates-and-limits'; import { assertOrganisationRatesAndLimits } from '../rate-limit/assert-organisation-rates-and-limits';
import { resolveSignatureLevel } from '../signature-level/resolve-signature-level';
import { getTeamSettings } from '../team/get-team-settings'; import { getTeamSettings } from '../team/get-team-settings';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook'; import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
@@ -197,6 +199,17 @@ export const createDocumentFromDirectTemplate = async ({
(recipient) => recipient.id !== directTemplateRecipient.id, (recipient) => recipient.id !== directTemplateRecipient.id,
); );
// Carry the template's level forward, coercing if the instance mode has
// changed since the template was created. ZSignatureLevelSchema parses the
// free-form TEXT column defensively. Resolved before meta extraction so
// signingOrder picks up the TSP-appropriate default + assertion.
const signatureLevel = resolveSignatureLevel({
requested: ZSignatureLevelSchema.parse(directTemplateEnvelope.signatureLevel),
strict: false,
});
const derivedDocumentMeta = extractDerivedDocumentMeta(settings, directTemplateEnvelope.documentMeta, signatureLevel);
// The resulting document contains every non-direct template recipient plus the // The resulting document contains every non-direct template recipient plus the
// direct recipient that is signing now. A recipientCount of 0 means unlimited. // direct recipient that is signing now. A recipientCount of 0 means unlimited.
// This mirrors the check in `sendDocument`, but must be done here because this // This mirrors the check in `sendDocument`, but must be done here because this
@@ -211,8 +224,6 @@ export const createDocumentFromDirectTemplate = async ({
}); });
} }
const derivedDocumentMeta = extractDerivedDocumentMeta(settings, directTemplateEnvelope.documentMeta);
// Associate, validate and map to a query every direct template recipient field with the provided fields. // Associate, validate and map to a query every direct template recipient field with the provided fields.
// Only process fields that are either required or have been signed by the user // Only process fields that are either required or have been signed by the user
const fieldsToProcess = directTemplateRecipient.fields.filter((templateField) => { const fieldsToProcess = directTemplateRecipient.fields.filter((templateField) => {
@@ -352,6 +363,7 @@ export const createDocumentFromDirectTemplate = async ({
secondaryId: incrementedDocumentId.formattedDocumentId, secondaryId: incrementedDocumentId.formattedDocumentId,
type: EnvelopeType.DOCUMENT, type: EnvelopeType.DOCUMENT,
internalVersion: directTemplateEnvelope.internalVersion, internalVersion: directTemplateEnvelope.internalVersion,
signatureLevel,
qrToken: prefixedId('qr'), qrToken: prefixedId('qr'),
source: DocumentSource.TEMPLATE_DIRECT_LINK, source: DocumentSource.TEMPLATE_DIRECT_LINK,
templateId: directTemplateEnvelopeLegacyId, templateId: directTemplateEnvelopeLegacyId,
@@ -33,6 +33,7 @@ import type {
TTextFieldMeta, TTextFieldMeta,
} from '../../types/field-meta'; } from '../../types/field-meta';
import { ZCheckboxFieldMeta, ZDropdownFieldMeta, ZFieldMetaSchema, ZRadioFieldMeta } from '../../types/field-meta'; import { ZCheckboxFieldMeta, ZDropdownFieldMeta, ZFieldMetaSchema, ZRadioFieldMeta } from '../../types/field-meta';
import { ZSignatureLevelSchema } from '../../types/signature-level';
import { mapEnvelopeToWebhookDocumentPayload, ZWebhookDocumentSchema } from '../../types/webhook-payload'; import { mapEnvelopeToWebhookDocumentPayload, ZWebhookDocumentSchema } from '../../types/webhook-payload';
import type { ApiRequestMetadata } from '../../universal/extract-request-metadata'; import type { ApiRequestMetadata } from '../../universal/extract-request-metadata';
import { getFileServerSide } from '../../universal/upload/get-file.server'; import { getFileServerSide } from '../../universal/upload/get-file.server';
@@ -50,6 +51,7 @@ import { buildTeamWhereQuery } from '../../utils/teams';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id'; import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
import { incrementDocumentId } from '../envelope/increment-id'; import { incrementDocumentId } from '../envelope/increment-id';
import { insertFormValuesInPdf } from '../pdf/insert-form-values-in-pdf'; import { insertFormValuesInPdf } from '../pdf/insert-form-values-in-pdf';
import { resolveSignatureLevel } from '../signature-level/resolve-signature-level';
import { assertOrganisationRatesAndLimits } from '../rate-limit/assert-organisation-rates-and-limits'; import { assertOrganisationRatesAndLimits } from '../rate-limit/assert-organisation-rates-and-limits';
import { getTeamSettings } from '../team/get-team-settings'; import { getTeamSettings } from '../team/get-team-settings';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook'; import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
@@ -513,23 +515,36 @@ export const createDocumentFromTemplate = async ({
const incrementedDocumentId = await incrementDocumentId(); const incrementedDocumentId = await incrementDocumentId();
// Carry the template's level forward, coercing if the instance mode has
// changed since the template was created. ZSignatureLevelSchema parses the
// free-form TEXT column defensively. Resolved before meta extraction so
// signingOrder picks up the TSP-appropriate default + assertion.
const signatureLevel = resolveSignatureLevel({
requested: ZSignatureLevelSchema.parse(template.signatureLevel),
strict: false,
});
const documentMeta = await prisma.documentMeta.create({ const documentMeta = await prisma.documentMeta.create({
data: extractDerivedDocumentMeta(settings, { data: extractDerivedDocumentMeta(
subject: override?.subject || template.documentMeta?.subject, settings,
message: override?.message || template.documentMeta?.message, {
timezone: override?.timezone || template.documentMeta?.timezone, subject: override?.subject || template.documentMeta?.subject,
dateFormat: override?.dateFormat || template.documentMeta?.dateFormat, message: override?.message || template.documentMeta?.message,
redirectUrl: override?.redirectUrl || template.documentMeta?.redirectUrl, timezone: override?.timezone || template.documentMeta?.timezone,
distributionMethod: override?.distributionMethod || template.documentMeta?.distributionMethod, dateFormat: override?.dateFormat || template.documentMeta?.dateFormat,
emailSettings: override?.emailSettings || template.documentMeta?.emailSettings, redirectUrl: override?.redirectUrl || template.documentMeta?.redirectUrl,
signingOrder: override?.signingOrder || template.documentMeta?.signingOrder, distributionMethod: override?.distributionMethod || template.documentMeta?.distributionMethod,
language: override?.language || template.documentMeta?.language || settings.documentLanguage, emailSettings: override?.emailSettings || template.documentMeta?.emailSettings,
typedSignatureEnabled: override?.typedSignatureEnabled ?? template.documentMeta?.typedSignatureEnabled, signingOrder: override?.signingOrder || template.documentMeta?.signingOrder,
uploadSignatureEnabled: override?.uploadSignatureEnabled ?? template.documentMeta?.uploadSignatureEnabled, language: override?.language || template.documentMeta?.language || settings.documentLanguage,
drawSignatureEnabled: override?.drawSignatureEnabled ?? template.documentMeta?.drawSignatureEnabled, typedSignatureEnabled: override?.typedSignatureEnabled ?? template.documentMeta?.typedSignatureEnabled,
allowDictateNextSigner: override?.allowDictateNextSigner ?? template.documentMeta?.allowDictateNextSigner, uploadSignatureEnabled: override?.uploadSignatureEnabled ?? template.documentMeta?.uploadSignatureEnabled,
envelopeExpirationPeriod: override?.envelopeExpirationPeriod ?? template.documentMeta?.envelopeExpirationPeriod, drawSignatureEnabled: override?.drawSignatureEnabled ?? template.documentMeta?.drawSignatureEnabled,
}), allowDictateNextSigner: override?.allowDictateNextSigner ?? template.documentMeta?.allowDictateNextSigner,
envelopeExpirationPeriod: override?.envelopeExpirationPeriod ?? template.documentMeta?.envelopeExpirationPeriod,
},
signatureLevel,
),
}); });
const { envelope, createdEnvelope } = await prisma.$transaction(async (tx) => { const { envelope, createdEnvelope } = await prisma.$transaction(async (tx) => {
@@ -539,6 +554,7 @@ export const createDocumentFromTemplate = async ({
secondaryId: incrementedDocumentId.formattedDocumentId, secondaryId: incrementedDocumentId.formattedDocumentId,
type: EnvelopeType.DOCUMENT, type: EnvelopeType.DOCUMENT,
internalVersion: template.internalVersion, internalVersion: template.internalVersion,
signatureLevel,
qrToken: prefixedId('qr'), qrToken: prefixedId('qr'),
source: DocumentSource.TEMPLATE, source: DocumentSource.TEMPLATE,
externalId: externalId || template.externalId, externalId: externalId || template.externalId,
+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_REQUESTED', // When ACCESS AUTH 2FA is requested.
'DOCUMENT_ACCESS_AUTH_2FA_VALIDATED', // When ACCESS AUTH 2FA is successfully validated. 'DOCUMENT_ACCESS_AUTH_2FA_VALIDATED', // When ACCESS AUTH 2FA is successfully validated.
'DOCUMENT_ACCESS_AUTH_2FA_FAILED', // When ACCESS AUTH 2FA validation fails. '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([ 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({ export const ZDocumentAuditLogBaseSchema = z.object({
id: z.string(), id: z.string(),
createdAt: z.date(), createdAt: z.date(),
@@ -770,6 +842,11 @@ export const ZDocumentAuditLogSchema = ZDocumentAuditLogBaseSchema.and(
ZDocumentAuditLogEventRecipientUpdatedSchema, ZDocumentAuditLogEventRecipientUpdatedSchema,
ZDocumentAuditLogEventRecipientRemovedSchema, ZDocumentAuditLogEventRecipientRemovedSchema,
ZDocumentAuditLogEventRecipientExpiredSchema, ZDocumentAuditLogEventRecipientExpiredSchema,
ZDocumentAuditLogEventDocumentRecipientCscAuthenticatedSchema,
ZDocumentAuditLogEventDocumentRecipientCscAuthenticationFailedSchema,
ZDocumentAuditLogEventDocumentRecipientCscSignRequestedSchema,
ZDocumentAuditLogEventDocumentRecipientCscAuthorizedSchema,
ZDocumentAuditLogEventDocumentRecipientCscSignedSchema,
]), ]),
); );
+9
View File
@@ -11,6 +11,8 @@ export const ZLicenseClaimSchema = z.object({
hipaa: z.boolean().optional(), hipaa: z.boolean().optional(),
authenticationPortal: z.boolean().optional(), authenticationPortal: z.boolean().optional(),
billing: 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 TLicenseRequest = z.infer<typeof ZLicenseRequestSchema>;
export type TLicenseResponse = z.infer<typeof ZLicenseResponseSchema>; 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. * 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(), signingReminders: z.boolean().optional(),
cscQesSigning: z.boolean().optional(),
/** /**
* Controls whether an organisation is prevented from sending emails. * Controls whether an organisation is prevented from sending emails.
* *
@@ -128,6 +130,11 @@ export const SUBSCRIPTION_CLAIM_FEATURE_FLAGS: Record<
key: 'signingReminders', key: 'signingReminders',
label: 'Signing reminders', label: 'Signing reminders',
}, },
cscQesSigning: {
key: 'cscQesSigning',
label: 'QES signing',
isEnterprise: true,
},
disableEmails: { disableEmails: {
key: 'disableEmails', key: 'disableEmails',
label: 'Disable emails', label: 'Disable emails',
+25
View File
@@ -597,6 +597,31 @@ export const formatDocumentAuditLogAction = (i18n: I18n, auditLog: TDocumentAudi
user: message, user: message,
}; };
}) })
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_CSC_AUTHENTICATED }, () => ({
anonymous: msg`Recipient authenticated with the signing provider`,
you: msg`You authenticated with the signing provider`,
user: msg`${user} authenticated with the signing provider`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_CSC_AUTHENTICATION_FAILED }, () => ({
anonymous: msg`Recipient's signing provider authentication failed`,
you: msg`Your signing provider authentication failed`,
user: msg`${user}'s signing provider authentication failed`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_CSC_SIGN_REQUESTED }, () => ({
anonymous: msg`Recipient requested a remote signature`,
you: msg`You requested a remote signature`,
user: msg`${user} requested a remote signature`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_CSC_AUTHORIZED }, () => ({
anonymous: msg`Recipient authorised the remote signature`,
you: msg`You authorised the remote signature`,
user: msg`${user} authorised the remote signature`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_CSC_SIGNED }, () => ({
anonymous: msg`Recipient's remote signature was applied`,
you: msg`Your remote signature was applied`,
user: msg`${user}'s remote signature was applied`,
}))
.exhaustive(); .exhaustive();
let selectedDescription = description.anonymous; let selectedDescription = description.anonymous;
+10 -2
View File
@@ -1,9 +1,11 @@
import type { DocumentMeta, Envelope, OrganisationGlobalSettings, Recipient, Team, User } from '@prisma/client'; import type { DocumentMeta, Envelope, OrganisationGlobalSettings, Recipient, Team, User } from '@prisma/client';
import { DocumentDistributionMethod, DocumentSigningOrder, DocumentStatus } from '@prisma/client'; import { DocumentDistributionMethod, DocumentStatus } from '@prisma/client';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '../constants/time-zones'; import { DEFAULT_DOCUMENT_TIME_ZONE } from '../constants/time-zones';
import { resolveSigningOrder } from '../server-only/signature-level/resolve-signing-order';
import type { TDocumentLite, TDocumentMany } from '../types/document'; import type { TDocumentLite, TDocumentMany } from '../types/document';
import { DEFAULT_DOCUMENT_EMAIL_SETTINGS } from '../types/document-email'; import { DEFAULT_DOCUMENT_EMAIL_SETTINGS } from '../types/document-email';
import { SignatureLevel } from '../types/signature-level';
import { mapSecondaryIdToDocumentId } from './envelope'; import { mapSecondaryIdToDocumentId } from './envelope';
import { mapRecipientToLegacyRecipient } from './recipients'; import { mapRecipientToLegacyRecipient } from './recipients';
@@ -23,11 +25,17 @@ export const isDocumentCompleted = (document: Pick<Envelope, 'status'> | Documen
* *
* @param settings - The merged organisation/team settings. * @param settings - The merged organisation/team settings.
* @param overrideMeta - The meta to override the settings with. * @param overrideMeta - The meta to override the settings with.
* @param signatureLevel - The envelope's signature level. Optional; defaults
* to `SES` for backward compatibility, which preserves the legacy `PARALLEL`
* signing-order default. New callers should pass the resolved level so the
* TSP envelopes get the `SEQUENTIAL` default + assertion against explicit
* `PARALLEL`.
* @returns The derived document meta. * @returns The derived document meta.
*/ */
export const extractDerivedDocumentMeta = ( export const extractDerivedDocumentMeta = (
settings: Omit<OrganisationGlobalSettings, 'id'>, settings: Omit<OrganisationGlobalSettings, 'id'>,
overrideMeta: Partial<DocumentMeta> | undefined | null, overrideMeta: Partial<DocumentMeta> | undefined | null,
signatureLevel: string = SignatureLevel.SES,
) => { ) => {
const meta = overrideMeta ?? {}; const meta = overrideMeta ?? {};
@@ -41,7 +49,7 @@ export const extractDerivedDocumentMeta = (
subject: meta.subject || null, subject: meta.subject || null,
redirectUrl: meta.redirectUrl || null, redirectUrl: meta.redirectUrl || null,
signingOrder: meta.signingOrder || DocumentSigningOrder.PARALLEL, signingOrder: resolveSigningOrder({ signatureLevel, requested: meta.signingOrder }),
allowDictateNextSigner: meta.allowDictateNextSigner ?? false, allowDictateNextSigner: meta.allowDictateNextSigner ?? false,
distributionMethod: meta.distributionMethod || DocumentDistributionMethod.EMAIL, // Todo: Make this a setting. distributionMethod: meta.distributionMethod || DocumentDistributionMethod.EMAIL, // Todo: Make this a setting.
+29
View File
@@ -1,5 +1,7 @@
/// <reference types="@documenso/tsconfig/process-env.d.ts" /> /// <reference types="@documenso/tsconfig/process-env.d.ts" />
import { AppError, AppErrorCode } from '../errors/app-error';
declare global { declare global {
interface Window { interface Window {
__ENV__?: Record<string, string | undefined>; __ENV__?: Record<string, string | undefined>;
@@ -20,6 +22,30 @@ export const env = <K extends EnvKey>(variable: K): EnvValue<K> => {
return (typeof process !== 'undefined' ? process?.env?.[variable] : undefined) as EnvValue<K>; return (typeof process !== 'undefined' ? process?.env?.[variable] : undefined) as EnvValue<K>;
}; };
/**
* Read an env var and assert it is set and non-empty. Throws `error` when
* provided, otherwise an `AppError(MISSING_ENV_VAR)` naming the missing
* variable.
*
* Empty-string is treated as unset — a shell-supplied `FOO=` is functionally
* equivalent to omission.
*/
export const requireEnv = <K extends EnvKey>(variable: K, error?: Error): NonNullable<EnvValue<K>> => {
const value = env(variable);
if (!value) {
throw (
error ??
new AppError(AppErrorCode.MISSING_ENV_VAR, {
message: `Required environment variable "${String(variable)}" is unset.`,
})
);
}
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
return value as NonNullable<EnvValue<K>>;
};
export const createPublicEnv = () => ({ export const createPublicEnv = () => ({
...Object.fromEntries(Object.entries(process.env).filter(([key]) => key.startsWith('NEXT_PUBLIC_'))), ...Object.fromEntries(Object.entries(process.env).filter(([key]) => key.startsWith('NEXT_PUBLIC_'))),
// Derived from the private URL so the public flag cannot drift from the // Derived from the private URL so the public flag cannot drift from the
@@ -27,4 +53,7 @@ export const createPublicEnv = () => ({
// env var with the same name. // env var with the same name.
// The `? 'true' : 'false'` might seem dumb but it's because we're expecting env var strings. // The `? 'true' : 'false'` might seem dumb but it's because we're expecting env var strings.
NEXT_PUBLIC_DOCUMENT_CONVERSION_ENABLED: process.env.NEXT_PRIVATE_DOCUMENT_CONVERSION_URL ? 'true' : 'false', NEXT_PUBLIC_DOCUMENT_CONVERSION_ENABLED: process.env.NEXT_PRIVATE_DOCUMENT_CONVERSION_URL ? 'true' : 'false',
// Derived from the private transport so the client can detect CSC mode for
// authoring UI gating without exposing the raw transport value.
NEXT_PUBLIC_SIGNING_TRANSPORT_IS_CSC: process.env.NEXT_PRIVATE_SIGNING_TRANSPORT === 'csc' ? 'true' : 'false',
}); });
@@ -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 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 { model Envelope {
id String @id id String @id
secondaryId String @unique secondaryId String @unique
@@ -447,6 +447,8 @@ model Envelope {
source DocumentSource source DocumentSource
qrToken String? /// @zod.string.describe("The token for viewing the document using the QR code on the certificate.") 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 internalVersion Int
useLegacyFieldInsertion Boolean @default(false) useLegacyFieldInsertion Boolean @default(false)
@@ -650,6 +652,9 @@ model Recipient {
fields Field[] fields Field[]
signatures Signature[] signatures Signature[]
cscCredential CscCredential?
cscSession CscSession?
@@index([token]) @@index([token])
@@index([email]) @@index([email])
@@index([envelopeId]) @@index([envelopeId])
@@ -717,6 +722,72 @@ model Signature {
@@index([recipientId]) @@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 { model DocumentShareLink {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
email String email String
+5
View File
@@ -14,6 +14,7 @@ import {
FIELD_SIGNATURE_META_DEFAULT_VALUES, FIELD_SIGNATURE_META_DEFAULT_VALUES,
FIELD_TEXT_META_DEFAULT_VALUES, FIELD_TEXT_META_DEFAULT_VALUES,
} from '@documenso/lib/types/field-meta'; } from '@documenso/lib/types/field-meta';
import { SignatureLevel } from '@documenso/lib/types/signature-level';
import { prefixedId } from '@documenso/lib/universal/id'; import { prefixedId } from '@documenso/lib/universal/id';
import type { Team, User } from '@prisma/client'; import type { Team, User } from '@prisma/client';
import { nanoid } from 'nanoid'; import { nanoid } from 'nanoid';
@@ -93,6 +94,7 @@ export const seedBlankDocument = async (owner: User, teamId: number, options: Cr
id: prefixedId('envelope'), id: prefixedId('envelope'),
secondaryId: documentId.formattedDocumentId, secondaryId: documentId.formattedDocumentId,
internalVersion, internalVersion,
signatureLevel: SignatureLevel.SES,
type: EnvelopeType.DOCUMENT, type: EnvelopeType.DOCUMENT,
documentMetaId: documentMeta.id, documentMetaId: documentMeta.id,
source: DocumentSource.DOCUMENT, source: DocumentSource.DOCUMENT,
@@ -313,6 +315,7 @@ export const seedDraftDocument = async (
id: prefixedId('envelope'), id: prefixedId('envelope'),
secondaryId: documentId.formattedDocumentId, secondaryId: documentId.formattedDocumentId,
internalVersion, internalVersion,
signatureLevel: SignatureLevel.SES,
type: EnvelopeType.DOCUMENT, type: EnvelopeType.DOCUMENT,
documentMetaId: documentMeta.id, documentMetaId: documentMeta.id,
source: DocumentSource.DOCUMENT, source: DocumentSource.DOCUMENT,
@@ -407,6 +410,7 @@ export const seedPendingDocument = async (
id: prefixedId('envelope'), id: prefixedId('envelope'),
secondaryId: documentId.formattedDocumentId, secondaryId: documentId.formattedDocumentId,
internalVersion, internalVersion,
signatureLevel: SignatureLevel.SES,
type: EnvelopeType.DOCUMENT, type: EnvelopeType.DOCUMENT,
documentMetaId: documentMeta.id, documentMetaId: documentMeta.id,
source: DocumentSource.DOCUMENT, source: DocumentSource.DOCUMENT,
@@ -660,6 +664,7 @@ export const seedCompletedDocument = async (
id: prefixedId('envelope'), id: prefixedId('envelope'),
secondaryId: documentId.formattedDocumentId, secondaryId: documentId.formattedDocumentId,
internalVersion, internalVersion,
signatureLevel: SignatureLevel.SES,
type: EnvelopeType.DOCUMENT, type: EnvelopeType.DOCUMENT,
documentMetaId: documentMeta.id, documentMetaId: documentMeta.id,
source: DocumentSource.DOCUMENT, 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 { OVERFLOW_TEST_FIELDS } from '@documenso/app-tests/constants/field-overflow-pdf';
import { isBase64Image } from '@documenso/lib/constants/signatures'; import { isBase64Image } from '@documenso/lib/constants/signatures';
import { incrementDocumentId, incrementTemplateId } from '@documenso/lib/server-only/envelope/increment-id'; 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 { nanoid, prefixedId } from '@documenso/lib/universal/id';
import { DIRECT_TEMPLATE_RECIPIENT_EMAIL, DIRECT_TEMPLATE_RECIPIENT_NAME } from '../../lib/constants/direct-templates'; import { DIRECT_TEMPLATE_RECIPIENT_EMAIL, DIRECT_TEMPLATE_RECIPIENT_NAME } from '../../lib/constants/direct-templates';
import { prisma } from '..'; import { prisma } from '..';
@@ -76,6 +77,7 @@ export const seedDatabase = async () => {
id: prefixedId('envelope'), id: prefixedId('envelope'),
secondaryId: documentId.formattedDocumentId, secondaryId: documentId.formattedDocumentId,
internalVersion: 1, internalVersion: 1,
signatureLevel: SignatureLevel.SES,
type: EnvelopeType.DOCUMENT, type: EnvelopeType.DOCUMENT,
documentMetaId: documentMeta.id, documentMetaId: documentMeta.id,
source: DocumentSource.DOCUMENT, source: DocumentSource.DOCUMENT,
@@ -115,6 +117,7 @@ export const seedDatabase = async () => {
id: prefixedId('envelope'), id: prefixedId('envelope'),
secondaryId: documentId.formattedDocumentId, secondaryId: documentId.formattedDocumentId,
internalVersion: 1, internalVersion: 1,
signatureLevel: SignatureLevel.SES,
type: EnvelopeType.DOCUMENT, type: EnvelopeType.DOCUMENT,
source: DocumentSource.DOCUMENT, source: DocumentSource.DOCUMENT,
title: `Document ${i}`, title: `Document ${i}`,
@@ -312,6 +315,7 @@ export const seedAlignmentTestDocument = async ({
id: prefixedId('envelope'), id: prefixedId('envelope'),
secondaryId, secondaryId,
internalVersion: 2, internalVersion: 2,
signatureLevel: SignatureLevel.SES,
type, type,
documentMetaId: documentMeta.id, documentMetaId: documentMeta.id,
source: DocumentSource.DOCUMENT, source: DocumentSource.DOCUMENT,
@@ -474,6 +478,7 @@ export const seedOverflowTestDocument = async ({
id: prefixedId('envelope'), id: prefixedId('envelope'),
secondaryId, secondaryId,
internalVersion: 2, internalVersion: 2,
signatureLevel: SignatureLevel.SES,
type: EnvelopeType.DOCUMENT, type: EnvelopeType.DOCUMENT,
documentMetaId: documentMeta.id, documentMetaId: documentMeta.id,
source: DocumentSource.DOCUMENT, source: DocumentSource.DOCUMENT,
+4
View File
@@ -6,6 +6,7 @@ import {
DIRECT_TEMPLATE_RECIPIENT_NAME, DIRECT_TEMPLATE_RECIPIENT_NAME,
} from '@documenso/lib/constants/direct-templates'; } from '@documenso/lib/constants/direct-templates';
import { incrementTemplateId } from '@documenso/lib/server-only/envelope/increment-id'; 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 { prefixedId } from '@documenso/lib/universal/id';
import { prisma } from '..'; import { prisma } from '..';
@@ -57,6 +58,7 @@ export const seedBlankTemplate = async (owner: User, teamId: number, options: Cr
id: prefixedId('envelope'), id: prefixedId('envelope'),
secondaryId: templateId.formattedTemplateId, secondaryId: templateId.formattedTemplateId,
internalVersion: 1, internalVersion: 1,
signatureLevel: SignatureLevel.SES,
type: EnvelopeType.TEMPLATE, type: EnvelopeType.TEMPLATE,
title: `[TEST] Template ${key}`, title: `[TEST] Template ${key}`,
teamId, teamId,
@@ -105,6 +107,7 @@ export const seedTemplate = async (options: SeedTemplateOptions) => {
id: prefixedId('envelope'), id: prefixedId('envelope'),
secondaryId: templateId.formattedTemplateId, secondaryId: templateId.formattedTemplateId,
internalVersion: options.internalVersion ?? 1, internalVersion: options.internalVersion ?? 1,
signatureLevel: SignatureLevel.SES,
type: EnvelopeType.TEMPLATE, type: EnvelopeType.TEMPLATE,
title, title,
envelopeItems: { envelopeItems: {
@@ -164,6 +167,7 @@ export const seedDirectTemplate = async (options: SeedTemplateOptions) => {
id: prefixedId('envelope'), id: prefixedId('envelope'),
secondaryId: templateId.formattedTemplateId, secondaryId: templateId.formattedTemplateId,
internalVersion: options.internalVersion ?? 1, internalVersion: options.internalVersion ?? 1,
signatureLevel: SignatureLevel.SES,
type: EnvelopeType.TEMPLATE, type: EnvelopeType.TEMPLATE,
title, title,
envelopeItems: { envelopeItems: {
@@ -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 { createOrganisationEmailRoute } from './create-organisation-email';
import { createOrganisationEmailDomainRoute } from './create-organisation-email-domain'; import { createOrganisationEmailDomainRoute } from './create-organisation-email-domain';
import { createSubscriptionRoute } from './create-subscription'; import { createSubscriptionRoute } from './create-subscription';
import { cscSignEnvelopeRoute } from './csc-sign-envelope';
import { declineLinkOrganisationAccountRoute } from './decline-link-organisation-account'; import { declineLinkOrganisationAccountRoute } from './decline-link-organisation-account';
import { deleteOrganisationEmailRoute } from './delete-organisation-email'; import { deleteOrganisationEmailRoute } from './delete-organisation-email';
import { deleteOrganisationEmailDomainRoute } from './delete-organisation-email-domain'; import { deleteOrganisationEmailDomainRoute } from './delete-organisation-email-domain';
@@ -55,4 +56,7 @@ export const enterpriseRouter = router({
get: getInvoicesRoute, get: getInvoicesRoute,
}, },
}, },
csc: {
signEnvelope: cscSignEnvelopeRoute,
},
}); });
@@ -1,4 +1,5 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; 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 { 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 { UNSAFE_replaceEnvelopeItemPdf } from '@documenso/lib/server-only/envelope-item/replace-envelope-item-pdf';
import { getEnvelopeItemPermissions } from '@documenso/lib/utils/envelope'; import { getEnvelopeItemPermissions } from '@documenso/lib/utils/envelope';
@@ -59,6 +60,8 @@ export const replaceEnvelopeItemPdfRoute = authenticatedProcedure
}); });
} }
assertEnvelopeMutable(envelope);
if (envelope.internalVersion !== 2) { if (envelope.internalVersion !== 2) {
throw new AppError(AppErrorCode.INVALID_REQUEST, { throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'PDF replacement is only supported for version 2 envelopes', 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 { completeDocumentWithToken } from '@documenso/lib/server-only/document/complete-document-with-token';
import { rejectDocumentWithToken } from '@documenso/lib/server-only/document/reject-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'; 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 { setDocumentRecipients } from '@documenso/lib/server-only/recipient/set-document-recipients';
import { setTemplateRecipients } from '@documenso/lib/server-only/recipient/set-template-recipients'; import { setTemplateRecipients } from '@documenso/lib/server-only/recipient/set-template-recipients';
import { updateEnvelopeRecipients } from '@documenso/lib/server-only/recipient/update-envelope-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 { EnvelopeType } from '@prisma/client';
import { ZGenericSuccessResponse, ZSuccessResponseSchema } from '../schema'; import { ZGenericSuccessResponse, ZSuccessResponseSchema } from '../schema';
@@ -13,6 +17,7 @@ import { authenticatedProcedure, procedure, router } from '../trpc';
import { findRecipientSuggestionsRoute } from './find-recipient-suggestions'; import { findRecipientSuggestionsRoute } from './find-recipient-suggestions';
import { import {
ZCompleteDocumentWithTokenMutationSchema, ZCompleteDocumentWithTokenMutationSchema,
ZCompleteDocumentWithTokenResponseSchema,
ZCreateDocumentRecipientRequestSchema, ZCreateDocumentRecipientRequestSchema,
ZCreateDocumentRecipientResponseSchema, ZCreateDocumentRecipientResponseSchema,
ZCreateDocumentRecipientsRequestSchema, ZCreateDocumentRecipientsRequestSchema,
@@ -559,6 +564,7 @@ export const recipientRouter = router({
*/ */
completeDocumentWithToken: procedure completeDocumentWithToken: procedure
.input(ZCompleteDocumentWithTokenMutationSchema) .input(ZCompleteDocumentWithTokenMutationSchema)
.output(ZCompleteDocumentWithTokenResponseSchema)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
const { token, documentId, accessAuthOptions, nextSigner, recipientOverride } = input; 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({ await completeDocumentWithToken({
token, token,
id: { id: {
@@ -580,6 +605,8 @@ export const recipientRouter = router({
userId: ctx.user?.id, userId: ctx.user?.id,
requestMetadata: ctx.metadata.requestMetadata, requestMetadata: ctx.metadata.requestMetadata,
}); });
return { status: 'SIGNED' as const };
}), }),
/** /**

Some files were not shown because too many files have changed in this diff Show More