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 `