From d5ce222482c2da81a1b0e6740e7d829752ea8f7f Mon Sep 17 00:00:00 2001 From: Lucas Smith Date: Tue, 16 Jun 2026 23:37:34 +1000 Subject: [PATCH] feat: add CSC AES/QES signing (v1 instance-wide config) (#2874) Adds Cloud Signature Consortium (CSC) integration for AES/QES signing against a configured TSP. v1 ships as instance-wide configuration via environment variables, with per-envelope signature level selection, license gating, and an OAuth-driven signing flow (capture + FIFO signers, SAD session, blocking/in-progress recipient pages). Includes signature level compatibility checks (role, signing order, dictate next signer), envelope mutability assertions, Prisma migration for signature level and CSC tables, and docs for the new signing certificate options. --- .env.example | 10 +- .../configuration/environment.mdx | 33 +- .../signing-certificate/csc-qes.mdx | 213 +++++++ .../signing-certificate/index.mdx | 19 +- .../signing-certificate/meta.json | 2 +- .../direct-template/direct-template-page.tsx | 5 +- .../csc-recipient-blocked-page.tsx | 68 +++ ...csc-recipient-signing-in-progress-page.tsx | 105 ++++ .../document-signing-page-view-v1.tsx | 4 +- .../document-signing-reject-dialog.tsx | 5 +- .../document/document-page-view-button.tsx | 4 +- .../envelope-signing-complete-dialog.tsx | 15 +- .../tables/documents-table-action-button.tsx | 4 +- .../documents-table-action-dropdown.tsx | 4 +- .../app/components/tables/inbox-table.tsx | 6 +- .../_recipient+/sign.$token+/_index.tsx | 96 ++- apps/remix/server/router.ts | 4 + package-lock.json | 18 +- package.json | 2 +- packages/ee/package.json | 2 + .../signing/csc/algorithm-resolver.ts | 347 +++++++++++ .../ee/server-only/signing/csc/cert-chain.ts | 122 ++++ .../ee/server-only/signing/csc/ciphers.ts | 51 ++ .../signing/csc/client/credentials.ts | 122 ++++ .../ee/server-only/signing/csc/client/http.ts | 170 ++++++ .../server-only/signing/csc/client/index.ts | 32 + .../ee/server-only/signing/csc/client/info.ts | 42 ++ .../server-only/signing/csc/client/oauth.ts | 321 ++++++++++ .../signing/csc/client/signatures.ts | 111 ++++ .../server-only/signing/csc/client/types.ts | 179 ++++++ .../csc/cookies/blocking-error-cookie.ts | 120 ++++ .../signing/csc/cookies/oauth-flow-cookie.ts | 85 +++ .../signing/csc/cookies/sad-session-cookie.ts | 61 ++ .../csc/cookies/service-session-cookie.ts | 65 +++ .../server-only/signing/csc/cookies/shared.ts | 46 ++ .../ee/server-only/signing/csc/credential.ts | 184 ++++++ .../signing/csc/execute-tsp-sign.ts | 546 ++++++++++++++++++ .../signing/csc/finalize-tsp-completion.ts | 130 +++++ .../server-only/signing/csc/hono/context.ts | 16 + .../ee/server-only/signing/csc/hono/index.ts | 63 ++ .../signing/csc/hono/oauth-authorize.ts | 154 +++++ .../signing/csc/hono/oauth-callback.ts | 303 ++++++++++ .../signing/csc/materialize-anchors.ts | 230 ++++++++ .../ee/server-only/signing/csc/pdf-names.ts | 23 + .../signing/csc/prepare-recipient-signing.ts | 248 ++++++++ .../server-only/signing/csc/render-overlay.ts | 162 ++++++ .../server-only/signing/csc/sign-session.ts | 181 ++++++ .../signing/csc/signers/capture-signer.ts | 123 ++++ .../signing/csc/signers/fifo-signer.ts | 57 ++ .../ee/server-only/signing/csc/transport.ts | 153 +++++ .../server-only/signing/csc/tsa-resolver.ts | 105 ++++ .../signing/csc/tsp-timestamp-authority.ts | 82 +++ .../hooks/use-editor-recipients.ts | 49 +- .../providers/envelope-editor-provider.tsx | 35 +- packages/lib/constants/app.ts | 53 ++ packages/lib/errors/app-error.ts | 57 +- .../internal/backport-subscription-claims.ts | 1 + .../internal/seal-document.handler.ts | 29 + .../document-meta/upsert-document-meta.ts | 21 + .../lib/server-only/document/send-document.ts | 31 +- .../replace-envelope-item-pdf.ts | 3 + .../envelope/assert-envelope-mutable.ts | 81 +++ .../server-only/envelope/create-envelope.ts | 38 +- .../envelope/duplicate-envelope.ts | 12 + .../get-envelope-for-recipient-signing.ts | 1 + .../server-only/envelope/update-envelope.ts | 21 + .../field/create-envelope-fields.ts | 5 + .../field/delete-document-field.ts | 5 + .../field/update-envelope-fields.ts | 5 + .../license/assert-licensed-for.ts | 69 +++ .../recipient/create-envelope-recipients.ts | 13 + .../recipient/delete-envelope-recipient.ts | 5 + .../recipient/set-document-recipients.ts | 13 + .../recipient/set-template-recipients.ts | 8 + .../recipient/update-envelope-recipients.ts | 17 + .../assert-compatible-dictate-next-signer.ts | 35 ++ .../assert-compatible-recipient-role.ts | 33 ++ .../assert-compatible-signing-order.ts | 41 ++ .../resolve-signature-level.ts | 87 +++ .../signature-level/resolve-signing-order.ts | 36 ++ .../create-document-from-direct-template.ts | 16 +- .../template/create-document-from-template.ts | 48 +- packages/lib/types/csc-session.ts | 28 + packages/lib/types/document-audit-logs.ts | 77 +++ packages/lib/types/license.ts | 9 + packages/lib/types/signature-level.ts | 33 ++ packages/lib/types/subscription.ts | 7 + packages/lib/utils/document-audit-logs.ts | 25 + packages/lib/utils/document.ts | 12 +- packages/lib/utils/env.ts | 29 + .../migration.sql | 54 ++ packages/prisma/schema.prisma | 73 ++- packages/prisma/seed/documents.ts | 5 + packages/prisma/seed/initial-seed.ts | 5 + packages/prisma/seed/templates.ts | 4 + .../enterprise-router/csc-sign-envelope.ts | 43 ++ .../csc-sign-envelope.types.ts | 13 + .../trpc/server/enterprise-router/router.ts | 4 + .../replace-envelope-item-pdf.ts | 3 + .../trpc/server/recipient-router/router.ts | 27 + .../trpc/server/recipient-router/schema.ts | 14 + packages/tsconfig/process-env.d.ts | 11 +- turbo.json | 4 + 103 files changed, 6524 insertions(+), 77 deletions(-) create mode 100644 apps/docs/content/docs/self-hosting/configuration/signing-certificate/csc-qes.mdx create mode 100644 apps/remix/app/components/general/document-signing/csc-recipient-blocked-page.tsx create mode 100644 apps/remix/app/components/general/document-signing/csc-recipient-signing-in-progress-page.tsx create mode 100644 packages/ee/server-only/signing/csc/algorithm-resolver.ts create mode 100644 packages/ee/server-only/signing/csc/cert-chain.ts create mode 100644 packages/ee/server-only/signing/csc/ciphers.ts create mode 100644 packages/ee/server-only/signing/csc/client/credentials.ts create mode 100644 packages/ee/server-only/signing/csc/client/http.ts create mode 100644 packages/ee/server-only/signing/csc/client/index.ts create mode 100644 packages/ee/server-only/signing/csc/client/info.ts create mode 100644 packages/ee/server-only/signing/csc/client/oauth.ts create mode 100644 packages/ee/server-only/signing/csc/client/signatures.ts create mode 100644 packages/ee/server-only/signing/csc/client/types.ts create mode 100644 packages/ee/server-only/signing/csc/cookies/blocking-error-cookie.ts create mode 100644 packages/ee/server-only/signing/csc/cookies/oauth-flow-cookie.ts create mode 100644 packages/ee/server-only/signing/csc/cookies/sad-session-cookie.ts create mode 100644 packages/ee/server-only/signing/csc/cookies/service-session-cookie.ts create mode 100644 packages/ee/server-only/signing/csc/cookies/shared.ts create mode 100644 packages/ee/server-only/signing/csc/credential.ts create mode 100644 packages/ee/server-only/signing/csc/execute-tsp-sign.ts create mode 100644 packages/ee/server-only/signing/csc/finalize-tsp-completion.ts create mode 100644 packages/ee/server-only/signing/csc/hono/context.ts create mode 100644 packages/ee/server-only/signing/csc/hono/index.ts create mode 100644 packages/ee/server-only/signing/csc/hono/oauth-authorize.ts create mode 100644 packages/ee/server-only/signing/csc/hono/oauth-callback.ts create mode 100644 packages/ee/server-only/signing/csc/materialize-anchors.ts create mode 100644 packages/ee/server-only/signing/csc/pdf-names.ts create mode 100644 packages/ee/server-only/signing/csc/prepare-recipient-signing.ts create mode 100644 packages/ee/server-only/signing/csc/render-overlay.ts create mode 100644 packages/ee/server-only/signing/csc/sign-session.ts create mode 100644 packages/ee/server-only/signing/csc/signers/capture-signer.ts create mode 100644 packages/ee/server-only/signing/csc/signers/fifo-signer.ts create mode 100644 packages/ee/server-only/signing/csc/transport.ts create mode 100644 packages/ee/server-only/signing/csc/tsa-resolver.ts create mode 100644 packages/ee/server-only/signing/csc/tsp-timestamp-authority.ts create mode 100644 packages/lib/server-only/envelope/assert-envelope-mutable.ts create mode 100644 packages/lib/server-only/license/assert-licensed-for.ts create mode 100644 packages/lib/server-only/signature-level/assert-compatible-dictate-next-signer.ts create mode 100644 packages/lib/server-only/signature-level/assert-compatible-recipient-role.ts create mode 100644 packages/lib/server-only/signature-level/assert-compatible-signing-order.ts create mode 100644 packages/lib/server-only/signature-level/resolve-signature-level.ts create mode 100644 packages/lib/server-only/signature-level/resolve-signing-order.ts create mode 100644 packages/lib/types/csc-session.ts create mode 100644 packages/lib/types/signature-level.ts create mode 100644 packages/prisma/migrations/20260525103410_add_signature_level_and_csc_tables/migration.sql create mode 100644 packages/trpc/server/enterprise-router/csc-sign-envelope.ts create mode 100644 packages/trpc/server/enterprise-router/csc-sign-envelope.types.ts 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 `