diff --git a/.env.example b/.env.example
index f723e1c66..aa6565f8c 100644
--- a/.env.example
+++ b/.env.example
@@ -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"
# [[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"
# OPTIONAL: The passphrase to use for the local file-based signing transport.
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=
# 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=
+# 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).
NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY=
# OPTIONAL: Contact info to embed in PDF signatures. Defaults to the webapp URL.
diff --git a/apps/docs/content/docs/self-hosting/configuration/environment.mdx b/apps/docs/content/docs/self-hosting/configuration/environment.mdx
index 1d57b5062..33f723c31 100644
--- a/apps/docs/content/docs/self-hosting/configuration/environment.mdx
+++ b/apps/docs/content/docs/self-hosting/configuration/environment.mdx
@@ -186,9 +186,9 @@ Documenso requires a certificate to digitally sign documents.
### Transport Selection
-| Variable | Description | Default |
-| -------------------------------- | ---------------------------------------- | ------- |
-| `NEXT_PRIVATE_SIGNING_TRANSPORT` | Signing backend: `local` or `gcloud-hsm` | `local` |
+| Variable | Description | Default |
+| -------------------------------- | ------------------------------------------------- | ------- |
+| `NEXT_PRIVATE_SIGNING_TRANSPORT` | Signing backend: `local`, `gcloud-hsm`, or `csc` | `local` |
### 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_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
| 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_PRIVATE_USE_LEGACY_SIGNING_SUBFILTER` | Use `adbe.pkcs7.detached` instead of `ETSI.CAdES.detached` | `false` |
diff --git a/apps/docs/content/docs/self-hosting/configuration/signing-certificate/csc-qes.mdx b/apps/docs/content/docs/self-hosting/configuration/signing-certificate/csc-qes.mdx
new file mode 100644
index 000000000..0495430d7
--- /dev/null
+++ b/apps/docs/content/docs/self-hosting/configuration/signing-certificate/csc-qes.mdx
@@ -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.
+
+
+ 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).
+
+
+
+ CSC mode requires an active [Enterprise Edition](/docs/policies/enterprise-edition) license. The
+ instance refuses to start in `csc` mode without it.
+
+
+## Prerequisites
+
+{/* prettier-ignore */}
+
+
+
+### 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.
+
+
+
+
+### 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.
+
+
+
+
+### Enterprise Edition license
+
+CSC mode is gated by the `instanceCscSigning` license flag. Without a valid Enterprise license, the transport refuses to start (`CSC_UNLICENSED`).
+
+
+
+
+### 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.
+
+
+
+
+## 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). | |
+
+
+ `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).
+
+
+## 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
diff --git a/apps/docs/content/docs/self-hosting/configuration/signing-certificate/index.mdx b/apps/docs/content/docs/self-hosting/configuration/signing-certificate/index.mdx
index 89a12a327..a2d5a0a7c 100644
--- a/apps/docs/content/docs/self-hosting/configuration/signing-certificate/index.mdx
+++ b/apps/docs/content/docs/self-hosting/configuration/signing-certificate/index.mdx
@@ -24,6 +24,11 @@ Self-hosted Documenso instances require a signing certificate. You can generate
description="Hardware-based key protection with Google Cloud KMS."
href="/docs/self-hosting/configuration/signing-certificate/google-cloud-hsm"
/>
+
+
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.
+
+
+
+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.
+
diff --git a/apps/docs/content/docs/self-hosting/configuration/signing-certificate/meta.json b/apps/docs/content/docs/self-hosting/configuration/signing-certificate/meta.json
index d37038af3..b52f83f9e 100644
--- a/apps/docs/content/docs/self-hosting/configuration/signing-certificate/meta.json
+++ b/apps/docs/content/docs/self-hosting/configuration/signing-certificate/meta.json
@@ -1,4 +1,4 @@
{
"title": "Signing Certificate",
- "pages": ["...index", "local", "google-cloud-hsm", "timestamp-server", "troubleshooting"]
+ "pages": ["...index", "local", "google-cloud-hsm", "csc-qes", "timestamp-server", "troubleshooting"]
}
diff --git a/apps/remix/app/components/general/direct-template/direct-template-page.tsx b/apps/remix/app/components/general/direct-template/direct-template-page.tsx
index 24753f7e0..9f6ea47dc 100644
--- a/apps/remix/app/components/general/direct-template/direct-template-page.tsx
+++ b/apps/remix/app/components/general/direct-template/direct-template-page.tsx
@@ -13,7 +13,7 @@ import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import type { Field, Recipient } from '@prisma/client';
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 { useRequiredDocumentSigningContext } from '~/components/general/document-signing/document-signing-provider';
@@ -37,7 +37,6 @@ export const DirectTemplatePageView = ({
directTemplateRecipient,
directTemplateToken,
}: DirectTemplatePageViewProps) => {
- const navigate = useNavigate();
const [searchParams] = useSearchParams();
const { _ } = useLingui();
@@ -119,7 +118,7 @@ export const DirectTemplatePageView = ({
if (redirectUrl) {
window.location.href = redirectUrl;
} else {
- await navigate(`/sign/${token}/complete`);
+ window.location.href = `/sign/${token}/complete`;
}
} catch (err) {
const error = AppError.parseError(err);
diff --git a/apps/remix/app/components/general/document-signing/csc-recipient-blocked-page.tsx b/apps/remix/app/components/general/document-signing/csc-recipient-blocked-page.tsx
new file mode 100644
index 000000000..0e2bb6fb2
--- /dev/null
+++ b/apps/remix/app/components/general/document-signing/csc-recipient-blocked-page.tsx
@@ -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 (
+
+
+
+
+ {code === AppErrorCode.CSC_CREDENTIAL_LIST_EMPTY ? (
+ No signing credentials available
+ ) : code === AppErrorCode.CSC_CERT_INVALID ? (
+ Signing certificate is invalid
+ ) : code === AppErrorCode.CSC_ALGORITHM_REFUSED ? (
+ Signing algorithm is not supported
+ ) : (
+ Unable to start the signing flow
+ )}
+
+
+
+ {code === AppErrorCode.CSC_CREDENTIAL_LIST_EMPTY ? (
+
+ Your signing provider returned no usable credentials for this account. Contact your administrator or signing
+ provider for assistance.
+
+ ) : code === AppErrorCode.CSC_CERT_INVALID ? (
+
+ Your signing certificate is invalid, expired, or missing a required key. Contact your administrator or
+ signing provider for assistance.
+
+ ) : code === AppErrorCode.CSC_ALGORITHM_REFUSED ? (
+
+ Your signing provider does not advertise a signing algorithm this document accepts. Contact your
+ administrator or signing provider for assistance.
+
+ ) : (
+ Something went wrong while preparing the remote signature. Please try again.
+ )}
+
+
+
+
+ );
+};
diff --git a/apps/remix/app/components/general/document-signing/csc-recipient-signing-in-progress-page.tsx b/apps/remix/app/components/general/document-signing/csc-recipient-signing-in-progress-page.tsx
new file mode 100644
index 000000000..1aa091b35
--- /dev/null
+++ b/apps/remix/app/components/general/document-signing/csc-recipient-signing-in-progress-page.tsx
@@ -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(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 (
+
+ {error ? (
+ <>
+
+
+
+ Signing failed
+
+
+
+ {error === AppErrorCode.CSC_TSP_TIMEOUT ? (
+ The signing provider did not respond in time. Please retry.
+ ) : error === AppErrorCode.CSC_SAD_EXPIRED_PRE_SIGN ? (
+
+ Your signing authorisation expired before the signature could be applied. Please reauthorise to retry.
+
+ ) : (
+ Something went wrong while applying your signature. Please retry.
+ )}
+
+
+
+ >
+ ) : (
+ <>
+
+
+
+ Applying your signature
+
+
+
+ Please don't close this tab. The signing provider is finalising your signature.
+
+ >
+ )}
+
+ );
+};
diff --git a/apps/remix/app/components/general/document-signing/document-signing-page-view-v1.tsx b/apps/remix/app/components/general/document-signing/document-signing-page-view-v1.tsx
index 3eea3753e..1979c63a2 100644
--- a/apps/remix/app/components/general/document-signing/document-signing-page-view-v1.tsx
+++ b/apps/remix/app/components/general/document-signing/document-signing-page-view-v1.tsx
@@ -27,7 +27,6 @@ import type { Field } from '@prisma/client';
import { FieldType, RecipientRole } from '@prisma/client';
import { LucideChevronDown, LucideChevronUp } from 'lucide-react';
import { useMemo, useState } from 'react';
-import { useNavigate } from 'react-router';
import { match, P } from 'ts-pattern';
import { DocumentSigningAttachmentsPopover } from '~/components/general/document-signing/document-signing-attachments-popover';
@@ -84,7 +83,6 @@ export const DocumentSigningPageViewV1 = ({
? authUser.twoFactorEnabled && authUser.email === recipient.email
: false;
- const navigate = useNavigate();
const analytics = useAnalytics();
const [selectedSignerId, setSelectedSignerId] = useState(allRecipients?.[0]?.id);
@@ -129,7 +127,7 @@ export const DocumentSigningPageViewV1 = ({
if (documentMeta?.redirectUrl) {
window.location.href = documentMeta.redirectUrl;
} else {
- await navigate(`/sign/${recipient.token}/complete`);
+ window.location.href = `/sign/${recipient.token}/complete`;
}
};
diff --git a/apps/remix/app/components/general/document-signing/document-signing-reject-dialog.tsx b/apps/remix/app/components/general/document-signing/document-signing-reject-dialog.tsx
index 102238613..ba70ad048 100644
--- a/apps/remix/app/components/general/document-signing/document-signing-reject-dialog.tsx
+++ b/apps/remix/app/components/general/document-signing/document-signing-reject-dialog.tsx
@@ -17,7 +17,7 @@ import { msg } from '@lingui/core/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
-import { useNavigate, useSearchParams } from 'react-router';
+import { useSearchParams } from 'react-router';
import { z } from 'zod';
const ZRejectDocumentFormSchema = z.object({
@@ -41,7 +41,6 @@ export function DocumentSigningRejectDialog({
}: DocumentSigningRejectDialogProps) {
const { t } = useLingui();
const { toast } = useToast();
- const navigate = useNavigate();
const [searchParams] = useSearchParams();
const [isOpen, setIsOpen] = useState(false);
@@ -74,7 +73,7 @@ export function DocumentSigningRejectDialog({
if (onRejected) {
await onRejected(reason);
} else {
- await navigate(`/sign/${token}/rejected`);
+ window.location.href = `/sign/${token}/rejected`;
}
} catch (err) {
toast({
diff --git a/apps/remix/app/components/general/document/document-page-view-button.tsx b/apps/remix/app/components/general/document/document-page-view-button.tsx
index 9eb96e768..730ae630b 100644
--- a/apps/remix/app/components/general/document/document-page-view-button.tsx
+++ b/apps/remix/app/components/general/document/document-page-view-button.tsx
@@ -38,7 +38,7 @@ export const DocumentPageViewButton = ({ envelope }: DocumentPageViewButtonProps
})
.with({ isRecipient: true, isPending: true, isSigned: false }, () => (
))
.with({ isComplete: false }, () => (
diff --git a/apps/remix/app/components/general/envelope-signing/envelope-signing-complete-dialog.tsx b/apps/remix/app/components/general/envelope-signing/envelope-signing-complete-dialog.tsx
index c62db2f11..2035e65b8 100644
--- a/apps/remix/app/components/general/envelope-signing/envelope-signing-complete-dialog.tsx
+++ b/apps/remix/app/components/general/envelope-signing/envelope-signing-complete-dialog.tsx
@@ -89,7 +89,7 @@ export const EnvelopeSignerCompleteDialog = () => {
recipientDetails?: { name: string; email: string },
) => {
try {
- await completeDocument({
+ const result = await completeDocument({
token: recipient.token,
documentId: mapSecondaryIdToDocumentId(envelope.secondaryId),
accessAuthOptions,
@@ -97,6 +97,15 @@ export const EnvelopeSignerCompleteDialog = () => {
...(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', {
signerId: recipient.id,
documentId: envelope.id,
@@ -119,7 +128,7 @@ export const EnvelopeSignerCompleteDialog = () => {
if (envelope.documentMeta.redirectUrl) {
window.location.href = envelope.documentMeta.redirectUrl;
} else {
- await navigate(`/sign/${recipient.token}/complete`);
+ window.location.href = `/sign/${recipient.token}/complete`;
}
} catch (err) {
const error = AppError.parseError(err);
@@ -197,7 +206,7 @@ export const EnvelopeSignerCompleteDialog = () => {
if (redirectUrl) {
window.location.href = redirectUrl;
} else {
- await navigate(`/sign/${token}/complete`);
+ window.location.href = `/sign/${token}/complete`;
}
} catch (err) {
console.log('err', err);
diff --git a/apps/remix/app/components/tables/documents-table-action-button.tsx b/apps/remix/app/components/tables/documents-table-action-button.tsx
index a6e9a9edd..000b46780 100644
--- a/apps/remix/app/components/tables/documents-table-action-button.tsx
+++ b/apps/remix/app/components/tables/documents-table-action-button.tsx
@@ -66,7 +66,7 @@ export const DocumentsTableActionButton = ({ row }: DocumentsTableActionButtonPr
))
.with({ isRecipient: true, isPending: true, isSigned: false }, () => (
))
.with({ isPending: true, isSigned: true }, () => (
diff --git a/apps/remix/app/components/tables/documents-table-action-dropdown.tsx b/apps/remix/app/components/tables/documents-table-action-dropdown.tsx
index 1555d54a2..1d09e2900 100644
--- a/apps/remix/app/components/tables/documents-table-action-dropdown.tsx
+++ b/apps/remix/app/components/tables/documents-table-action-dropdown.tsx
@@ -105,7 +105,7 @@ export const DocumentsTableActionDropdown = ({ row, onMoveDocument }: DocumentsT
recipient?.role !== RecipientRole.CC &&
recipient?.role !== RecipientRole.ASSISTANT && (
-
+
{recipient?.role === RecipientRole.VIEWER && (
<>
@@ -126,7 +126,7 @@ export const DocumentsTableActionDropdown = ({ row, onMoveDocument }: DocumentsT
Approve
>
)}
-
+
)}
diff --git a/apps/remix/app/components/tables/inbox-table.tsx b/apps/remix/app/components/tables/inbox-table.tsx
index d0d6a9e21..f958da66d 100644
--- a/apps/remix/app/components/tables/inbox-table.tsx
+++ b/apps/remix/app/components/tables/inbox-table.tsx
@@ -17,7 +17,7 @@ import { DocumentStatus as DocumentStatusEnum, RecipientRole, SigningStatus } fr
import { CheckCircleIcon, DownloadIcon, EyeIcon, Loader, PencilIcon } from 'lucide-react';
import { DateTime } from 'luxon';
import { useMemo, useTransition } from 'react';
-import { Link, useSearchParams } from 'react-router';
+import { useSearchParams } from 'react-router';
import { match } from 'ts-pattern';
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: true }, () => (
diff --git a/apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx b/apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx
index e28947361..d35313fd8 100644
--- a/apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx
+++ b/apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx
@@ -1,7 +1,14 @@
import signingCelebration from '@documenso/assets/images/signing-celebration.png';
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 { 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 { loadRecipientBrandingByTeamId } from '@documenso/lib/server-only/branding/load-recipient-branding';
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 { getUserByEmail } from '@documenso/lib/server-only/user/get-user-by-email';
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 { isRecipientExpired } from '@documenso/lib/utils/recipients';
import { prisma } from '@documenso/prisma';
@@ -30,6 +38,8 @@ import { getOptionalLoaderContext } from 'server/utils/get-loader-session';
import { match } from 'ts-pattern';
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 { DocumentSigningAuthProvider } from '~/components/general/document-signing/document-signing-auth-provider';
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,
}).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 {
isDocumentAccessValid: true,
envelopeForSigning,
@@ -296,11 +358,22 @@ export async function loader(loaderArgs: Route.LoaderArgs) {
if (foundRecipient.envelope.internalVersion === 2) {
const payloadV2 = await handleV2Loader(loaderArgs);
- return superLoaderJson({
- version: 2,
- payload: payloadV2,
- branding,
- } as const);
+ // V2 payload may carry a one-shot `Set-Cookie` header (used to clear the
+ // CSC blocking-error cookie after the loader reads it). Forward it via
+ // the `superLoaderJson` response init so the browser actually applies the
+ // header. The field stays on the payload — it's just a `Max-Age=0` clear
+ // 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);
@@ -430,6 +503,19 @@ const SigningPageV2 = ({ data }: { data: Awaited;
}
+ if ('csc' in data && data.csc?.state === 'blocked') {
+ return ;
+ }
+
+ if ('csc' in data && data.csc?.state === 'signing-in-progress') {
+ return (
+
+ );
+ }
+
const { envelope, recipientSignature, recipient } = data.envelopeForSigning;
if (envelope.deletedAt || envelope.status === DocumentStatus.REJECTED) {
diff --git a/apps/remix/server/router.ts b/apps/remix/server/router.ts
index 72e847ae0..19747d4b2 100644
--- a/apps/remix/server/router.ts
+++ b/apps/remix/server/router.ts
@@ -1,5 +1,6 @@
import { tsRestHonoApp } from '@documenso/api/hono';
import { auth } from '@documenso/auth/server';
+import { csc } from '@documenso/ee/server-only/signing/csc/hono';
import { jobsClient } from '@documenso/lib/jobs/client';
import { LicenseClient } from '@documenso/lib/server-only/license/license-client';
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.route('/api/ai', aiRoute);
+// CSC OAuth routes (mounted from @documenso/ee).
+app.route('/api/csc', csc);
+
// API servers.
app.route('/api/v1', tsRestHonoApp);
app.use('/api/jobs/*', jobsClient.getApiHandler());
diff --git a/package-lock.json b/package-lock.json
index ca4111950..2b85538ce 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -15,7 +15,7 @@
"dependencies": {
"@ai-sdk/google-vertex": "3.0.81",
"@documenso/prisma": "*",
- "@libpdf/core": "^0.3.6",
+ "@libpdf/core": "^0.4.0",
"@lingui/conf": "^5.6.0",
"@lingui/core": "^5.6.0",
"@marsidev/react-turnstile": "^1.5.0",
@@ -4661,16 +4661,16 @@
"license": "MIT"
},
"node_modules/@libpdf/core": {
- "version": "0.3.6",
- "resolved": "https://registry.npmjs.org/@libpdf/core/-/core-0.3.6.tgz",
- "integrity": "sha512-VzRUXaDq+M9qrroKiipCgePK2mwKM3M6DY7B0yfXnxD4aYnUxD/nUtkcsHCBUUnJpkX9rWikdEhYa5vU8ZlReg==",
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/@libpdf/core/-/core-0.4.0.tgz",
+ "integrity": "sha512-G9nZRjf9DGDJaS/C23YWogk8akPM7O/6HfMslxVsKTKRbbbb+0szpQIetcGGUGRu7KtmBDmGDWCgz//DXSmq8A==",
"license": "MIT",
"dependencies": {
"@noble/ciphers": "^2.2.0",
"@noble/hashes": "^2.2.0",
"@scure/base": "^2.2.0",
"asn1js": "^3.0.10",
- "lru-cache": "^11.4.0",
+ "lru-cache": "^11.5.1",
"pako": "^2.1.0",
"pkijs": "^3.4.0"
},
@@ -4724,9 +4724,9 @@
}
},
"node_modules/@libpdf/core/node_modules/lru-cache": {
- "version": "11.4.0",
- "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.4.0.tgz",
- "integrity": "sha512-W+R+kFL4HgVxONq2bhXPi3bGpzGe/yEhVOp233qw9wCRtgncJ15P3bC+e4zZMu4Cq7d+WAJjXGW0uUkifhcatA==",
+ "version": "11.5.1",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz",
+ "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==",
"license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
@@ -30593,6 +30593,8 @@
"@aws-sdk/client-sesv2": "^3.998.0",
"@documenso/lib": "*",
"@documenso/prisma": "*",
+ "arctic": "^3.7.0",
+ "hono": "^4.12.14",
"luxon": "^3.7.2",
"react": "^18",
"ts-pattern": "^5.9.0",
diff --git a/package.json b/package.json
index d2a0c4b1b..e2c01cdd6 100644
--- a/package.json
+++ b/package.json
@@ -88,7 +88,7 @@
"dependencies": {
"@ai-sdk/google-vertex": "3.0.81",
"@documenso/prisma": "*",
- "@libpdf/core": "^0.3.6",
+ "@libpdf/core": "^0.4.0",
"@lingui/conf": "^5.6.0",
"@lingui/core": "^5.6.0",
"@prisma/extension-read-replicas": "^0.4.1",
diff --git a/packages/ee/package.json b/packages/ee/package.json
index 52a51512f..79cc3ba85 100644
--- a/packages/ee/package.json
+++ b/packages/ee/package.json
@@ -16,6 +16,8 @@
"@aws-sdk/client-sesv2": "^3.998.0",
"@documenso/lib": "*",
"@documenso/prisma": "*",
+ "arctic": "^3.7.0",
+ "hono": "^4.12.14",
"luxon": "^3.7.2",
"react": "^18",
"ts-pattern": "^5.9.0",
diff --git a/packages/ee/server-only/signing/csc/algorithm-resolver.ts b/packages/ee/server-only/signing/csc/algorithm-resolver.ts
new file mode 100644
index 000000000..184e3edde
--- /dev/null
+++ b/packages/ee/server-only/signing/csc/algorithm-resolver.ts
@@ -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 = {
+ '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 = {
+ 'SHA-256': 256,
+ 'SHA-384': 384,
+ 'SHA-512': 512,
+};
+
+const STRONG_DIGEST_SET = new Set(['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 = {
+ // 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 = {
+ '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(', ') || ''}).`,
+ });
+ }
+
+ 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,
+ };
+};
diff --git a/packages/ee/server-only/signing/csc/cert-chain.ts b/packages/ee/server-only/signing/csc/cert-chain.ts
new file mode 100644
index 000000000..1967fdf1f
--- /dev/null
+++ b/packages/ee/server-only/signing/csc/cert-chain.ts
@@ -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;
+};
diff --git a/packages/ee/server-only/signing/csc/ciphers.ts b/packages/ee/server-only/signing/csc/ciphers.ts
new file mode 100644
index 000000000..7bb58f26e
--- /dev/null
+++ b/packages/ee/server-only/signing/csc/ciphers.ts
@@ -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;
+ }
+};
diff --git a/packages/ee/server-only/signing/csc/client/credentials.ts b/packages/ee/server-only/signing/csc/client/credentials.ts
new file mode 100644
index 000000000..ae4c5b7d0
--- /dev/null
+++ b/packages/ee/server-only/signing/csc/client/credentials.ts
@@ -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 => {
+ const { baseUrl, accessToken, signal, userID, maxResults, pageToken, clientData } = opts;
+
+ const body: Record = {};
+
+ 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 => {
+ const { baseUrl, accessToken, signal, credentialID, certificates, certInfo, authInfo, lang, clientData } = opts;
+
+ const body: Record = { 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,
+ );
+};
diff --git a/packages/ee/server-only/signing/csc/client/http.ts b/packages/ee/server-only/signing/csc/client/http.ts
new file mode 100644
index 000000000..631aa0471
--- /dev/null
+++ b/packages/ee/server-only/signing/csc/client/http.ts
@@ -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;
+ /** 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 (opts: CscJsonPostOptions, responseSchema: z.ZodSchema): Promise => {
+ 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;
+};
diff --git a/packages/ee/server-only/signing/csc/client/index.ts b/packages/ee/server-only/signing/csc/client/index.ts
new file mode 100644
index 000000000..6e84c1a82
--- /dev/null
+++ b/packages/ee/server-only/signing/csc/client/index.ts
@@ -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';
diff --git a/packages/ee/server-only/signing/csc/client/info.ts b/packages/ee/server-only/signing/csc/client/info.ts
new file mode 100644
index 000000000..39195ed6b
--- /dev/null
+++ b/packages/ee/server-only/signing/csc/client/info.ts
@@ -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 => {
+ const { baseUrl, lang, signal } = opts;
+
+ return await cscJsonPost(
+ {
+ url: joinCscUrl({ baseUrl, path: 'info' }),
+ body: lang ? { lang } : {},
+ errorCode: AppErrorCode.CSC_PROVIDER_INFO_FAILED,
+ signal,
+ },
+ ZCscInfoResponseSchema,
+ );
+};
diff --git a/packages/ee/server-only/signing/csc/client/oauth.ts b/packages/ee/server-only/signing/csc/client/oauth.ts
new file mode 100644
index 000000000..7935ddc68
--- /dev/null
+++ b/packages/ee/server-only/signing/csc/client/oauth.ts
@@ -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 => {
+ 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 => {
+ 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 => {
+ 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)}`,
+ });
+};
diff --git a/packages/ee/server-only/signing/csc/client/signatures.ts b/packages/ee/server-only/signing/csc/client/signatures.ts
new file mode 100644
index 000000000..5253bc622
--- /dev/null
+++ b/packages/ee/server-only/signing/csc/client/signatures.ts
@@ -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 => {
+ const { baseUrl, accessToken, signal, credentialID, SAD, hash, hashAlgo, signAlgo, signAlgoParams, clientData } =
+ opts;
+
+ const body: Record = {
+ 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 => {
+ const { baseUrl, accessToken, signal, hash, hashAlgo, nonce, clientData } = opts;
+
+ const body: Record = { 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,
+ );
+};
diff --git a/packages/ee/server-only/signing/csc/client/types.ts b/packages/ee/server-only/signing/csc/client/types.ts
new file mode 100644
index 000000000..2106ba75c
--- /dev/null
+++ b/packages/ee/server-only/signing/csc/client/types.ts
@@ -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;
+
+// ─── §11.1 info ──────────────────────────────────────────────────────────────
+
+export const ZCscInfoRequestSchema = z.object({
+ lang: z.string().optional(),
+});
+
+export type TCscInfoRequest = z.infer;
+
+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;
+
+// ─── §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;
+
+export const ZCscCredentialsListResponseSchema = z.object({
+ credentialIDs: z.array(z.string()),
+ nextPageToken: z.string().optional(),
+});
+
+export type TCscCredentialsListResponse = z.infer;
+
+// ─── §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;
+
+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;
+
+// ─── §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;
+
+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;
+
+// ─── §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;
+
+export const ZCscTimestampResponseSchema = z.object({
+ // Base64-encoded RFC 3161 (with RFC 5816 update) time-stamp token.
+ timestamp: z.string(),
+});
+
+export type TCscTimestampResponse = z.infer;
+
+// 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'`).
diff --git a/packages/ee/server-only/signing/csc/cookies/blocking-error-cookie.ts b/packages/ee/server-only/signing/csc/cookies/blocking-error-cookie.ts
new file mode 100644
index 000000000..7b44382d1
--- /dev/null
+++ b/packages/ee/server-only/signing/csc/cookies/blocking-error-cookie.ts
@@ -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;
+
+type SetCscBlockingErrorCookieOptions = {
+ c: Context;
+ payload: TCscBlockingErrorPayload;
+};
+
+export const setCscBlockingErrorCookie = async (options: SetCscBlockingErrorCookieOptions): Promise => {
+ 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 => {
+ 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 => {
+ 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,
+ });
+};
diff --git a/packages/ee/server-only/signing/csc/cookies/oauth-flow-cookie.ts b/packages/ee/server-only/signing/csc/cookies/oauth-flow-cookie.ts
new file mode 100644
index 000000000..37eea9323
--- /dev/null
+++ b/packages/ee/server-only/signing/csc/cookies/oauth-flow-cookie.ts
@@ -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;
+
+type SetCscOAuthFlowCookieOptions = {
+ c: Context;
+ payload: TCscOAuthFlowPayload;
+};
+
+export const setCscOAuthFlowCookie = async (options: SetCscOAuthFlowCookieOptions): Promise => {
+ 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 => {
+ 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);
+};
diff --git a/packages/ee/server-only/signing/csc/cookies/sad-session-cookie.ts b/packages/ee/server-only/signing/csc/cookies/sad-session-cookie.ts
new file mode 100644
index 000000000..6c4df0d67
--- /dev/null
+++ b/packages/ee/server-only/signing/csc/cookies/sad-session-cookie.ts
@@ -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 => {
+ 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 => {
+ 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 => {
+ 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;
+};
diff --git a/packages/ee/server-only/signing/csc/cookies/service-session-cookie.ts b/packages/ee/server-only/signing/csc/cookies/service-session-cookie.ts
new file mode 100644
index 000000000..5a8267d78
--- /dev/null
+++ b/packages/ee/server-only/signing/csc/cookies/service-session-cookie.ts
@@ -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 ``. 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 => {
+ 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 => {
+ 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 => {
+ 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;
+};
diff --git a/packages/ee/server-only/signing/csc/cookies/shared.ts b/packages/ee/server-only/signing/csc/cookies/shared.ts
new file mode 100644
index 000000000..3280c0c2a
--- /dev/null
+++ b/packages/ee/server-only/signing/csc/cookies/shared.ts
@@ -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 `