mirror of
https://github.com/documenso/documenso.git
synced 2026-06-22 04:12:06 +10:00
chore: merged main
This commit is contained in:
+9
-1
@@ -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.
|
||||
|
||||
@@ -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` |
|
||||
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
---
|
||||
title: CSC (AES / QES)
|
||||
description: Configure Cloud Signature Consortium signing for Advanced and Qualified Electronic Signatures via a third-party Trust Service Provider.
|
||||
---
|
||||
|
||||
import { Callout } from 'fumadocs-ui/components/callout';
|
||||
import { Step, Steps } from 'fumadocs-ui/components/steps';
|
||||
|
||||
The `csc` signing transport routes signatures through a third-party Trust Service Provider (TSP) using the [Cloud Signature Consortium API v1.0.4.0](https://cloudsignatureconsortium.org/). Each recipient authenticates directly with the TSP (Strong Customer Authentication) and the TSP returns a per-recipient signature bound to the document hash. Documenso assembles the resulting PAdES signature inside the PDF.
|
||||
|
||||
This transport enables **Advanced Electronic Signatures (AES)** and **Qualified Electronic Signatures (QES)** under eIDAS. See [Signature Levels](/docs/compliance/signature-levels) for the legal framework.
|
||||
|
||||
<Callout type="warn">
|
||||
CSC mode is **instance-wide**: one CSC provider per Documenso install. All envelopes created
|
||||
while the instance runs in `csc` mode use AES or QES. Switching `NEXT_PRIVATE_SIGNING_TRANSPORT`
|
||||
is a one-way operational migration — see [Switching Transports](#switching-transports).
|
||||
</Callout>
|
||||
|
||||
<Callout type="warn">
|
||||
CSC mode requires an active [Enterprise Edition](/docs/policies/enterprise-edition) license. The
|
||||
instance refuses to start in `csc` mode without it.
|
||||
</Callout>
|
||||
|
||||
## Prerequisites
|
||||
|
||||
{/* prettier-ignore */}
|
||||
<Steps>
|
||||
<Step>
|
||||
|
||||
### A TSP account
|
||||
|
||||
Establish a relationship with a CSC-compatible Trust Service Provider. The TSP issues qualified or advanced certificates to your signers, holds the private keys in its HSM, and exposes a CSC v1.0.4.0-compliant API.
|
||||
|
||||
</Step>
|
||||
<Step>
|
||||
|
||||
### OAuth client credentials
|
||||
|
||||
Register Documenso as an OAuth client with the TSP. You will receive a client ID and client secret, and must supply Documenso's callback URL when registering:
|
||||
|
||||
```
|
||||
${NEXT_PUBLIC_WEBAPP_URL}/api/csc/oauth/callback
|
||||
```
|
||||
|
||||
The callback URL is fixed — Documenso derives it from `NEXT_PUBLIC_WEBAPP_URL` and the route mount path. There is no env var to override it; ensuring the registered URL matches your instance's webapp URL exactly is the operator's responsibility.
|
||||
|
||||
</Step>
|
||||
<Step>
|
||||
|
||||
### Enterprise Edition license
|
||||
|
||||
CSC mode is gated by the `instanceCscSigning` license flag. Without a valid Enterprise license, the transport refuses to start (`CSC_UNLICENSED`).
|
||||
|
||||
</Step>
|
||||
<Step>
|
||||
|
||||
### S3 storage (strongly recommended)
|
||||
|
||||
CSC produces multiple `DocumentData` rows per envelope item (one per recipient signature, plus the materialised and source rows). Database-backed storage base64-inflates each row by ~33% and is impractical at meaningful PDF sizes. Configure [S3 storage](/docs/self-hosting/configuration/storage) before enabling CSC.
|
||||
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Description | Default |
|
||||
| ---------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | ------- |
|
||||
| `NEXT_PRIVATE_SIGNING_TRANSPORT` | Set to `csc` | |
|
||||
| `NEXT_PRIVATE_SIGNING_CSC_PROVIDER_BASE_URL` | Base URL of the CSC provider's API | |
|
||||
| `NEXT_PRIVATE_SIGNING_CSC_OAUTH_CLIENT_ID` | OAuth client ID registered with the CSC provider | |
|
||||
| `NEXT_PRIVATE_SIGNING_CSC_OAUTH_CLIENT_SECRET` | OAuth client secret registered with the CSC provider | |
|
||||
| `NEXT_PRIVATE_SIGNING_CSC_SIGNATURE_LEVEL` | Default legal tier for new envelopes when the caller does not specify one. `AES` or `QES`. Explicit requests always pass through. | `AES` |
|
||||
| `NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY` | **Required.** Comma-separated RFC 3161 TSA URLs. Always used for B-LTA archival timestamps at seal time, and also serves as the B-T sign-time fallback when the TSP does not expose `signatures/timestamp`. The instance refuses to start in CSC mode without it. See [Timestamp Authority Resolution](#timestamp-authority-resolution). | |
|
||||
|
||||
<Callout type="info">
|
||||
`NEXT_PUBLIC_SIGNING_TRANSPORT_IS_CSC` is set automatically from
|
||||
`NEXT_PRIVATE_SIGNING_TRANSPORT` at server startup. Do not set it manually — see
|
||||
[Environment Variables](/docs/self-hosting/configuration/environment#derived-public-variables).
|
||||
</Callout>
|
||||
|
||||
## Configuration Example
|
||||
|
||||
```bash
|
||||
NEXT_PRIVATE_SIGNING_TRANSPORT=csc
|
||||
NEXT_PRIVATE_SIGNING_CSC_PROVIDER_BASE_URL=https://api.example-tsp.com/csc/v1
|
||||
NEXT_PRIVATE_SIGNING_CSC_OAUTH_CLIENT_ID=documenso-prod
|
||||
NEXT_PRIVATE_SIGNING_CSC_OAUTH_CLIENT_SECRET=...
|
||||
NEXT_PRIVATE_SIGNING_CSC_SIGNATURE_LEVEL=QES
|
||||
NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY=http://timestamp.example.com
|
||||
```
|
||||
|
||||
Register `${NEXT_PUBLIC_WEBAPP_URL}/api/csc/oauth/callback` (e.g. `https://sign.example.com/api/csc/oauth/callback`) as the OAuth callback URL with the TSP.
|
||||
|
||||
## Default Signature Level
|
||||
|
||||
`NEXT_PRIVATE_SIGNING_CSC_SIGNATURE_LEVEL` selects the legal tier applied to envelopes that do not specify one explicitly. It is a default, not a capability gate: callers may still create AES or QES envelopes explicitly regardless of this setting.
|
||||
|
||||
| Configured value | Caller passes nothing | Caller passes `AES` | Caller passes `QES` |
|
||||
| ---------------- | --------------------- | ------------------- | ------------------- |
|
||||
| `AES` (default) | Envelope is `AES` | Envelope is `AES` | Envelope is `QES` |
|
||||
| `QES` | Envelope is `QES` | Envelope is `AES` | Envelope is `QES` |
|
||||
|
||||
Any value other than `AES` or `QES` causes the instance to refuse to start. This prevents silent qualified-to-advanced downgrades from a typo.
|
||||
|
||||
## Timestamp Authority Resolution
|
||||
|
||||
AES/QES envelopes use TSA-attested timestamps in two distinct phases. Resolution differs per phase.
|
||||
|
||||
### Sign time — PAdES B-T per recipient
|
||||
|
||||
Each recipient's CMS embeds a signature timestamp (CMS unsigned attribute) so proven time is bound to the recipient's signature itself. Resolution order:
|
||||
|
||||
1. If the TSP advertises `signatures/timestamp` in its `info` response (CSC §11.10), the TSP endpoint is used. The call is authorised with **this recipient's** service-scope bearer token — the same one authorising the `signatures/signHash` call alongside it.
|
||||
2. Otherwise, the first URL from `NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY` is used (RFC 3161 over HTTP).
|
||||
|
||||
Selection is made at boot from the discovered transport, not at runtime; there is no try-then-fall-through. If the chosen source fails, the recipient's sign attempt fails.
|
||||
|
||||
### Seal time — PAdES B-LTA archival
|
||||
|
||||
The seal-document job emits a single archival `/DocTimeStamp` over the fully-signed envelope (plus DSS for the existing signatures and the timestamp's own chain). This phase is **env-only**: the first URL from `NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY` is always used.
|
||||
|
||||
The archival anchor is the operator's long-term trust anchor and SHOULD point at a dedicated qualified archival TSA (e.g. DigiCert) independent of the per-recipient TSP. We deliberately do not fall back to the TSP at seal time: archive longevity should not be coupled to a TSP that may rotate or revoke, and the seal-document job has no recipient context to carry a service-scope bearer.
|
||||
|
||||
### Boot-time guard
|
||||
|
||||
The instance refuses to start in CSC mode unless `NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY` is set (`CSC_PROVIDER_NO_TSA` at transport construction). The env var is required unconditionally — even when the TSP advertises its own `signatures/timestamp`, seal-time B-LTA archival uses the env TSA. Catching this at boot prevents the failure mode where an envelope signs successfully at B-T and then hangs in `WAITING_FOR_SIGNATURE_COMPLETION` when the seal job throws.
|
||||
|
||||
## Switching Transports
|
||||
|
||||
`NEXT_PRIVATE_SIGNING_TRANSPORT` is a one-way operational migration. Existing envelopes route per the `signatureLevel` column they were created with — the runtime branching looks at the envelope, not the env var. After a switch:
|
||||
|
||||
- Envelopes already at `SES` continue to use the new transport for sealing, but the new transport's signer must produce SES-compatible signatures (only `local` and `gcloud-hsm` qualify).
|
||||
- Envelopes already at `AES` / `QES` will fail at sign or seal time if the new transport is not `csc`.
|
||||
|
||||
Plan migrations during a quiet window with no in-flight envelopes.
|
||||
|
||||
## Behavioural Notes
|
||||
|
||||
CSC mode changes a number of envelope-authoring behaviours that operators should communicate to users.
|
||||
|
||||
### Mutation lock at distribution
|
||||
|
||||
For AES/QES envelopes, all authoring routes refuse mutations once the envelope leaves DRAFT. This locks the PDF before any recipient begins Strong Customer Authentication, closing the PDF-swap window that would otherwise allow an owner to replace the PDF between view and sign and break the legal "what you see is what you sign" guarantee.
|
||||
|
||||
In practice: edit envelope, recipients, fields, and items freely while DRAFT; once sent, no changes are accepted (including from the API).
|
||||
|
||||
### Sequential signing only
|
||||
|
||||
Parallel signing produces conflicting incremental updates over the same base PDF, breaking the per-recipient `/ByteRange` invariant. The signing order is forced to `SEQUENTIAL` on AES/QES envelopes — at the schema layer, at send time, and in the UI (the parallel-signing toggle is hidden).
|
||||
|
||||
### Assistant role and Dictate Next Signer disabled
|
||||
|
||||
Both features modify the recipient set after the envelope is sent, which is incompatible with the AES/QES mutation lock. They are hidden in the UI and rejected at the server schema layer.
|
||||
|
||||
### Sidecar PDFs at download
|
||||
|
||||
The signed PDF must remain byte-identical to what each recipient's TSP signature authorised — Documenso cannot decorate it after signing. Audit logs and the Certificate of Completion are generated on demand and delivered as separate PDFs:
|
||||
|
||||
- `GET /sign/{token}/download` returns the signed PDF only (or a ZIP for multi-item envelopes).
|
||||
- `GET /sign/{token}/download?version=bundle` returns a ZIP containing the signed PDFs, audit log PDF, and Certificate of Completion.
|
||||
- The completion email attaches all three.
|
||||
|
||||
## Recipient Flow
|
||||
|
||||
For context when supporting end users, here is what a recipient experiences on an AES/QES envelope:
|
||||
|
||||
1. Opens the email link, lands on the signing page.
|
||||
2. Documenso redirects to the TSP for Strong Customer Authentication (first visit only; cached for the session lifetime).
|
||||
3. Fills fields as normal.
|
||||
4. Clicks Sign → redirected to the TSP for a second authentication round (issues a per-document Signature Activation Data token).
|
||||
5. Returns to Documenso; the signing call completes within ~15 seconds.
|
||||
6. Sees the standard completion screen.
|
||||
|
||||
If the TSP returns no eligible credentials for the recipient (e.g. they have not enrolled), they see a blocking page directing them to enrol with the TSP and retry.
|
||||
|
||||
## Error Codes
|
||||
|
||||
CSC-specific error codes surfaced through the standard error channels:
|
||||
|
||||
| Code | Meaning | Recovery |
|
||||
| -------------------------- | ------------------------------------------------ | ----------------------------------------------------------------------- |
|
||||
| `CSC_UNLICENSED` | License flag absent at transport-create | Operator: enable Enterprise Edition, restart |
|
||||
| `CSC_PROVIDER_INFO_FAILED` | `info` discovery failed at startup | Operator: check TSP availability and `NEXT_PRIVATE_SIGNING_CSC_PROVIDER_BASE_URL` |
|
||||
| `CSC_PROVIDER_NO_TSA` | `NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY` is unset | Operator: configure `NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY` |
|
||||
| `CSC_CREDENTIAL_LIST_EMPTY`| TSP returned no credentials for the user | Recipient: enrol with the TSP |
|
||||
| `CSC_CERT_INVALID` | Certificate refused at credential validation | Recipient: contact the TSP |
|
||||
| `CSC_ALGORITHM_REFUSED` | Signature algorithm fails policy | Operator/recipient: TSP does not meet policy (see below) |
|
||||
| `CSC_SAD_EXPIRED_PRE_SIGN` | Signature Activation Data expired before signing | Recipient: retry from Sign |
|
||||
| `CSC_TSP_TIMEOUT` | 15-second synchronous timeout reached | Recipient: retry (idempotent — the TSP enforces single-use SAD binding) |
|
||||
| `CSC_EMBED_FAILED` | Sign-time digest diverged from prep capture | Recipient: retry from Sign |
|
||||
| `CSC_BASE_DOCUMENT_MUTATED`| Document data changed between prep and sign | Operator: investigate (structural guard violation) |
|
||||
| `CSC_INSTANCE_MODE_MISMATCH`| Envelope created with wrong level for transport | Caller: use a level matching the instance transport |
|
||||
| `CSC_REQUEST_FAILED` | TSP HTTP transport failure — network error, non-2xx, or malformed response | Operator: check TSP availability; carries the TSP HTTP status and error in the message |
|
||||
|
||||
## Algorithm Policy
|
||||
|
||||
Documenso refuses TSP credentials that do not meet the following minimums, at the OAuth callback boundary and again at sign time:
|
||||
|
||||
| Class | Allowed | Refused |
|
||||
| ----- | ---------------------------------- | ------------------------------------------------------ |
|
||||
| RSA | `key.len >= 2048` | Missing `key.len`, `key.len < 2048` |
|
||||
| ECDSA | P-256, P-384, P-521 | Missing `key.curve`, P-192, P-224, other curves |
|
||||
| Hash | SHA-256, SHA-384, SHA-512 | SHA-1, MD5 |
|
||||
| Other | — | DSA |
|
||||
|
||||
This is the union of CSC v1.0.4.0 §11.5 requirements and current cryptographic guidance.
|
||||
|
||||
## Related
|
||||
|
||||
- [Signature Levels](/docs/compliance/signature-levels) — AES / QES legal framework
|
||||
- [Signing Certificate](/docs/self-hosting/configuration/signing-certificate) — overview of all signing transports
|
||||
- [Environment Variables](/docs/self-hosting/configuration/environment) — full env reference
|
||||
- [Enterprise Edition](/docs/policies/enterprise-edition) — license requirements
|
||||
@@ -24,6 +24,11 @@ Self-hosted Documenso instances require a signing certificate. You can generate
|
||||
description="Hardware-based key protection with Google Cloud KMS."
|
||||
href="/docs/self-hosting/configuration/signing-certificate/google-cloud-hsm"
|
||||
/>
|
||||
<Card
|
||||
title="CSC (AES / QES)"
|
||||
description="Route signing through a third-party Trust Service Provider for Advanced and Qualified Electronic Signatures."
|
||||
href="/docs/self-hosting/configuration/signing-certificate/csc-qes"
|
||||
/>
|
||||
<Card
|
||||
title="Timestamp Server"
|
||||
description="Add trusted timestamps and customise signature appearance."
|
||||
@@ -38,7 +43,7 @@ Self-hosted Documenso instances require a signing certificate. You can generate
|
||||
|
||||
## Certificate Options
|
||||
|
||||
<Tabs items={['Self-Signed', 'CA-Issued', 'Google Cloud HSM']}>
|
||||
<Tabs items={['Self-Signed', 'CA-Issued', 'Google Cloud HSM', 'CSC (AES / QES)']}>
|
||||
<Tab value="Self-Signed">
|
||||
|
||||
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.
|
||||
|
||||
</Tab>
|
||||
<Tab value="CSC (AES / QES)">
|
||||
|
||||
For Advanced and Qualified Electronic Signatures under eIDAS, Documenso integrates with third-party Trust Service Providers via the Cloud Signature Consortium API. Each recipient authenticates directly with the TSP, which holds the private key and issues the signature.
|
||||
|
||||
- Per-recipient identity verification by an accredited TSP
|
||||
- Legally equivalent to a handwritten signature within the EU (QES)
|
||||
- Requires an [Enterprise Edition](/docs/policies/enterprise-edition) license
|
||||
- Instance-wide setting; one CSC provider per Documenso install
|
||||
|
||||
See [CSC (AES / QES)](/docs/self-hosting/configuration/signing-certificate/csc-qes) for setup instructions.
|
||||
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { AlertTriangleIcon } from 'lucide-react';
|
||||
|
||||
export type CscRecipientBlockedPageProps = {
|
||||
code: string;
|
||||
recipientToken: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Terminal page rendered when the service-scope CSC OAuth callback surfaces a
|
||||
* hard error the recipient can't resolve themselves (empty credential list,
|
||||
* invalid cert, refused algorithm). The blocking-error cookie is read +
|
||||
* cleared by the loader; this page only renders the message + retry CTA.
|
||||
*
|
||||
* The retry link kicks a fresh service-scope OAuth round-trip — useful when
|
||||
* the TSP-side issue is transient (e.g. the recipient's admin has since
|
||||
* provisioned a credential).
|
||||
*/
|
||||
export const CscRecipientBlockedPage = ({ code, recipientToken }: CscRecipientBlockedPageProps) => {
|
||||
const retryUrl = `/api/csc/oauth/authorize?scope=service&token=${encodeURIComponent(recipientToken)}`;
|
||||
|
||||
return (
|
||||
<div className="-mx-4 flex max-w-[100vw] flex-col items-center overflow-x-hidden px-4 pt-16 md:-mx-8 md:px-8 lg:pt-16 xl:pt-24">
|
||||
<AlertTriangleIcon className="h-12 w-12 text-destructive" />
|
||||
|
||||
<h2 className="mt-6 max-w-[35ch] text-center font-semibold text-2xl leading-normal md:text-3xl lg:text-4xl">
|
||||
{code === AppErrorCode.CSC_CREDENTIAL_LIST_EMPTY ? (
|
||||
<Trans>No signing credentials available</Trans>
|
||||
) : code === AppErrorCode.CSC_CERT_INVALID ? (
|
||||
<Trans>Signing certificate is invalid</Trans>
|
||||
) : code === AppErrorCode.CSC_ALGORITHM_REFUSED ? (
|
||||
<Trans>Signing algorithm is not supported</Trans>
|
||||
) : (
|
||||
<Trans>Unable to start the signing flow</Trans>
|
||||
)}
|
||||
</h2>
|
||||
|
||||
<p className="mt-2.5 max-w-[60ch] text-center font-medium text-muted-foreground/60 text-sm md:text-base">
|
||||
{code === AppErrorCode.CSC_CREDENTIAL_LIST_EMPTY ? (
|
||||
<Trans>
|
||||
Your signing provider returned no usable credentials for this account. Contact your administrator or signing
|
||||
provider for assistance.
|
||||
</Trans>
|
||||
) : code === AppErrorCode.CSC_CERT_INVALID ? (
|
||||
<Trans>
|
||||
Your signing certificate is invalid, expired, or missing a required key. Contact your administrator or
|
||||
signing provider for assistance.
|
||||
</Trans>
|
||||
) : code === AppErrorCode.CSC_ALGORITHM_REFUSED ? (
|
||||
<Trans>
|
||||
Your signing provider does not advertise a signing algorithm this document accepts. Contact your
|
||||
administrator or signing provider for assistance.
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>Something went wrong while preparing the remote signature. Please try again.</Trans>
|
||||
)}
|
||||
</p>
|
||||
|
||||
<Button asChild className="mt-8">
|
||||
<a href={retryUrl}>
|
||||
<Trans>Try again</Trans>
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { AlertTriangleIcon, Loader2Icon } from 'lucide-react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
export type CscRecipientSigningInProgressPageProps = {
|
||||
sessionId: string;
|
||||
recipientToken: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Rendered when the credential-scope OAuth callback has attached a SAD to the
|
||||
* server-side `CscSession` and set the `csc_sad_session` cookie. The page
|
||||
* auto-fires `enterprise.csc.signEnvelope` on mount and navigates to the
|
||||
* completion page on success. On failure, it surfaces an error message and
|
||||
* a retry CTA pointing at a fresh credential-scope OAuth round-trip.
|
||||
*/
|
||||
export const CscRecipientSigningInProgressPage = ({
|
||||
sessionId,
|
||||
recipientToken,
|
||||
}: CscRecipientSigningInProgressPageProps) => {
|
||||
const { mutateAsync: signEnvelope } = trpc.enterprise.csc.signEnvelope.useMutation();
|
||||
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Ref rather than state for the fire-once guard. Refs mutate synchronously,
|
||||
// so React StrictMode's double-invoke of the effect sees the updated value
|
||||
// on the second pass and short-circuits. A useState guard would still let
|
||||
// the second effect fire because the queued setState from the first run
|
||||
// hasn't been committed yet when the second one reads it — that double-fire
|
||||
// races two signEnvelope calls; whichever loses sees the SAD already
|
||||
// consumed and flashes "Signing failed" before the winning call's
|
||||
// navigation kicks in.
|
||||
const hasFiredRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasFiredRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
hasFiredRef.current = true;
|
||||
|
||||
const run = async () => {
|
||||
try {
|
||||
await signEnvelope({ sessionId, recipientToken });
|
||||
|
||||
window.location.href = `/sign/${recipientToken}/complete`;
|
||||
} catch (err) {
|
||||
const parsed = AppError.parseError(err);
|
||||
setError(parsed.code || AppErrorCode.UNKNOWN_ERROR);
|
||||
}
|
||||
};
|
||||
|
||||
void run();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const retryUrl = `/api/csc/oauth/authorize?scope=credential&session=${encodeURIComponent(sessionId)}`;
|
||||
|
||||
return (
|
||||
<div className="-mx-4 flex max-w-[100vw] flex-col items-center overflow-x-hidden px-4 pt-16 md:-mx-8 md:px-8 lg:pt-16 xl:pt-24">
|
||||
{error ? (
|
||||
<>
|
||||
<AlertTriangleIcon className="h-12 w-12 text-destructive" />
|
||||
|
||||
<h2 className="mt-6 max-w-[35ch] text-center font-semibold text-2xl leading-normal md:text-3xl lg:text-4xl">
|
||||
<Trans>Signing failed</Trans>
|
||||
</h2>
|
||||
|
||||
<p className="mt-2.5 max-w-[60ch] text-center font-medium text-muted-foreground/60 text-sm md:text-base">
|
||||
{error === AppErrorCode.CSC_TSP_TIMEOUT ? (
|
||||
<Trans>The signing provider did not respond in time. Please retry.</Trans>
|
||||
) : error === AppErrorCode.CSC_SAD_EXPIRED_PRE_SIGN ? (
|
||||
<Trans>
|
||||
Your signing authorisation expired before the signature could be applied. Please reauthorise to retry.
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>Something went wrong while applying your signature. Please retry.</Trans>
|
||||
)}
|
||||
</p>
|
||||
|
||||
<Button asChild className="mt-8">
|
||||
<a href={retryUrl}>
|
||||
<Trans>Reauthorise and retry</Trans>
|
||||
</a>
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Loader2Icon className="h-12 w-12 animate-spin text-primary" />
|
||||
|
||||
<h2 className="mt-6 max-w-[35ch] text-center font-semibold text-2xl leading-normal md:text-3xl lg:text-4xl">
|
||||
<Trans>Applying your signature</Trans>
|
||||
</h2>
|
||||
|
||||
<p className="mt-2.5 max-w-[60ch] text-center font-medium text-muted-foreground/60 text-sm md:text-base">
|
||||
<Trans>Please don't close this tab. The signing provider is finalising your signature.</Trans>
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+1
-3
@@ -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<number | null>(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`;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
+2
-3
@@ -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({
|
||||
|
||||
@@ -38,7 +38,7 @@ export const DocumentPageViewButton = ({ envelope }: DocumentPageViewButtonProps
|
||||
})
|
||||
.with({ isRecipient: true, isPending: true, isSigned: false }, () => (
|
||||
<Button className="w-full" asChild>
|
||||
<Link to={`/sign/${recipient?.token}`}>
|
||||
<a href={`/sign/${recipient?.token}`}>
|
||||
{match(role)
|
||||
.with(RecipientRole.SIGNER, () => (
|
||||
<>
|
||||
@@ -58,7 +58,7 @@ export const DocumentPageViewButton = ({ envelope }: DocumentPageViewButtonProps
|
||||
<Trans>View</Trans>
|
||||
</>
|
||||
))}
|
||||
</Link>
|
||||
</a>
|
||||
</Button>
|
||||
))
|
||||
.with({ isComplete: false }, () => (
|
||||
|
||||
+12
-3
@@ -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);
|
||||
|
||||
@@ -66,7 +66,7 @@ export const DocumentsTableActionButton = ({ row }: DocumentsTableActionButtonPr
|
||||
))
|
||||
.with({ isRecipient: true, isPending: true, isSigned: false }, () => (
|
||||
<Button className="w-32" asChild>
|
||||
<Link to={`/sign/${recipient?.token}`}>
|
||||
<a href={`/sign/${recipient?.token}`}>
|
||||
{match(role)
|
||||
.with(RecipientRole.SIGNER, () => (
|
||||
<>
|
||||
@@ -86,7 +86,7 @@ export const DocumentsTableActionButton = ({ row }: DocumentsTableActionButtonPr
|
||||
<Trans>View</Trans>
|
||||
</>
|
||||
))}
|
||||
</Link>
|
||||
</a>
|
||||
</Button>
|
||||
))
|
||||
.with({ isPending: true, isSigned: true }, () => (
|
||||
|
||||
@@ -105,7 +105,7 @@ export const DocumentsTableActionDropdown = ({ row, onMoveDocument }: DocumentsT
|
||||
recipient?.role !== RecipientRole.CC &&
|
||||
recipient?.role !== RecipientRole.ASSISTANT && (
|
||||
<DropdownMenuItem disabled={!recipient || isComplete} asChild>
|
||||
<Link to={`/sign/${recipient?.token}`}>
|
||||
<a href={`/sign/${recipient?.token}`}>
|
||||
{recipient?.role === RecipientRole.VIEWER && (
|
||||
<>
|
||||
<EyeIcon className="mr-2 h-4 w-4" />
|
||||
@@ -126,7 +126,7 @@ export const DocumentsTableActionDropdown = ({ row, onMoveDocument }: DocumentsT
|
||||
<Trans>Approve</Trans>
|
||||
</>
|
||||
)}
|
||||
</Link>
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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 }, () => (
|
||||
<Button className="w-32" asChild>
|
||||
<Link to={`/sign/${recipient?.token}`}>
|
||||
<a href={`/sign/${recipient?.token}`}>
|
||||
{match(role)
|
||||
.with(RecipientRole.SIGNER, () => (
|
||||
<>
|
||||
@@ -220,7 +220,7 @@ export const InboxTableActionButton = ({ row }: InboxTableActionButtonProps) =>
|
||||
<Trans>View</Trans>
|
||||
</>
|
||||
))}
|
||||
</Link>
|
||||
</a>
|
||||
</Button>
|
||||
))
|
||||
.with({ isPending: true, isSigned: true }, () => (
|
||||
|
||||
@@ -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<ReturnType<typeof handleV2Loade
|
||||
return <DocumentSigningAuthPageView email={data.recipientEmail} emailHasAccount={!!data.recipientHasAccount} />;
|
||||
}
|
||||
|
||||
if ('csc' in data && data.csc?.state === 'blocked') {
|
||||
return <CscRecipientBlockedPage code={data.csc.code} recipientToken={data.envelopeForSigning.recipient.token} />;
|
||||
}
|
||||
|
||||
if ('csc' in data && data.csc?.state === 'signing-in-progress') {
|
||||
return (
|
||||
<CscRecipientSigningInProgressPage
|
||||
sessionId={data.csc.sessionId}
|
||||
recipientToken={data.envelopeForSigning.recipient.token}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const { envelope, recipientSignature, recipient } = data.envelopeForSigning;
|
||||
|
||||
if (envelope.deletedAt || envelope.status === DocumentStatus.REJECTED) {
|
||||
|
||||
@@ -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());
|
||||
|
||||
Generated
+10
-8
@@ -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",
|
||||
@@ -4649,16 +4649,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"
|
||||
},
|
||||
@@ -4712,9 +4712,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"
|
||||
@@ -30569,6 +30569,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",
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -0,0 +1,347 @@
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
|
||||
import type { TCscCredentialsInfoResponse } from './client/types';
|
||||
|
||||
/**
|
||||
* CSC QES V1 algorithm policy.
|
||||
*
|
||||
* Single OID-to-algorithm map + single helper that:
|
||||
* - validates cert state (status, validity window) → `CSC_CERT_INVALID`,
|
||||
* - validates the credential's key + algorithm against the spec's policy
|
||||
* table (RSA ≥2048, ECDSA P-256/384/521, SHA-256/384/512) →
|
||||
* `CSC_ALGORITHM_REFUSED`,
|
||||
* - resolves a concrete `(signAlgo, hashAlgo)` OID pair for §11.9.
|
||||
*
|
||||
* Called at the service-scope OAuth callback (validation boundary) and
|
||||
* re-called at sign time as a defence-in-depth pre-check. Persisted fields
|
||||
* (`keyType` / `keyLenBits` / `digestAlgorithm` / `signAlgoOid`) round-trip
|
||||
* through `CscCredential`.
|
||||
*/
|
||||
|
||||
export type CscKeyType = 'RSA' | 'ECDSA';
|
||||
|
||||
export type CscDigest = 'SHA-256' | 'SHA-384' | 'SHA-512';
|
||||
|
||||
export type CscEcdsaCurve = 'P-256' | 'P-384' | 'P-521';
|
||||
|
||||
export type CscAlgorithmPolicy = {
|
||||
keyType: CscKeyType;
|
||||
keyLenBits: number;
|
||||
digestAlgorithm: CscDigest;
|
||||
/** OID for `signatures/signHash.signAlgo` + persisted on `CscCredential`. */
|
||||
signAlgoOid: string;
|
||||
/** OID for `signatures/signHash.hashAlgo`. */
|
||||
hashAlgoOid: string;
|
||||
/** ECDSA named curve (informational; not separately persisted). */
|
||||
ecdsaCurve?: CscEcdsaCurve;
|
||||
};
|
||||
|
||||
/**
|
||||
* Default RSA digest when the TSP advertises only hash-agnostic RSA OIDs
|
||||
* (plain `rsaEncryption` / RSASSA-PSS). SHA-256 matches the CSC §11.9
|
||||
* sample and is universally TSP-supported.
|
||||
*/
|
||||
const DEFAULT_RSA_DIGEST: CscDigest = 'SHA-256';
|
||||
|
||||
const HASH_OID_FOR_DIGEST: Record<CscDigest, string> = {
|
||||
'SHA-256': '2.16.840.1.101.3.4.2.1',
|
||||
'SHA-384': '2.16.840.1.101.3.4.2.2',
|
||||
'SHA-512': '2.16.840.1.101.3.4.2.3',
|
||||
};
|
||||
|
||||
/**
|
||||
* Exposed lookup for the `signatures/signHash.hashAlgo` OID corresponding to a
|
||||
* resolved {@link CscDigest}. Useful at sign time when the policy's
|
||||
* `hashAlgoOid` field is not in scope (e.g. when reconstructing a
|
||||
* `LibpdfSignerAlgo` from a persisted `CscCredential` row).
|
||||
*/
|
||||
export const hashOidForDigest = (digest: CscDigest): string => HASH_OID_FOR_DIGEST[digest];
|
||||
|
||||
const DIGEST_STRENGTH: Record<CscDigest, number> = {
|
||||
'SHA-256': 256,
|
||||
'SHA-384': 384,
|
||||
'SHA-512': 512,
|
||||
};
|
||||
|
||||
const STRONG_DIGEST_SET = new Set<string>(['SHA-256', 'SHA-384', 'SHA-512']);
|
||||
|
||||
type AlgoOidInfo = { family: 'RSA' | 'ECDSA'; boundDigest: CscDigest | 'SHA-1' | 'MD5' | null } | { family: 'DSA' };
|
||||
|
||||
/**
|
||||
* Source-of-truth registry for `key.algo` entries (§11.5). Anything not
|
||||
* listed is treated as unknown and skipped at policy evaluation.
|
||||
*/
|
||||
const KEY_ALGO_OID_REGISTRY: Record<string, AlgoOidInfo> = {
|
||||
// Hash-agnostic RSA — caller picks the hash via `hashAlgo`.
|
||||
'1.2.840.113549.1.1.1': { family: 'RSA', boundDigest: null }, // rsaEncryption
|
||||
'1.2.840.113549.1.1.10': { family: 'RSA', boundDigest: null }, // RSASSA-PSS
|
||||
|
||||
// Hash-bound legacy RSA combos.
|
||||
'1.2.840.113549.1.1.4': { family: 'RSA', boundDigest: 'MD5' }, // md5WithRSAEncryption
|
||||
'1.2.840.113549.1.1.5': { family: 'RSA', boundDigest: 'SHA-1' }, // sha1WithRSAEncryption
|
||||
'1.2.840.113549.1.1.11': { family: 'RSA', boundDigest: 'SHA-256' }, // sha256WithRSAEncryption
|
||||
'1.2.840.113549.1.1.12': { family: 'RSA', boundDigest: 'SHA-384' }, // sha384WithRSAEncryption
|
||||
'1.2.840.113549.1.1.13': { family: 'RSA', boundDigest: 'SHA-512' }, // sha512WithRSAEncryption
|
||||
|
||||
// ECDSA with SHA-x (hash is always bound).
|
||||
'1.2.840.10045.4.1': { family: 'ECDSA', boundDigest: 'SHA-1' }, // ecdsa-with-SHA1
|
||||
'1.2.840.10045.4.3.2': { family: 'ECDSA', boundDigest: 'SHA-256' },
|
||||
'1.2.840.10045.4.3.3': { family: 'ECDSA', boundDigest: 'SHA-384' },
|
||||
'1.2.840.10045.4.3.4': { family: 'ECDSA', boundDigest: 'SHA-512' },
|
||||
|
||||
// DSA — refused outright.
|
||||
'1.2.840.10040.4.1': { family: 'DSA' },
|
||||
'1.2.840.10040.4.3': { family: 'DSA' }, // dsa-with-SHA1
|
||||
};
|
||||
|
||||
/**
|
||||
* ECDSA named-curve OID registry. Policy verdict (allow/refuse) is decided
|
||||
* by the resolver from the resolved curve name, not encoded here.
|
||||
*/
|
||||
const CURVE_OID_REGISTRY: Record<string, CscEcdsaCurve | 'P-192' | 'P-224'> = {
|
||||
'1.2.840.10045.3.1.7': 'P-256', // secp256r1
|
||||
'1.3.132.0.34': 'P-384', // secp384r1
|
||||
'1.3.132.0.35': 'P-521', // secp521r1
|
||||
'1.2.840.10045.3.1.1': 'P-192', // secp192r1
|
||||
'1.3.132.0.33': 'P-224', // secp224r1
|
||||
};
|
||||
|
||||
/**
|
||||
* Validate a CSC credential's cert + key/algorithm against V1 policy and
|
||||
* resolve the `(signAlgo, hashAlgo)` OID pair used by `signatures/signHash`.
|
||||
*
|
||||
* Caller MUST fetch the credential with `certInfo: true` so `cert.validFrom`
|
||||
* / `cert.validTo` are present.
|
||||
*
|
||||
* Throws:
|
||||
* - `CSC_CERT_INVALID` for cert-state issues (status not `valid`, missing or
|
||||
* malformed validity dates, current time outside the validity window).
|
||||
* - `CSC_ALGORITHM_REFUSED` for key/algorithm policy failures (disabled key,
|
||||
* missing `key.len`, RSA `< 2048`, ECDSA without an allowed curve, DSA, no
|
||||
* acceptable digest advertised in `key.algo`).
|
||||
*/
|
||||
export const resolveCscAlgorithmPolicy = (credentialInfo: TCscCredentialsInfoResponse): CscAlgorithmPolicy => {
|
||||
assertCertValid(credentialInfo.cert);
|
||||
|
||||
if (credentialInfo.key.status !== 'enabled') {
|
||||
throw new AppError(AppErrorCode.CSC_ALGORITHM_REFUSED, {
|
||||
message: `CSC credential key status is '${credentialInfo.key.status}'.`,
|
||||
});
|
||||
}
|
||||
|
||||
if (credentialInfo.key.len === undefined) {
|
||||
throw new AppError(AppErrorCode.CSC_ALGORITHM_REFUSED, {
|
||||
message: 'CSC credential omits required key.len (REQUIRED per §11.5).',
|
||||
});
|
||||
}
|
||||
|
||||
const choice = pickAlgorithmChoice(credentialInfo.key.algo);
|
||||
|
||||
if (choice.family === 'RSA') {
|
||||
if (credentialInfo.key.len < 2048) {
|
||||
throw new AppError(AppErrorCode.CSC_ALGORITHM_REFUSED, {
|
||||
message: `CSC RSA credential keyLen ${credentialInfo.key.len} < 2048.`,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
keyType: 'RSA',
|
||||
keyLenBits: credentialInfo.key.len,
|
||||
digestAlgorithm: choice.digest,
|
||||
signAlgoOid: choice.signAlgoOid,
|
||||
hashAlgoOid: HASH_OID_FOR_DIGEST[choice.digest],
|
||||
};
|
||||
}
|
||||
|
||||
const curve = resolveEcdsaCurve(credentialInfo.key.curve);
|
||||
|
||||
return {
|
||||
keyType: 'ECDSA',
|
||||
keyLenBits: credentialInfo.key.len,
|
||||
digestAlgorithm: choice.digest,
|
||||
signAlgoOid: choice.signAlgoOid,
|
||||
hashAlgoOid: HASH_OID_FOR_DIGEST[choice.digest],
|
||||
ecdsaCurve: curve,
|
||||
};
|
||||
};
|
||||
|
||||
type AlgorithmChoice = {
|
||||
family: 'RSA' | 'ECDSA';
|
||||
signAlgoOid: string;
|
||||
digest: CscDigest;
|
||||
};
|
||||
|
||||
/**
|
||||
* Iterate the TSP's advertised `key.algo` OIDs, drop the policy-refused
|
||||
* entries, and pick the strongest survivor.
|
||||
*
|
||||
* Precedence: ECDSA before RSA (smaller signatures, modern); within each
|
||||
* family, strongest advertised digest first. Hash-agnostic RSA OIDs pair
|
||||
* with {@link DEFAULT_RSA_DIGEST}.
|
||||
*/
|
||||
const pickAlgorithmChoice = (algoOids: readonly string[]): AlgorithmChoice => {
|
||||
const candidates: AlgorithmChoice[] = [];
|
||||
|
||||
for (const oid of algoOids) {
|
||||
const info = KEY_ALGO_OID_REGISTRY[oid];
|
||||
|
||||
if (info === undefined) {
|
||||
// Unknown OID — another entry in `key.algo` may still be acceptable.
|
||||
continue;
|
||||
}
|
||||
|
||||
if (info.family === 'DSA') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (info.boundDigest === null) {
|
||||
candidates.push({
|
||||
family: info.family,
|
||||
signAlgoOid: oid,
|
||||
digest: DEFAULT_RSA_DIGEST,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (STRONG_DIGEST_SET.has(info.boundDigest)) {
|
||||
candidates.push({
|
||||
family: info.family,
|
||||
signAlgoOid: oid,
|
||||
digest: info.boundDigest as CscDigest,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (candidates.length === 0) {
|
||||
throw new AppError(AppErrorCode.CSC_ALGORITHM_REFUSED, {
|
||||
message: `CSC credential advertises no policy-compliant key.algo OIDs (got: ${algoOids.join(', ') || '<empty>'}).`,
|
||||
});
|
||||
}
|
||||
|
||||
candidates.sort((a, b) => {
|
||||
if (a.family !== b.family) {
|
||||
return a.family === 'ECDSA' ? -1 : 1;
|
||||
}
|
||||
|
||||
return DIGEST_STRENGTH[b.digest] - DIGEST_STRENGTH[a.digest];
|
||||
});
|
||||
|
||||
return candidates[0];
|
||||
};
|
||||
|
||||
const resolveEcdsaCurve = (curveOid: string | undefined): CscEcdsaCurve => {
|
||||
if (curveOid === undefined || curveOid === '') {
|
||||
throw new AppError(AppErrorCode.CSC_ALGORITHM_REFUSED, {
|
||||
message: 'CSC ECDSA credential omits required key.curve.',
|
||||
});
|
||||
}
|
||||
|
||||
const named = CURVE_OID_REGISTRY[curveOid];
|
||||
|
||||
if (named === 'P-256' || named === 'P-384' || named === 'P-521') {
|
||||
return named;
|
||||
}
|
||||
|
||||
const detail = named ? `, named=${named}` : '';
|
||||
|
||||
throw new AppError(AppErrorCode.CSC_ALGORITHM_REFUSED, {
|
||||
message: `CSC ECDSA credential uses refused curve (oid=${curveOid}${detail}).`,
|
||||
});
|
||||
};
|
||||
|
||||
const assertCertValid = (cert: TCscCredentialsInfoResponse['cert']): void => {
|
||||
if (cert.status !== undefined && cert.status !== 'valid') {
|
||||
throw new AppError(AppErrorCode.CSC_CERT_INVALID, {
|
||||
message: `CSC credential certificate status is '${cert.status}'.`,
|
||||
});
|
||||
}
|
||||
|
||||
if (!cert.validFrom || !cert.validTo) {
|
||||
throw new AppError(AppErrorCode.CSC_CERT_INVALID, {
|
||||
message: 'CSC credential certificate omits validFrom/validTo (malformed).',
|
||||
});
|
||||
}
|
||||
|
||||
const validFromMs = parseGeneralizedTime(cert.validFrom);
|
||||
const validToMs = parseGeneralizedTime(cert.validTo);
|
||||
|
||||
if (validFromMs === null || validToMs === null) {
|
||||
throw new AppError(AppErrorCode.CSC_CERT_INVALID, {
|
||||
message: `CSC credential certificate validity dates are malformed (validFrom=${cert.validFrom}, validTo=${cert.validTo}).`,
|
||||
});
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
if (now < validFromMs) {
|
||||
throw new AppError(AppErrorCode.CSC_CERT_INVALID, {
|
||||
message: `CSC credential certificate is not yet valid (validFrom=${cert.validFrom}).`,
|
||||
});
|
||||
}
|
||||
|
||||
if (now > validToMs) {
|
||||
throw new AppError(AppErrorCode.CSC_CERT_INVALID, {
|
||||
message: `CSC credential certificate has expired (validTo=${cert.validTo}).`,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse an X.509 GeneralizedTime string (`YYYYMMDDHHMMSSZ`) into epoch ms.
|
||||
* Strict — returns null on any deviation from the §11.5 example format.
|
||||
*/
|
||||
const parseGeneralizedTime = (value: string): number | null => {
|
||||
const matched = /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})Z$/.exec(value);
|
||||
|
||||
if (matched === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [, y, mo, d, h, mi, s] = matched;
|
||||
|
||||
const ms = Date.UTC(Number(y), Number(mo) - 1, Number(d), Number(h), Number(mi), Number(s));
|
||||
|
||||
return Number.isNaN(ms) ? null : ms;
|
||||
};
|
||||
|
||||
/**
|
||||
* Subset of libpdf's `Signer` interface fields derived from a `CscAlgorithmPolicy`.
|
||||
* Used by `CscCaptureSigner` / `CscFifoSigner` to satisfy libpdf's signer
|
||||
* contract without re-deriving the mapping at each call site. `keyLenBits`
|
||||
* is carried through so the capture-signer can size its placeholder output
|
||||
* appropriately for the chosen key.
|
||||
*/
|
||||
export type LibpdfSignerAlgo = {
|
||||
keyType: 'RSA' | 'EC';
|
||||
signatureAlgorithm: 'RSASSA-PKCS1-v1_5' | 'RSA-PSS' | 'ECDSA';
|
||||
digestAlgorithm: CscDigest;
|
||||
keyLenBits: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Translate a `CscAlgorithmPolicy` (CSC §11.5 OIDs) into libpdf's `Signer`
|
||||
* algorithm tuple. RSASSA-PSS is detected by the `signAlgoOid`; everything
|
||||
* else maps directly from `keyType` + `digestAlgorithm`.
|
||||
*/
|
||||
export const policyToLibpdfSignerAlgo = (policy: CscAlgorithmPolicy): LibpdfSignerAlgo => {
|
||||
if (policy.keyType === 'ECDSA') {
|
||||
return {
|
||||
keyType: 'EC',
|
||||
signatureAlgorithm: 'ECDSA',
|
||||
digestAlgorithm: policy.digestAlgorithm,
|
||||
keyLenBits: policy.keyLenBits,
|
||||
};
|
||||
}
|
||||
|
||||
// RSA — distinguish PKCS1-v1.5 from PSS by the resolved sign-algo OID.
|
||||
// RSASSA-PSS OID: '1.2.840.113549.1.1.10'.
|
||||
const signatureAlgorithm: 'RSASSA-PKCS1-v1_5' | 'RSA-PSS' =
|
||||
policy.signAlgoOid === '1.2.840.113549.1.1.10' ? 'RSA-PSS' : 'RSASSA-PKCS1-v1_5';
|
||||
|
||||
return {
|
||||
keyType: 'RSA',
|
||||
signatureAlgorithm,
|
||||
digestAlgorithm: policy.digestAlgorithm,
|
||||
keyLenBits: policy.keyLenBits,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,122 @@
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
|
||||
/**
|
||||
* Length-prefixed X.509 chain for `CscCredential.certCache`. Schema column is
|
||||
* `Bytes`; this gives a self-describing binary that round-trips without
|
||||
* base64 inflation. Format: u32 BE cert count, then per-cert u32 BE byte
|
||||
* length + raw DER bytes.
|
||||
*
|
||||
* Encoding inputs come from `cscCredentialsInfo.cert.certificates`, which the
|
||||
* CSC §11.5 spec defines as an array of base64-encoded DER X.509 certificates
|
||||
* (leaf-first). The encoder decodes each base64 entry once at persistence
|
||||
* time; the decoder is the symmetric inverse used at sign time.
|
||||
*
|
||||
* Pure functions, no I/O.
|
||||
*/
|
||||
|
||||
const BASE64_REGEX = /^[A-Za-z0-9+/]+={0,2}$/;
|
||||
|
||||
/**
|
||||
* Encode a leaf-first chain of base64-encoded DER certs into the
|
||||
* length-prefixed binary form persisted on `CscCredential.certCache`.
|
||||
*
|
||||
* Throws `INVALID_REQUEST` when the input is empty or any entry is not valid
|
||||
* base64.
|
||||
*/
|
||||
export const encodeCscCertChain = (certs: string[]): Uint8Array => {
|
||||
if (certs.length === 0) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'CSC certificate chain encoding requires at least one certificate.',
|
||||
});
|
||||
}
|
||||
|
||||
const derBuffers: Uint8Array[] = [];
|
||||
let totalDerBytes = 0;
|
||||
|
||||
for (const entry of certs) {
|
||||
if (entry.length === 0 || !BASE64_REGEX.test(entry)) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'CSC certificate chain entry is not valid base64.',
|
||||
});
|
||||
}
|
||||
|
||||
const der = Buffer.from(entry, 'base64');
|
||||
|
||||
if (der.length === 0) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'CSC certificate chain entry decoded to zero bytes.',
|
||||
});
|
||||
}
|
||||
|
||||
derBuffers.push(der);
|
||||
totalDerBytes += der.length;
|
||||
}
|
||||
|
||||
// 4 bytes for the count + 4 bytes per-cert length prefix + raw DER bytes.
|
||||
const totalLength = 4 + derBuffers.length * 4 + totalDerBytes;
|
||||
const out = new Uint8Array(totalLength);
|
||||
const view = new DataView(out.buffer, out.byteOffset, out.byteLength);
|
||||
|
||||
view.setUint32(0, derBuffers.length, false);
|
||||
|
||||
let offset = 4;
|
||||
|
||||
for (const der of derBuffers) {
|
||||
view.setUint32(offset, der.length, false);
|
||||
offset += 4;
|
||||
out.set(der, offset);
|
||||
offset += der.length;
|
||||
}
|
||||
|
||||
return out;
|
||||
};
|
||||
|
||||
/**
|
||||
* Decode a length-prefixed cert chain back into an array of DER cert byte
|
||||
* arrays. Inverse of {@link encodeCscCertChain}.
|
||||
*
|
||||
* Throws `INVALID_REQUEST` when the buffer is truncated or any per-cert
|
||||
* length prefix runs off the end of the buffer.
|
||||
*/
|
||||
export const decodeCscCertChain = (bytes: Uint8Array): Uint8Array[] => {
|
||||
if (bytes.byteLength < 4) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'CSC certificate chain buffer is too short to contain a count prefix.',
|
||||
});
|
||||
}
|
||||
|
||||
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||
const count = view.getUint32(0, false);
|
||||
|
||||
const result: Uint8Array[] = [];
|
||||
let offset = 4;
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
if (offset + 4 > bytes.byteLength) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'CSC certificate chain buffer truncated at length prefix.',
|
||||
});
|
||||
}
|
||||
|
||||
const length = view.getUint32(offset, false);
|
||||
offset += 4;
|
||||
|
||||
if (length === 0 || offset + length > bytes.byteLength) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'CSC certificate chain buffer truncated within certificate body.',
|
||||
});
|
||||
}
|
||||
|
||||
// Slice copies the underlying bytes so callers can't mutate the source.
|
||||
result.push(bytes.slice(offset, offset + length));
|
||||
offset += length;
|
||||
}
|
||||
|
||||
if (offset !== bytes.byteLength) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'CSC certificate chain buffer has trailing bytes after declared chain end.',
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
@@ -0,0 +1,51 @@
|
||||
import { symmetricDecrypt, symmetricEncrypt } from '@documenso/lib/universal/crypto';
|
||||
import { requireEnv } from '@documenso/lib/utils/env';
|
||||
import { bytesToHex, hexToBytes } from '@noble/ciphers/utils';
|
||||
|
||||
/**
|
||||
* Bytes-based wrappers around {@link symmetricEncrypt} / {@link symmetricDecrypt}
|
||||
* for the two CSC secrets stored on Prisma `Bytes` columns:
|
||||
*
|
||||
* - `CscCredential.serviceTokenCiphertext` — service-scope OAuth access token.
|
||||
* - `CscSession.encryptedSad` — credential-scope SAD.
|
||||
*
|
||||
* Both use the primary `DOCUMENSO_ENCRYPTION_KEY` (same key family as 2FA
|
||||
* secrets, OIDC client secrets, DKIM private keys). The underlying cipher
|
||||
* returns hex; we round-trip through `bytesToHex` / `hexToBytes` so the
|
||||
* persisted bytes are the raw XChaCha20-Poly1305 ciphertext (nonce + tag +
|
||||
* payload), not a hex-string-as-bytes inflation.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Encrypt a CSC plaintext secret (service token or SAD) for persistence.
|
||||
* Throws `MISSING_ENV_VAR` on missing encryption key — encryption can't
|
||||
* otherwise fail.
|
||||
*/
|
||||
export const encryptCscToken = (plaintext: string): Uint8Array => {
|
||||
const key = requireEnv('NEXT_PRIVATE_ENCRYPTION_KEY');
|
||||
|
||||
const hex = symmetricEncrypt({ key, data: plaintext });
|
||||
|
||||
return hexToBytes(hex);
|
||||
};
|
||||
|
||||
/**
|
||||
* Decrypt a CSC ciphertext back to its UTF-8 plaintext. Returns `null` on
|
||||
* any cipher-level failure (key rotation, payload tamper, row corruption)
|
||||
* so the caller can map to a domain-appropriate AppError — typically
|
||||
* re-auth for service tokens, `CSC_SAD_EXPIRED_PRE_SIGN` for SADs.
|
||||
*
|
||||
* A missing key throws (config error, must surface loudly) and is *not*
|
||||
* folded into the null return.
|
||||
*/
|
||||
export const decryptCscToken = (ciphertext: Uint8Array): string | null => {
|
||||
const key = requireEnv('NEXT_PRIVATE_ENCRYPTION_KEY');
|
||||
|
||||
try {
|
||||
const buf = symmetricDecrypt({ key, data: bytesToHex(ciphertext) });
|
||||
|
||||
return Buffer.from(buf).toString('utf-8');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,122 @@
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
|
||||
import { cscJsonPost, joinCscUrl } from './http';
|
||||
import {
|
||||
type TCscCredentialsInfoRequest,
|
||||
type TCscCredentialsInfoResponse,
|
||||
type TCscCredentialsListRequest,
|
||||
type TCscCredentialsListResponse,
|
||||
ZCscCredentialsInfoResponseSchema,
|
||||
ZCscCredentialsListResponseSchema,
|
||||
} from './types';
|
||||
|
||||
type CscCredentialsListOptions = TCscCredentialsListRequest & {
|
||||
baseUrl: string;
|
||||
/** Service-scope bearer token (CSC §11.4 + §11.9). */
|
||||
accessToken: string;
|
||||
signal?: AbortSignal;
|
||||
};
|
||||
|
||||
/**
|
||||
* `credentials/list` (§11.4) — list the credentialIDs the bearer token's user
|
||||
* owns at the TSP.
|
||||
*
|
||||
* Throws `CSC_CREDENTIAL_LIST_EMPTY` when the TSP returns a successful
|
||||
* response with zero credentials — the recipient needs to enrol with the TSP
|
||||
* before they can sign. Other failures throw `CSC_REQUEST_FAILED`.
|
||||
*
|
||||
* `userID` MUST be omitted when the service authorization is user-specific
|
||||
* (true for OAuth `service` scope, which is V1's only flow). The spec rejects
|
||||
* the call with `invalid_request` if both are present.
|
||||
*/
|
||||
export const cscCredentialsList = async (opts: CscCredentialsListOptions): Promise<TCscCredentialsListResponse> => {
|
||||
const { baseUrl, accessToken, signal, userID, maxResults, pageToken, clientData } = opts;
|
||||
|
||||
const body: Record<string, unknown> = {};
|
||||
|
||||
if (userID !== undefined) {
|
||||
body.userID = userID;
|
||||
}
|
||||
|
||||
if (maxResults !== undefined) {
|
||||
body.maxResults = maxResults;
|
||||
}
|
||||
|
||||
if (pageToken !== undefined) {
|
||||
body.pageToken = pageToken;
|
||||
}
|
||||
|
||||
if (clientData !== undefined) {
|
||||
body.clientData = clientData;
|
||||
}
|
||||
|
||||
const response = await cscJsonPost(
|
||||
{
|
||||
url: joinCscUrl({ baseUrl, path: 'credentials/list' }),
|
||||
body,
|
||||
accessToken,
|
||||
signal,
|
||||
},
|
||||
ZCscCredentialsListResponseSchema,
|
||||
);
|
||||
|
||||
if (response.credentialIDs.length === 0) {
|
||||
throw new AppError(AppErrorCode.CSC_CREDENTIAL_LIST_EMPTY, {
|
||||
message:
|
||||
'CSC provider returned no credentials for the authenticated user. Recipient must enrol with the TSP before signing.',
|
||||
});
|
||||
}
|
||||
|
||||
return response;
|
||||
};
|
||||
|
||||
type CscCredentialsInfoOptions = TCscCredentialsInfoRequest & {
|
||||
baseUrl: string;
|
||||
/** Service-scope bearer token. */
|
||||
accessToken: string;
|
||||
signal?: AbortSignal;
|
||||
};
|
||||
|
||||
/**
|
||||
* `credentials/info` (§11.5) — fetch credential metadata: key algorithm tuple,
|
||||
* X.509 certificate chain, authorization mode, multisign capacity.
|
||||
*
|
||||
* Returns the parsed response verbatim. Cert validity, algorithm policy, and
|
||||
* SCAL semantics are enforced by `csc/algorithm-resolver.ts` — that lives
|
||||
* outside the client because it's domain logic, not transport.
|
||||
*/
|
||||
export const cscCredentialsInfo = async (opts: CscCredentialsInfoOptions): Promise<TCscCredentialsInfoResponse> => {
|
||||
const { baseUrl, accessToken, signal, credentialID, certificates, certInfo, authInfo, lang, clientData } = opts;
|
||||
|
||||
const body: Record<string, unknown> = { credentialID };
|
||||
|
||||
if (certificates !== undefined) {
|
||||
body.certificates = certificates;
|
||||
}
|
||||
|
||||
if (certInfo !== undefined) {
|
||||
body.certInfo = certInfo;
|
||||
}
|
||||
|
||||
if (authInfo !== undefined) {
|
||||
body.authInfo = authInfo;
|
||||
}
|
||||
|
||||
if (lang !== undefined) {
|
||||
body.lang = lang;
|
||||
}
|
||||
|
||||
if (clientData !== undefined) {
|
||||
body.clientData = clientData;
|
||||
}
|
||||
|
||||
return await cscJsonPost(
|
||||
{
|
||||
url: joinCscUrl({ baseUrl, path: 'credentials/info' }),
|
||||
body,
|
||||
accessToken,
|
||||
signal,
|
||||
},
|
||||
ZCscCredentialsInfoResponseSchema,
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,170 @@
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import type { z } from 'zod';
|
||||
|
||||
import { ZCscErrorResponseSchema } from './types';
|
||||
|
||||
const LEADING_SLASHES_REGEX = /^\/+/;
|
||||
const TRAILING_SLASHES_REGEX = /\/+$/;
|
||||
|
||||
/**
|
||||
* Low-level fetch wrapper for the JSON-bodied CSC API methods (§7.1 mandates
|
||||
* `Content-Type: application/json` for all API requests).
|
||||
*
|
||||
* OAuth 2.0 endpoints (`oauth2/token`, `oauth2/revoke`) use
|
||||
* `application/x-www-form-urlencoded` per RFC 6749 and are handled by the
|
||||
* `arctic` library — see `oauth.ts` in this directory.
|
||||
*
|
||||
* Normalises CSC error responses (§10.1: `{ error, error_description }`)
|
||||
* into {@link AppError}s carrying the upstream HTTP status in
|
||||
* {@link AppError.statusCode}, so callers can discriminate without
|
||||
* re-parsing the body.
|
||||
*/
|
||||
|
||||
type JoinUrlInput = {
|
||||
baseUrl: string;
|
||||
path: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Join a CSC base URL with a path segment. Strips trailing/leading slashes so
|
||||
* `joinCscUrl({ baseUrl: 'https://x/csc/v1/', path: '/credentials/list' })`
|
||||
* yields `https://x/csc/v1/credentials/list`.
|
||||
*/
|
||||
export const joinCscUrl = ({ baseUrl, path }: JoinUrlInput): string => {
|
||||
const cleanBaseUrl = baseUrl.replace(TRAILING_SLASHES_REGEX, ''); // Strip trailing slashes from base URL.
|
||||
const cleanPath = path.replace(LEADING_SLASHES_REGEX, ''); // Strip leading slashes from path.
|
||||
|
||||
const url = new URL(cleanPath, `${cleanBaseUrl}/`);
|
||||
|
||||
return url.toString();
|
||||
};
|
||||
|
||||
type CscRequestErrorOptions = {
|
||||
url: string;
|
||||
status: number;
|
||||
cscError?: { error: string; error_description?: string };
|
||||
cause?: unknown;
|
||||
errorCode?: string;
|
||||
};
|
||||
|
||||
const buildCscRequestError = ({
|
||||
url,
|
||||
status,
|
||||
cscError,
|
||||
cause,
|
||||
errorCode = AppErrorCode.CSC_REQUEST_FAILED,
|
||||
}: CscRequestErrorOptions): AppError => {
|
||||
const causeMessage = cause instanceof Error ? cause.message : undefined;
|
||||
|
||||
const parts: string[] = [`CSC request to ${url} failed (HTTP ${status})`];
|
||||
|
||||
if (cscError) {
|
||||
parts.push(cscError.error_description ? `${cscError.error}: ${cscError.error_description}` : cscError.error);
|
||||
}
|
||||
|
||||
if (causeMessage) {
|
||||
parts.push(causeMessage);
|
||||
}
|
||||
|
||||
return new AppError(errorCode, {
|
||||
message: parts.join(' — '),
|
||||
statusCode: status,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Best-effort parse of a CSC error body. Returns `undefined` on non-JSON or
|
||||
* schema mismatch so the caller still surfaces the HTTP status without
|
||||
* masking it.
|
||||
*/
|
||||
const readCscErrorBody = async (
|
||||
response: Response,
|
||||
): Promise<{ error: string; error_description?: string } | undefined> => {
|
||||
try {
|
||||
const json = await response.json();
|
||||
const parsed = ZCscErrorResponseSchema.safeParse(json);
|
||||
|
||||
return parsed.success ? parsed.data : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
type CscJsonPostOptions = {
|
||||
/** Fully-qualified endpoint URL (use {@link joinCscUrl} to build it). */
|
||||
url: string;
|
||||
/** Decoded JSON body; serialised via `JSON.stringify`. */
|
||||
body: Record<string, unknown>;
|
||||
/** Bearer access token. Omit for unauthenticated calls (e.g. `info`). */
|
||||
accessToken?: string;
|
||||
/** Override the AppError code thrown on failure. Defaults to `CSC_REQUEST_FAILED`. */
|
||||
errorCode?: string;
|
||||
/**
|
||||
* Optional `AbortSignal` so callers can enforce their own deadlines
|
||||
* (e.g. the 15s sign-time sync timeout).
|
||||
*/
|
||||
signal?: AbortSignal;
|
||||
};
|
||||
|
||||
/**
|
||||
* POST a JSON body to a CSC API endpoint and parse the response against the
|
||||
* supplied Zod schema.
|
||||
*
|
||||
* Throws {@link AppError} on:
|
||||
* - network/transport error (fetch threw)
|
||||
* - non-2xx HTTP response (with CSC error body folded into the message)
|
||||
* - malformed JSON response
|
||||
* - schema validation failure
|
||||
*/
|
||||
export const cscJsonPost = async <T>(opts: CscJsonPostOptions, responseSchema: z.ZodSchema<T>): Promise<T> => {
|
||||
const { url, body, accessToken, errorCode, signal } = opts;
|
||||
|
||||
let response: Response;
|
||||
|
||||
try {
|
||||
response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
signal,
|
||||
});
|
||||
} catch (cause) {
|
||||
throw buildCscRequestError({ url, status: 0, cause, errorCode });
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const cscError = await readCscErrorBody(response);
|
||||
|
||||
throw buildCscRequestError({
|
||||
url,
|
||||
status: response.status,
|
||||
cscError,
|
||||
errorCode,
|
||||
});
|
||||
}
|
||||
|
||||
let json: unknown;
|
||||
|
||||
try {
|
||||
json = await response.json();
|
||||
} catch (cause) {
|
||||
throw buildCscRequestError({ url, status: response.status, cause, errorCode });
|
||||
}
|
||||
|
||||
const parsed = responseSchema.safeParse(json);
|
||||
|
||||
if (!parsed.success) {
|
||||
throw buildCscRequestError({
|
||||
url,
|
||||
status: response.status,
|
||||
cause: parsed.error,
|
||||
errorCode,
|
||||
});
|
||||
}
|
||||
|
||||
return parsed.data;
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* CSC v1.0.4.0 HTTP client. Stateless function wrappers — one per endpoint,
|
||||
* grouped by spec section. Bring your own base URL(s) and bearer token.
|
||||
*
|
||||
* Endpoint coverage (V1 scope):
|
||||
* - §11.1 info → {@link cscInfo}
|
||||
* - §11.4 credentials/list → {@link cscCredentialsList}
|
||||
* - §11.5 credentials/info → {@link cscCredentialsInfo}
|
||||
* - §11.9 signatures/signHash → {@link cscSignHash}
|
||||
* - §11.10 signatures/timestamp → {@link cscTimestamp}
|
||||
* - §8.3.2 oauth2/authorize → {@link buildCscServiceScopeAuthorizeUrl},
|
||||
* {@link buildCscCredentialScopeAuthorizeUrl}
|
||||
* - §8.3.3 oauth2/token → {@link exchangeCscAuthorizationCode},
|
||||
* {@link refreshCscServiceToken}
|
||||
* - §8.3.4 oauth2/revoke → {@link revokeCscToken}
|
||||
*
|
||||
* Out of scope for V1 (intentionally excluded; we use OAuth + single-sig):
|
||||
* - §11.2 auth/login (HTTP Basic)
|
||||
* - §11.3 auth/revoke (HTTP Basic)
|
||||
* - §11.6 credentials/authorize (alternative to OAuth credential scope)
|
||||
* - §11.7 credentials/extendTransaction
|
||||
* - §11.8 credentials/sendOTP
|
||||
*
|
||||
* OAuth is delegated to `arctic` (same library `packages/auth/` uses).
|
||||
*/
|
||||
|
||||
export * from './credentials';
|
||||
export * from './http';
|
||||
export * from './info';
|
||||
export * from './oauth';
|
||||
export * from './signatures';
|
||||
export * from './types';
|
||||
@@ -0,0 +1,42 @@
|
||||
import { AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
|
||||
import { cscJsonPost, joinCscUrl } from './http';
|
||||
import { type TCscInfoRequest, type TCscInfoResponse, ZCscInfoResponseSchema } from './types';
|
||||
|
||||
type CscInfoOptions = TCscInfoRequest & {
|
||||
/**
|
||||
* Base URI of the CSC service (e.g. `https://service.example.org/csc/v1`).
|
||||
* Per §7.2, `info` is mounted relative to the service base URI; the OAuth
|
||||
* base URI returned in `oauth2` is discovered from this call.
|
||||
*/
|
||||
baseUrl: string;
|
||||
signal?: AbortSignal;
|
||||
};
|
||||
|
||||
/**
|
||||
* `info` (§11.1) — discovery method every CSC-conformant TSP MUST implement.
|
||||
*
|
||||
* Used at startup to:
|
||||
*
|
||||
* 1. Learn the OAuth 2.0 base URI (`oauth2`) for subsequent token / revoke
|
||||
* calls. Per §11.1, this MAY differ from the API base URI.
|
||||
* 2. Enumerate supported methods (`methods`) so the caller can fail fast
|
||||
* when a required endpoint is absent.
|
||||
* 3. Surface `signatures/timestamp` capability for the B-LTA seal step.
|
||||
*
|
||||
* Unauthenticated — `info` requires no bearer token. Failures throw
|
||||
* `CSC_PROVIDER_INFO_FAILED` per the spec's startup-discovery error code.
|
||||
*/
|
||||
export const cscInfo = async (opts: CscInfoOptions): Promise<TCscInfoResponse> => {
|
||||
const { baseUrl, lang, signal } = opts;
|
||||
|
||||
return await cscJsonPost(
|
||||
{
|
||||
url: joinCscUrl({ baseUrl, path: 'info' }),
|
||||
body: lang ? { lang } : {},
|
||||
errorCode: AppErrorCode.CSC_PROVIDER_INFO_FAILED,
|
||||
signal,
|
||||
},
|
||||
ZCscInfoResponseSchema,
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,321 @@
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import {
|
||||
ArcticFetchError,
|
||||
CodeChallengeMethod,
|
||||
generateCodeVerifier,
|
||||
generateState,
|
||||
OAuth2Client,
|
||||
OAuth2RequestError,
|
||||
type OAuth2Tokens,
|
||||
UnexpectedErrorResponseBodyError,
|
||||
UnexpectedResponseError,
|
||||
} from 'arctic';
|
||||
|
||||
import { joinCscUrl } from './http';
|
||||
|
||||
/**
|
||||
* OAuth 2.0 surface for the CSC v1.0.4.0 protocol (§8.3.2 authorize,
|
||||
* §8.3.3 token, §8.3.4 revoke).
|
||||
*
|
||||
* Backed by `arctic` — the same library `packages/auth/` uses for sign-in
|
||||
* OAuth — so PKCE + state generation, token parsing, and revocation share a
|
||||
* proven implementation. CSC-specific extension parameters (`credentialID`,
|
||||
* `numSignatures`, `hash`, `description`, `account_token`, `clientData`,
|
||||
* `lang` — §8.3.2) layer on top of the returned `URL` via
|
||||
* `searchParams.set()`.
|
||||
*
|
||||
* Non-standard CSC bits arctic doesn't model directly:
|
||||
* - `token_type === 'SAD'` for credential-scope responses (§8.3.3). Read from
|
||||
* `tokens.tokenType()` which sources from raw `data`.
|
||||
* - SAD is single-use and short-lived per spec; no refresh_token is issued
|
||||
* for the credential scope. Callers SHOULD NOT call `refreshAccessToken`
|
||||
* with a SAD.
|
||||
*
|
||||
* Re-exports `generateState` and `generateCodeVerifier` for callers that
|
||||
* persist these in the OAuth-flow cookie.
|
||||
*/
|
||||
|
||||
export { generateCodeVerifier, generateState };
|
||||
|
||||
// ─── Client construction ─────────────────────────────────────────────────────
|
||||
|
||||
type CreateCscOAuthClientOptions = {
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
redirectUri: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Construct an `OAuth2Client` bound to the CSC TSP's OAuth registration. The
|
||||
* three values come from the env (`NEXT_PRIVATE_SIGNING_CSC_OAUTH_*`).
|
||||
* Stateless — instantiate per request or cache at the transport singleton
|
||||
* level; arctic's client carries no per-call state.
|
||||
*/
|
||||
export const createCscOAuthClient = ({
|
||||
clientId,
|
||||
clientSecret,
|
||||
redirectUri,
|
||||
}: CreateCscOAuthClientOptions): OAuth2Client => {
|
||||
return new OAuth2Client(clientId, clientSecret, redirectUri);
|
||||
};
|
||||
|
||||
// ─── Authorize URL builders (§8.3.2) ─────────────────────────────────────────
|
||||
|
||||
type AuthorizeUrlBaseOptions = {
|
||||
client: OAuth2Client;
|
||||
/**
|
||||
* The TSP's OAuth base URI as returned by `info.oauth2` (§11.1). The
|
||||
* `oauth2/authorize` path is joined on; per §8.3.2 NOTE 1 this can live on
|
||||
* a different host from the API base URI.
|
||||
*/
|
||||
oauthBaseUrl: string;
|
||||
/** Opaque CSRF token; see {@link generateState}. Caller persists it. */
|
||||
state: string;
|
||||
/** PKCE verifier; see {@link generateCodeVerifier}. Caller persists it. */
|
||||
codeVerifier: string;
|
||||
/** Preferred response language (§11.1 `lang` parameter). */
|
||||
lang?: string;
|
||||
/**
|
||||
* Arbitrary application-defined string echoed back at callback. WARNING per
|
||||
* §8.3.2: this is forwarded verbatim to the TSP; never put secrets here.
|
||||
*/
|
||||
clientData?: string;
|
||||
};
|
||||
|
||||
const applyCscAuthorizeExtras = (url: URL, opts: { lang?: string; clientData?: string }): URL => {
|
||||
if (opts.lang) {
|
||||
url.searchParams.set('lang', opts.lang);
|
||||
}
|
||||
|
||||
if (opts.clientData) {
|
||||
url.searchParams.set('clientData', opts.clientData);
|
||||
}
|
||||
|
||||
return url;
|
||||
};
|
||||
|
||||
/**
|
||||
* Build the `oauth2/authorize` URL for the **service** scope. Recipient
|
||||
* follows this URL to authenticate at the TSP and grant access to list
|
||||
* credentials + fetch credential info.
|
||||
*/
|
||||
export const buildCscServiceScopeAuthorizeUrl = (opts: AuthorizeUrlBaseOptions): URL => {
|
||||
const { client, oauthBaseUrl, state, codeVerifier, lang, clientData } = opts;
|
||||
|
||||
const url = client.createAuthorizationURLWithPKCE(
|
||||
joinCscUrl({ baseUrl: oauthBaseUrl, path: 'oauth2/authorize' }),
|
||||
state,
|
||||
CodeChallengeMethod.S256,
|
||||
codeVerifier,
|
||||
['service'],
|
||||
);
|
||||
|
||||
return applyCscAuthorizeExtras(url, { lang, clientData });
|
||||
};
|
||||
|
||||
type CredentialScopeAuthorizeOptions = AuthorizeUrlBaseOptions & {
|
||||
/** Target credential (§8.3.2 — REQUIRED for credential scope). */
|
||||
credentialId: string;
|
||||
/** Number of signatures this SAD will authorise (§8.3.2 — REQUIRED). */
|
||||
numSignatures: number;
|
||||
/**
|
||||
* Standard-base64-encoded hash values the SAD will be bound to. REQUIRED for
|
||||
* SCAL2 credentials (§8.3.2). The builder converts each value to base64url
|
||||
* before joining with `,` per the spec — §8.3.2 mandates base64url for the
|
||||
* `hash` URL parameter, but the rest of the codebase (and the
|
||||
* `signatures/signHash` JSON body per §11.9) uses standard base64. Callers
|
||||
* pass what `Buffer.from(...).toString('base64')` produces.
|
||||
*/
|
||||
hashes: string[];
|
||||
/** Human-readable transaction description shown on the TSP's SCA page. */
|
||||
description?: string;
|
||||
/** Optional restricted-access token (JWT) some TSPs require (§8.3.2). */
|
||||
accountToken?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert a standard-base64 string to base64url (RFC 4648 §5). The CSC §8.3.2
|
||||
* `hash` URL parameter requires base64url; TSPs reject standard base64 even
|
||||
* after percent-decoding because `+`, `/`, and `=` are invalid base64url
|
||||
* characters. JSON-body fields (§11.9 `signatures/signHash`) keep standard
|
||||
* base64.
|
||||
*/
|
||||
const toBase64Url = (standardBase64: string): string =>
|
||||
standardBase64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
|
||||
/**
|
||||
* Build the `oauth2/authorize` URL for the **credential** scope. The TSP
|
||||
* binds the issued SAD to `hashes` so it can only sign those exact digests.
|
||||
*
|
||||
* Hash ordering in the SAD is independent of the order passed to
|
||||
* `signatures/signHash` (§8.3.2) — the TSP matches by hash value, not
|
||||
* position.
|
||||
*/
|
||||
export const buildCscCredentialScopeAuthorizeUrl = (opts: CredentialScopeAuthorizeOptions): URL => {
|
||||
const {
|
||||
client,
|
||||
oauthBaseUrl,
|
||||
state,
|
||||
codeVerifier,
|
||||
credentialId,
|
||||
numSignatures,
|
||||
hashes,
|
||||
description,
|
||||
accountToken,
|
||||
lang,
|
||||
clientData,
|
||||
} = opts;
|
||||
|
||||
const url = client.createAuthorizationURLWithPKCE(
|
||||
joinCscUrl({ baseUrl: oauthBaseUrl, path: 'oauth2/authorize' }),
|
||||
state,
|
||||
CodeChallengeMethod.S256,
|
||||
codeVerifier,
|
||||
['credential'],
|
||||
);
|
||||
|
||||
url.searchParams.set('credentialID', credentialId);
|
||||
url.searchParams.set('numSignatures', String(numSignatures));
|
||||
url.searchParams.set('hash', hashes.map(toBase64Url).join(','));
|
||||
|
||||
if (description) {
|
||||
url.searchParams.set('description', description);
|
||||
}
|
||||
|
||||
if (accountToken) {
|
||||
url.searchParams.set('account_token', accountToken);
|
||||
}
|
||||
|
||||
return applyCscAuthorizeExtras(url, { lang, clientData });
|
||||
};
|
||||
|
||||
// ─── Token exchange (§8.3.3) ─────────────────────────────────────────────────
|
||||
|
||||
type ExchangeCodeOptions = {
|
||||
client: OAuth2Client;
|
||||
/** OAuth base URI from `info.oauth2`. `oauth2/token` is joined on. */
|
||||
oauthBaseUrl: string;
|
||||
/** Authorization code from the callback's `code` query param. */
|
||||
code: string;
|
||||
/** Same PKCE verifier passed to the authorize URL builder. */
|
||||
codeVerifier: string;
|
||||
signal?: AbortSignal;
|
||||
};
|
||||
|
||||
/**
|
||||
* Exchange an authorization code for an access token. Used for both scopes;
|
||||
* the response shape differs only in `token_type`:
|
||||
*
|
||||
* - service scope: `token_type === 'Bearer'`, optional `refresh_token`.
|
||||
* - credential scope: `token_type === 'SAD'`, single-use, no refresh_token.
|
||||
*
|
||||
* Inspect `tokens.tokenType()` (or `tokens.data` for raw access) to
|
||||
* discriminate.
|
||||
*/
|
||||
export const exchangeCscAuthorizationCode = async (opts: ExchangeCodeOptions): Promise<OAuth2Tokens> => {
|
||||
const { client, oauthBaseUrl, code, codeVerifier } = opts;
|
||||
|
||||
try {
|
||||
return await client.validateAuthorizationCode(
|
||||
joinCscUrl({ baseUrl: oauthBaseUrl, path: 'oauth2/token' }),
|
||||
code,
|
||||
codeVerifier,
|
||||
);
|
||||
} catch (err) {
|
||||
throw mapArcticError(err, 'oauth2/token');
|
||||
}
|
||||
};
|
||||
|
||||
type RefreshServiceTokenOptions = {
|
||||
client: OAuth2Client;
|
||||
oauthBaseUrl: string;
|
||||
/** Service-scope refresh token from a prior token exchange. */
|
||||
refreshToken: string;
|
||||
signal?: AbortSignal;
|
||||
};
|
||||
|
||||
/**
|
||||
* Refresh a service-scope access token. Credential-scope SADs are NOT
|
||||
* refreshable per §8.3.3 — only service scope issues refresh tokens.
|
||||
*
|
||||
* Scopes passed as `['service']` to keep the refresh narrow; the TSP may
|
||||
* ignore the scope parameter on refresh per RFC 6749 §6.
|
||||
*/
|
||||
export const refreshCscServiceToken = async (opts: RefreshServiceTokenOptions): Promise<OAuth2Tokens> => {
|
||||
const { client, oauthBaseUrl, refreshToken } = opts;
|
||||
|
||||
try {
|
||||
return await client.refreshAccessToken(joinCscUrl({ baseUrl: oauthBaseUrl, path: 'oauth2/token' }), refreshToken, [
|
||||
'service',
|
||||
]);
|
||||
} catch (err) {
|
||||
throw mapArcticError(err, 'oauth2/token');
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Revoke (§8.3.4) ─────────────────────────────────────────────────────────
|
||||
|
||||
type RevokeTokenOptions = {
|
||||
client: OAuth2Client;
|
||||
oauthBaseUrl: string;
|
||||
/** Access token or refresh token to revoke. */
|
||||
token: string;
|
||||
signal?: AbortSignal;
|
||||
};
|
||||
|
||||
/**
|
||||
* Revoke a CSC OAuth token. Per §8.3.4, revoking a refresh token also
|
||||
* invalidates every access token derived from the same grant; revoking an
|
||||
* access token only invalidates that access token.
|
||||
*
|
||||
* `204 No Content` on success; arctic resolves the promise. Failures
|
||||
* surface as `CSC_REQUEST_FAILED` via {@link mapArcticError}.
|
||||
*/
|
||||
export const revokeCscToken = async (opts: RevokeTokenOptions): Promise<void> => {
|
||||
const { client, oauthBaseUrl, token } = opts;
|
||||
|
||||
try {
|
||||
await client.revokeToken(joinCscUrl({ baseUrl: oauthBaseUrl, path: 'oauth2/revoke' }), token);
|
||||
} catch (err) {
|
||||
throw mapArcticError(err, 'oauth2/revoke');
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Error normalisation ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Translate arctic's typed exception hierarchy into AppErrors consistent with
|
||||
* the rest of the CSC client (see http.ts). Preserves the HTTP status when
|
||||
* arctic surfaces it.
|
||||
*/
|
||||
const mapArcticError = (err: unknown, endpoint: string): AppError => {
|
||||
if (err instanceof OAuth2RequestError) {
|
||||
return new AppError(AppErrorCode.CSC_REQUEST_FAILED, {
|
||||
message: `CSC ${endpoint} rejected: ${err.code}${err.description ? ` — ${err.description}` : ''}`,
|
||||
});
|
||||
}
|
||||
|
||||
if (err instanceof ArcticFetchError) {
|
||||
return new AppError(AppErrorCode.CSC_REQUEST_FAILED, {
|
||||
message: `CSC ${endpoint} fetch failed: ${err.message}`,
|
||||
});
|
||||
}
|
||||
|
||||
if (err instanceof UnexpectedResponseError) {
|
||||
return new AppError(AppErrorCode.CSC_REQUEST_FAILED, {
|
||||
message: `CSC ${endpoint} returned unexpected HTTP ${err.status}`,
|
||||
statusCode: err.status,
|
||||
});
|
||||
}
|
||||
|
||||
if (err instanceof UnexpectedErrorResponseBodyError) {
|
||||
return new AppError(AppErrorCode.CSC_REQUEST_FAILED, {
|
||||
message: `CSC ${endpoint} returned HTTP ${err.status} with unparseable body`,
|
||||
statusCode: err.status,
|
||||
});
|
||||
}
|
||||
|
||||
return new AppError(AppErrorCode.CSC_REQUEST_FAILED, {
|
||||
message: `CSC ${endpoint} failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,111 @@
|
||||
import { cscJsonPost, joinCscUrl } from './http';
|
||||
import {
|
||||
type TCscSignHashRequest,
|
||||
type TCscSignHashResponse,
|
||||
type TCscTimestampRequest,
|
||||
type TCscTimestampResponse,
|
||||
ZCscSignHashResponseSchema,
|
||||
ZCscTimestampResponseSchema,
|
||||
} from './types';
|
||||
|
||||
type CscSignHashOptions = TCscSignHashRequest & {
|
||||
baseUrl: string;
|
||||
/** Service-scope bearer token. The SAD (in the body) is the credential-scope grant. */
|
||||
accessToken: string;
|
||||
signal?: AbortSignal;
|
||||
};
|
||||
|
||||
/**
|
||||
* `signatures/signHash` (§11.9) — submit one or more pre-computed hashes for
|
||||
* the TSP to sign with the credential identified by `credentialID`.
|
||||
*
|
||||
* Authorisation is two-layered:
|
||||
* - The service-scope bearer token authenticates the API call itself.
|
||||
* - The credential-scope SAD (in the JSON body) authorises the specific
|
||||
* hashes — the TSP rejects with `invalid_request` ("Hash is not authorized
|
||||
* by the SAD") if any hash in the array wasn't bound at SAD issuance.
|
||||
*
|
||||
* The returned `signatures` array is position-ordered with `hash` per §11.9.
|
||||
* Callers SHALL preserve order when mapping responses back to PDF embed
|
||||
* slots (the fifoSigner relies on this).
|
||||
*/
|
||||
export const cscSignHash = async (opts: CscSignHashOptions): Promise<TCscSignHashResponse> => {
|
||||
const { baseUrl, accessToken, signal, credentialID, SAD, hash, hashAlgo, signAlgo, signAlgoParams, clientData } =
|
||||
opts;
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
credentialID,
|
||||
SAD,
|
||||
hash,
|
||||
signAlgo,
|
||||
};
|
||||
|
||||
if (hashAlgo !== undefined) {
|
||||
body.hashAlgo = hashAlgo;
|
||||
}
|
||||
|
||||
if (signAlgoParams !== undefined) {
|
||||
body.signAlgoParams = signAlgoParams;
|
||||
}
|
||||
|
||||
if (clientData !== undefined) {
|
||||
body.clientData = clientData;
|
||||
}
|
||||
|
||||
return await cscJsonPost(
|
||||
{
|
||||
url: joinCscUrl({ baseUrl, path: 'signatures/signHash' }),
|
||||
body,
|
||||
accessToken,
|
||||
signal,
|
||||
},
|
||||
ZCscSignHashResponseSchema,
|
||||
);
|
||||
};
|
||||
|
||||
type CscTimestampOptions = TCscTimestampRequest & {
|
||||
baseUrl: string;
|
||||
/**
|
||||
* Service-scope bearer token. Per §11.10 the timestamp endpoint may or may
|
||||
* not require auth depending on TSP policy; the spec is silent. We send the
|
||||
* token unconditionally because all known TSPs gate this endpoint.
|
||||
*/
|
||||
accessToken: string;
|
||||
signal?: AbortSignal;
|
||||
};
|
||||
|
||||
/**
|
||||
* `signatures/timestamp` (§11.10) — request an RFC 3161 / RFC 5816 time-stamp
|
||||
* token for a pre-computed hash. Driven by {@link CscTspTimestampAuthority}
|
||||
* at sign time, when {@link resolveCscSignTimeTsa} selects the TSP source
|
||||
* (TSP advertises `signatures/timestamp` in `info.methods`). The bearer is
|
||||
* the current recipient's own service-scope token. Seal-time archival
|
||||
* timestamps do not go through this endpoint — they use the env-configured
|
||||
* RFC 3161 TSA directly.
|
||||
*
|
||||
* If `nonce` is supplied, the TSP MUST round-trip it in the token — we leave
|
||||
* verification to LibPDF / our TSA helper, not this client.
|
||||
*/
|
||||
export const cscTimestamp = async (opts: CscTimestampOptions): Promise<TCscTimestampResponse> => {
|
||||
const { baseUrl, accessToken, signal, hash, hashAlgo, nonce, clientData } = opts;
|
||||
|
||||
const body: Record<string, unknown> = { hash, hashAlgo };
|
||||
|
||||
if (nonce !== undefined) {
|
||||
body.nonce = nonce;
|
||||
}
|
||||
|
||||
if (clientData !== undefined) {
|
||||
body.clientData = clientData;
|
||||
}
|
||||
|
||||
return await cscJsonPost(
|
||||
{
|
||||
url: joinCscUrl({ baseUrl, path: 'signatures/timestamp' }),
|
||||
body,
|
||||
accessToken,
|
||||
signal,
|
||||
},
|
||||
ZCscTimestampResponseSchema,
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,179 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Zod schemas + types for every CSC v1.0.4.0 request/response shape the V1
|
||||
* client touches. Field names mirror the spec exactly. Unknown fields are
|
||||
* silently dropped (Zod default `.strip()`); we don't `.passthrough()` to
|
||||
* keep parsed objects narrow.
|
||||
*
|
||||
* Out-of-scope endpoints (`auth/login`, `auth/revoke`, `credentials/authorize`,
|
||||
* `credentials/extendTransaction`, `credentials/sendOTP`) intentionally have
|
||||
* no schemas here — V1 uses OAuth + sequential single-signature flows only.
|
||||
*/
|
||||
|
||||
// ─── §10.1 common error envelope ─────────────────────────────────────────────
|
||||
|
||||
export const ZCscErrorResponseSchema = z.object({
|
||||
error: z.string(),
|
||||
error_description: z.string().optional(),
|
||||
});
|
||||
|
||||
export type TCscErrorResponse = z.infer<typeof ZCscErrorResponseSchema>;
|
||||
|
||||
// ─── §11.1 info ──────────────────────────────────────────────────────────────
|
||||
|
||||
export const ZCscInfoRequestSchema = z.object({
|
||||
lang: z.string().optional(),
|
||||
});
|
||||
|
||||
export type TCscInfoRequest = z.infer<typeof ZCscInfoRequestSchema>;
|
||||
|
||||
export const ZCscInfoResponseSchema = z.object({
|
||||
specs: z.string(),
|
||||
name: z.string(),
|
||||
logo: z.string(),
|
||||
region: z.string(),
|
||||
lang: z.string(),
|
||||
description: z.string(),
|
||||
authType: z.array(z.string()),
|
||||
// REQUIRED Conditional — present when authType includes `oauth2code` /
|
||||
// `oauth2client`, or when any credential supports `oauth2code` authMode.
|
||||
// We always need it for V1, but keeping the schema permissive matches the
|
||||
// spec; absence is detected at the call site.
|
||||
oauth2: z.string().optional(),
|
||||
methods: z.array(z.string()),
|
||||
});
|
||||
|
||||
export type TCscInfoResponse = z.infer<typeof ZCscInfoResponseSchema>;
|
||||
|
||||
// ─── §11.4 credentials/list ──────────────────────────────────────────────────
|
||||
|
||||
export const ZCscCredentialsListRequestSchema = z.object({
|
||||
// OAuth2 user-specific service auth → userID MUST be omitted (§11.4 NOTE 1).
|
||||
userID: z.string().optional(),
|
||||
maxResults: z.number().int().positive().optional(),
|
||||
pageToken: z.string().optional(),
|
||||
clientData: z.string().optional(),
|
||||
});
|
||||
|
||||
export type TCscCredentialsListRequest = z.infer<typeof ZCscCredentialsListRequestSchema>;
|
||||
|
||||
export const ZCscCredentialsListResponseSchema = z.object({
|
||||
credentialIDs: z.array(z.string()),
|
||||
nextPageToken: z.string().optional(),
|
||||
});
|
||||
|
||||
export type TCscCredentialsListResponse = z.infer<typeof ZCscCredentialsListResponseSchema>;
|
||||
|
||||
// ─── §11.5 credentials/info ──────────────────────────────────────────────────
|
||||
|
||||
export const ZCscCredentialsInfoRequestSchema = z.object({
|
||||
credentialID: z.string(),
|
||||
certificates: z.enum(['none', 'single', 'chain']).optional(),
|
||||
certInfo: z.boolean().optional(),
|
||||
authInfo: z.boolean().optional(),
|
||||
lang: z.string().optional(),
|
||||
clientData: z.string().optional(),
|
||||
});
|
||||
|
||||
export type TCscCredentialsInfoRequest = z.infer<typeof ZCscCredentialsInfoRequestSchema>;
|
||||
|
||||
export const ZCscCredentialsInfoKeySchema = z.object({
|
||||
status: z.enum(['enabled', 'disabled']),
|
||||
algo: z.array(z.string()),
|
||||
// REQUIRED per §11.5 but kept optional here so the algorithm-resolver can
|
||||
// surface absence as a typed `CSC_ALGORITHM_REFUSED` (matching the spec's
|
||||
// policy table) instead of a generic transport schema failure.
|
||||
len: z.number().int().positive().optional(),
|
||||
// REQUIRED Conditional for ECDSA per §11.5; absence handled by the resolver.
|
||||
curve: z.string().optional(),
|
||||
});
|
||||
|
||||
export const ZCscCredentialsInfoCertSchema = z.object({
|
||||
status: z.enum(['valid', 'expired', 'revoked', 'suspended']).optional(),
|
||||
certificates: z.array(z.string()).optional(),
|
||||
issuerDN: z.string().optional(),
|
||||
serialNumber: z.string().optional(),
|
||||
subjectDN: z.string().optional(),
|
||||
validFrom: z.string().optional(),
|
||||
validTo: z.string().optional(),
|
||||
});
|
||||
|
||||
export const ZCscCredentialsInfoPinSchema = z.object({
|
||||
presence: z.enum(['true', 'false', 'optional']),
|
||||
format: z.enum(['A', 'N']).optional(),
|
||||
label: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
});
|
||||
|
||||
export const ZCscCredentialsInfoOtpSchema = z.object({
|
||||
presence: z.enum(['true', 'false', 'optional']),
|
||||
type: z.enum(['offline', 'online']).optional(),
|
||||
format: z.enum(['A', 'N']).optional(),
|
||||
label: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
ID: z.string().optional(),
|
||||
provider: z.string().optional(),
|
||||
});
|
||||
|
||||
export const ZCscCredentialsInfoResponseSchema = z.object({
|
||||
description: z.string().optional(),
|
||||
key: ZCscCredentialsInfoKeySchema,
|
||||
cert: ZCscCredentialsInfoCertSchema,
|
||||
authMode: z.enum(['implicit', 'explicit', 'oauth2code']),
|
||||
SCAL: z.enum(['1', '2']).optional(),
|
||||
PIN: ZCscCredentialsInfoPinSchema.optional(),
|
||||
OTP: ZCscCredentialsInfoOtpSchema.optional(),
|
||||
multisign: z.number().int().min(1),
|
||||
lang: z.string().optional(),
|
||||
});
|
||||
|
||||
export type TCscCredentialsInfoResponse = z.infer<typeof ZCscCredentialsInfoResponseSchema>;
|
||||
|
||||
// ─── §11.9 signatures/signHash ───────────────────────────────────────────────
|
||||
|
||||
export const ZCscSignHashRequestSchema = z.object({
|
||||
credentialID: z.string(),
|
||||
SAD: z.string(),
|
||||
// Base64-encoded raw message digests.
|
||||
hash: z.array(z.string()).nonempty(),
|
||||
// REQUIRED Conditional — OID of the hash algorithm. Omit only when implied
|
||||
// by signAlgo (per §11.9). The caller decides.
|
||||
hashAlgo: z.string().optional(),
|
||||
signAlgo: z.string(),
|
||||
// REQUIRED Conditional for algorithms like RSASSA-PSS.
|
||||
signAlgoParams: z.string().optional(),
|
||||
clientData: z.string().optional(),
|
||||
});
|
||||
|
||||
export type TCscSignHashRequest = z.infer<typeof ZCscSignHashRequestSchema>;
|
||||
|
||||
export const ZCscSignHashResponseSchema = z.object({
|
||||
// Position-ordered Base64-encoded signed hashes matching the input order.
|
||||
signatures: z.array(z.string()).nonempty(),
|
||||
});
|
||||
|
||||
export type TCscSignHashResponse = z.infer<typeof ZCscSignHashResponseSchema>;
|
||||
|
||||
// ─── §11.10 signatures/timestamp ─────────────────────────────────────────────
|
||||
|
||||
export const ZCscTimestampRequestSchema = z.object({
|
||||
hash: z.string(),
|
||||
hashAlgo: z.string(),
|
||||
// Hex-encoded random; SHALL round-trip in the timestamp token when supplied.
|
||||
nonce: z.string().optional(),
|
||||
clientData: z.string().optional(),
|
||||
});
|
||||
|
||||
export type TCscTimestampRequest = z.infer<typeof ZCscTimestampRequestSchema>;
|
||||
|
||||
export const ZCscTimestampResponseSchema = z.object({
|
||||
// Base64-encoded RFC 3161 (with RFC 5816 update) time-stamp token.
|
||||
timestamp: z.string(),
|
||||
});
|
||||
|
||||
export type TCscTimestampResponse = z.infer<typeof ZCscTimestampResponseSchema>;
|
||||
|
||||
// OAuth 2.0 token + revoke shapes are handled by the `arctic` library — see
|
||||
// `oauth.ts` in this directory. Arctic exposes `OAuth2Tokens` (with `.data`
|
||||
// available for non-standard CSC fields like `token_type === 'SAD'`).
|
||||
@@ -0,0 +1,120 @@
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import type { Context } from 'hono';
|
||||
import { deleteCookie, getSignedCookie, setSignedCookie } from 'hono/cookie';
|
||||
import { parseSigned, serialize } from 'hono/utils/cookie';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { CSC_BLOCKING_ERROR_COOKIE_NAME, cscCookieBaseOptions, getCscCookieSecret } from './shared';
|
||||
|
||||
/**
|
||||
* `csc_blocking_error` — one-shot surface for service-scope OAuth callback
|
||||
* failures the recipient can't self-resolve (empty credential list, invalid
|
||||
* cert, refused algorithm, etc.). The `/sign/{token}` loader reads + clears
|
||||
* it on next visit so no error state rides on URL query params.
|
||||
*/
|
||||
|
||||
const CSC_BLOCKING_ERROR_MAX_AGE_SECONDS = 60 * 10; // 10 minutes — matches the other short-lived CSC cookies.
|
||||
|
||||
export const ZCscBlockingErrorPayloadSchema = z.object({
|
||||
/** `AppErrorCode` value, e.g. `'CSC_CREDENTIAL_LIST_EMPTY'`. */
|
||||
code: z.string().min(1),
|
||||
/** Recipient token from `/sign/{token}`; loader scopes the error to its recipient. */
|
||||
recipientToken: z.string().min(1),
|
||||
});
|
||||
|
||||
export type TCscBlockingErrorPayload = z.infer<typeof ZCscBlockingErrorPayloadSchema>;
|
||||
|
||||
type SetCscBlockingErrorCookieOptions = {
|
||||
c: Context;
|
||||
payload: TCscBlockingErrorPayload;
|
||||
};
|
||||
|
||||
export const setCscBlockingErrorCookie = async (options: SetCscBlockingErrorCookieOptions): Promise<void> => {
|
||||
const { c, payload } = options;
|
||||
|
||||
await setSignedCookie(c, CSC_BLOCKING_ERROR_COOKIE_NAME, JSON.stringify(payload), getCscCookieSecret(), {
|
||||
...cscCookieBaseOptions,
|
||||
maxAge: CSC_BLOCKING_ERROR_MAX_AGE_SECONDS,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Read + validate the blocking-error cookie. Returns `null` when absent or
|
||||
* signature-invalid; throws `INVALID_REQUEST` when signed-but-malformed
|
||||
* (tamper-shaped, mirroring `oauth-flow-cookie.ts`).
|
||||
*/
|
||||
export const getCscBlockingErrorCookie = async (c: Context): Promise<TCscBlockingErrorPayload | null> => {
|
||||
const raw = await getSignedCookie(c, getCscCookieSecret(), CSC_BLOCKING_ERROR_COOKIE_NAME);
|
||||
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let parsedJson: unknown;
|
||||
|
||||
try {
|
||||
parsedJson = JSON.parse(raw);
|
||||
} catch {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'CSC blocking error cookie payload is not valid JSON.',
|
||||
});
|
||||
}
|
||||
|
||||
const result = ZCscBlockingErrorPayloadSchema.safeParse(parsedJson);
|
||||
|
||||
if (!result.success) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'CSC blocking error cookie payload failed schema validation.',
|
||||
});
|
||||
}
|
||||
|
||||
return result.data;
|
||||
};
|
||||
|
||||
export const clearCscBlockingErrorCookie = (c: Context): void => {
|
||||
deleteCookie(c, CSC_BLOCKING_ERROR_COOKIE_NAME, cscCookieBaseOptions);
|
||||
};
|
||||
|
||||
/**
|
||||
* Remix-compatible reader: parses + HMAC-verifies the blocking-error cookie
|
||||
* from a raw `Cookie` header on a standard `Request`. Returns `null` when
|
||||
* absent, signature-invalid, or payload-malformed (no throw — the loader
|
||||
* only uses the cookie advisorily, so a bad cookie shouldn't break the page).
|
||||
*/
|
||||
export const readCscBlockingErrorFromRequest = async (request: Request): Promise<TCscBlockingErrorPayload | null> => {
|
||||
const cookieHeader = request.headers.get('cookie');
|
||||
|
||||
if (!cookieHeader) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = await parseSigned(cookieHeader, getCscCookieSecret(), CSC_BLOCKING_ERROR_COOKIE_NAME);
|
||||
|
||||
const value = parsed[CSC_BLOCKING_ERROR_COOKIE_NAME];
|
||||
|
||||
if (typeof value !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const json = JSON.parse(value);
|
||||
|
||||
const result = ZCscBlockingErrorPayloadSchema.safeParse(json);
|
||||
|
||||
return result.success ? result.data : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Serialised `Set-Cookie` header value that expires the cookie immediately.
|
||||
* Use in a Remix loader's response headers to clear the cookie after the
|
||||
* loader reads it once.
|
||||
*/
|
||||
export const buildClearCscBlockingErrorCookieHeader = (): string => {
|
||||
return serialize(CSC_BLOCKING_ERROR_COOKIE_NAME, '', {
|
||||
...cscCookieBaseOptions,
|
||||
maxAge: 0,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,85 @@
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import type { Context } from 'hono';
|
||||
import { deleteCookie, getSignedCookie, setSignedCookie } from 'hono/cookie';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { CSC_OAUTH_FLOW_COOKIE_NAME, cscCookieBaseOptions, getCscCookieSecret } from './shared';
|
||||
|
||||
/**
|
||||
* `csc_oauth_flow` — single-round-trip carrier across `/api/csc/oauth/authorize`
|
||||
* → TSP → `/api/csc/oauth/callback`. Holds the PKCE verifier + state plus the
|
||||
* Documenso-side context (`recipientToken`, optional `sessionId`) the
|
||||
* callback needs to resume the right signing flow.
|
||||
*
|
||||
* JSON-encoded inside a single signed cookie; structurally validated on read
|
||||
* so a tampered or stale shape can't smuggle bad state into the callback.
|
||||
*/
|
||||
|
||||
const CSC_OAUTH_FLOW_MAX_AGE_SECONDS = 60 * 10; // 10 minutes — matches /api/auth/oauth/* convention.
|
||||
|
||||
export const ZCscOAuthFlowPayloadSchema = z.object({
|
||||
/** `'service'` for the first round-trip, `'credential'` for the SAD round-trip. */
|
||||
scope: z.enum(['service', 'credential']),
|
||||
/** Arctic-generated CSRF token; re-validated against `?state` at callback. */
|
||||
state: z.string().min(1),
|
||||
/** Arctic-generated PKCE verifier (RFC 7636); paired with the URL's `code_challenge`. */
|
||||
codeVerifier: z.string().min(1),
|
||||
/** Recipient signing token from `/sign/{token}`; threads recipient identity through the round-trip. */
|
||||
recipientToken: z.string().min(1),
|
||||
/** CSC session id — present only on `credential`-scope flows (set at prep). */
|
||||
sessionId: z.string().min(1).optional(),
|
||||
});
|
||||
|
||||
export type TCscOAuthFlowPayload = z.infer<typeof ZCscOAuthFlowPayloadSchema>;
|
||||
|
||||
type SetCscOAuthFlowCookieOptions = {
|
||||
c: Context;
|
||||
payload: TCscOAuthFlowPayload;
|
||||
};
|
||||
|
||||
export const setCscOAuthFlowCookie = async (options: SetCscOAuthFlowCookieOptions): Promise<void> => {
|
||||
const { c, payload } = options;
|
||||
|
||||
await setSignedCookie(c, CSC_OAUTH_FLOW_COOKIE_NAME, JSON.stringify(payload), getCscCookieSecret(), {
|
||||
...cscCookieBaseOptions,
|
||||
maxAge: CSC_OAUTH_FLOW_MAX_AGE_SECONDS,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Read + validate the OAuth-flow cookie. Returns `null` when the cookie is
|
||||
* absent or the signature is invalid; throws `INVALID_REQUEST` when the
|
||||
* payload is structurally bad (signed but malformed JSON / schema mismatch),
|
||||
* since that's tamper-shaped, not a normal missing-cookie case.
|
||||
*/
|
||||
export const getCscOAuthFlowCookie = async (c: Context): Promise<TCscOAuthFlowPayload | null> => {
|
||||
const raw = await getSignedCookie(c, getCscCookieSecret(), CSC_OAUTH_FLOW_COOKIE_NAME);
|
||||
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let parsedJson: unknown;
|
||||
|
||||
try {
|
||||
parsedJson = JSON.parse(raw);
|
||||
} catch {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'CSC OAuth flow cookie payload is not valid JSON.',
|
||||
});
|
||||
}
|
||||
|
||||
const result = ZCscOAuthFlowPayloadSchema.safeParse(parsedJson);
|
||||
|
||||
if (!result.success) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'CSC OAuth flow cookie payload failed schema validation.',
|
||||
});
|
||||
}
|
||||
|
||||
return result.data;
|
||||
};
|
||||
|
||||
export const clearCscOAuthFlowCookie = (c: Context): void => {
|
||||
deleteCookie(c, CSC_OAUTH_FLOW_COOKIE_NAME, cscCookieBaseOptions);
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { Context } from 'hono';
|
||||
import { deleteCookie, getSignedCookie, setSignedCookie } from 'hono/cookie';
|
||||
import { parseSigned } from 'hono/utils/cookie';
|
||||
|
||||
import { CSC_SAD_SESSION_COOKIE_NAME, cscCookieBaseOptions, getCscCookieSecret } from './shared';
|
||||
|
||||
/**
|
||||
* `csc_sad_session` — HMAC-signed `CscSession` cuid. Set after the
|
||||
* credential-scope OAuth callback exchanges code → SAD; pointed at the
|
||||
* server-side session row that owns the SAD + the prep-time item hashes.
|
||||
*
|
||||
* Lifetime mirrors the TSP-asserted SAD expiry (`sadExpiresAt`) so the cookie
|
||||
* cannot outlive its server-side authorisation. Cleared by the sync sign
|
||||
* mutation on success; otherwise decays naturally with the browser TTL.
|
||||
*/
|
||||
|
||||
type SetCscSadSessionCookieOptions = {
|
||||
c: Context;
|
||||
sessionId: string;
|
||||
/** Mirror of `CscSession.sadExpiresAt`; cookie expires no later than the SAD. */
|
||||
expiresAt: Date;
|
||||
};
|
||||
|
||||
export const setCscSadSessionCookie = async (options: SetCscSadSessionCookieOptions): Promise<void> => {
|
||||
const { c, sessionId, expiresAt } = options;
|
||||
|
||||
await setSignedCookie(c, CSC_SAD_SESSION_COOKIE_NAME, sessionId, getCscCookieSecret(), {
|
||||
...cscCookieBaseOptions,
|
||||
expires: expiresAt,
|
||||
});
|
||||
};
|
||||
|
||||
export const getCscSadSessionCookie = async (c: Context): Promise<string | null> => {
|
||||
const value = await getSignedCookie(c, getCscCookieSecret(), CSC_SAD_SESSION_COOKIE_NAME);
|
||||
|
||||
// `getSignedCookie` returns `false` on signature mismatch, `undefined` when
|
||||
// the cookie is absent. Both collapse to `null` for the caller's sake.
|
||||
return value ? value : null;
|
||||
};
|
||||
|
||||
export const clearCscSadSessionCookie = (c: Context): void => {
|
||||
deleteCookie(c, CSC_SAD_SESSION_COOKIE_NAME, cscCookieBaseOptions);
|
||||
};
|
||||
|
||||
/**
|
||||
* Remix-compatible reader: parses + HMAC-verifies the SAD-session cookie
|
||||
* from a raw `Cookie` header on a standard `Request`. Mirrors
|
||||
* `getCscSadSessionCookie` but works outside Hono's `Context`.
|
||||
*/
|
||||
export const readCscSadSessionFromRequest = async (request: Request): Promise<string | null> => {
|
||||
const cookieHeader = request.headers.get('cookie');
|
||||
|
||||
if (!cookieHeader) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = await parseSigned(cookieHeader, getCscCookieSecret(), CSC_SAD_SESSION_COOKIE_NAME);
|
||||
const value = parsed[CSC_SAD_SESSION_COOKIE_NAME];
|
||||
|
||||
return typeof value === 'string' ? value : null;
|
||||
};
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { Context } from 'hono';
|
||||
import { deleteCookie, getSignedCookie, setSignedCookie } from 'hono/cookie';
|
||||
import { parseSigned } from 'hono/utils/cookie';
|
||||
|
||||
import { CSC_SERVICE_SESSION_COOKIE_NAME, cscCookieBaseOptions, getCscCookieSecret } from './shared';
|
||||
|
||||
/**
|
||||
* `csc_service_session` — recipient-scoped attestation that this browser just
|
||||
* completed a service-scope OAuth round-trip for `<recipientToken>`. The
|
||||
* `/sign/{token}` loader compares the cookie value against the path token; on
|
||||
* match it skips re-auth, breaking the redirect loop that would otherwise
|
||||
* occur when the TSP silently re-grants from its cached SCA session.
|
||||
*
|
||||
* Covers the long-lived T1→T3 window (recipient on the signing page filling
|
||||
* fields, before clicking Sign). `csc_sad_session` covers the much shorter
|
||||
* T4→T5 window (active signing transaction); the two are complementary, not
|
||||
* substitutes.
|
||||
*
|
||||
* TTL = TSP-asserted service-scope `expires_in` so the trust window can never
|
||||
* outlive the underlying access token.
|
||||
*/
|
||||
|
||||
type SetCscServiceSessionCookieOptions = {
|
||||
c: Context;
|
||||
recipientToken: string;
|
||||
/** TSP service-scope `expires_in` in seconds. Mirrored as the cookie max-age. */
|
||||
ttlSeconds: number;
|
||||
};
|
||||
|
||||
export const setCscServiceSessionCookie = async (options: SetCscServiceSessionCookieOptions): Promise<void> => {
|
||||
const { c, recipientToken, ttlSeconds } = options;
|
||||
|
||||
await setSignedCookie(c, CSC_SERVICE_SESSION_COOKIE_NAME, recipientToken, getCscCookieSecret(), {
|
||||
...cscCookieBaseOptions,
|
||||
maxAge: ttlSeconds,
|
||||
});
|
||||
};
|
||||
|
||||
export const getCscServiceSessionCookie = async (c: Context): Promise<string | null> => {
|
||||
const value = await getSignedCookie(c, getCscCookieSecret(), CSC_SERVICE_SESSION_COOKIE_NAME);
|
||||
|
||||
return value ? value : null;
|
||||
};
|
||||
|
||||
export const clearCscServiceSessionCookie = (c: Context): void => {
|
||||
deleteCookie(c, CSC_SERVICE_SESSION_COOKIE_NAME, cscCookieBaseOptions);
|
||||
};
|
||||
|
||||
/**
|
||||
* Remix-compatible reader: parses + HMAC-verifies the service-session cookie
|
||||
* from a raw `Cookie` header on a standard `Request`. Mirrors
|
||||
* `getCscServiceSessionCookie` but works outside Hono's `Context`.
|
||||
*/
|
||||
export const readCscServiceSessionFromRequest = async (request: Request): Promise<string | null> => {
|
||||
const cookieHeader = request.headers.get('cookie');
|
||||
|
||||
if (!cookieHeader) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = await parseSigned(cookieHeader, getCscCookieSecret(), CSC_SERVICE_SESSION_COOKIE_NAME);
|
||||
const value = parsed[CSC_SERVICE_SESSION_COOKIE_NAME];
|
||||
|
||||
return typeof value === 'string' ? value : null;
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
import { formatSecureCookieName, getCookieDomain, useSecureCookies } from '@documenso/lib/constants/auth';
|
||||
import { requireEnv } from '@documenso/lib/utils/env';
|
||||
|
||||
/**
|
||||
* Shared HMAC secret + base attribute set for the CSC cookies.
|
||||
*
|
||||
* `NEXTAUTH_SECRET` is reused so signed-cookie verification stays uniform
|
||||
* across the auth + CSC surfaces. The `sameSite` conditional matches
|
||||
* `sessionCookieOptions` in `@documenso/auth` so a future embedding flow
|
||||
* (CSC inside an `<iframe>` on a partner host) works without a separate
|
||||
* cookie-attribute regime.
|
||||
*/
|
||||
|
||||
/** HMAC secret for hono `setSignedCookie` / `getSignedCookie`. */
|
||||
export const getCscCookieSecret = (): string => requireEnv('NEXTAUTH_SECRET');
|
||||
|
||||
/**
|
||||
* CSC cookie names; prefixed with `__Secure-` in production over HTTPS.
|
||||
*
|
||||
* Naming maps 1:1 to the CSC OAuth scope each cookie attests:
|
||||
* - `csc_service_session` — service-scope grant (long-lived per-browser SCA
|
||||
* attestation; lifetime = TSP `expires_in`).
|
||||
* - `csc_sad_session` — credential-scope grant in progress (in-flight signing
|
||||
* transaction; lifetime = SAD lifetime).
|
||||
* - `csc_oauth_flow` — single-round-trip carrier across authorize → callback
|
||||
* (scope-agnostic; both flows reuse it).
|
||||
* - `csc_blocking_error` — callback failure surface; carries an unresolvable
|
||||
* service-scope error (e.g. empty credential list, refused algorithm) to
|
||||
* the next `/sign/{token}` loader, read-once.
|
||||
*/
|
||||
export const CSC_SERVICE_SESSION_COOKIE_NAME = formatSecureCookieName('csc_service_session');
|
||||
export const CSC_SAD_SESSION_COOKIE_NAME = formatSecureCookieName('csc_sad_session');
|
||||
export const CSC_OAUTH_FLOW_COOKIE_NAME = formatSecureCookieName('csc_oauth_flow');
|
||||
export const CSC_BLOCKING_ERROR_COOKIE_NAME = formatSecureCookieName('csc_blocking_error');
|
||||
|
||||
/**
|
||||
* Base options spread into every CSC cookie. Callers add per-cookie expiry
|
||||
* (`maxAge` or `expires`) on top.
|
||||
*/
|
||||
export const cscCookieBaseOptions = {
|
||||
httpOnly: true,
|
||||
path: '/',
|
||||
sameSite: useSecureCookies ? 'none' : 'lax',
|
||||
secure: useSecureCookies,
|
||||
domain: getCookieDomain(),
|
||||
} as const;
|
||||
@@ -0,0 +1,184 @@
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { Prisma } from '@prisma/client';
|
||||
|
||||
/**
|
||||
* DB helpers for `CscCredential` — the per-recipient row that holds the
|
||||
* TSP-validated certificate chain, the resolved algorithm policy, and the
|
||||
* encrypted service-scope access token.
|
||||
*
|
||||
* Lifecycle mirrors {@link sign-session.ts} but with a longer-lived row:
|
||||
*
|
||||
* - {@link upsertCscCredential} — service-scope OAuth callback writes the
|
||||
* full credential after `credentials/info` + algorithm validation succeed.
|
||||
* Re-runs replace prior bytes (cert / token rotates as the TSP refreshes).
|
||||
* - {@link loadCscCredential} — sign-time fetches by `recipientId` to recover
|
||||
* the persisted algorithm + encrypted service token; returns `null` when
|
||||
* the recipient never completed service-scope OAuth.
|
||||
*
|
||||
* Encryption is the caller's job — both byte columns hold raw ciphertext
|
||||
* produced by {@link encryptCscToken} so the helpers stay cipher-agnostic.
|
||||
* Cascade cleanup on `Recipient` delete removes the row transitively.
|
||||
*/
|
||||
|
||||
export type CscCredentialRow = {
|
||||
id: string;
|
||||
recipientId: number;
|
||||
providerId: string;
|
||||
credentialId: string;
|
||||
certCache: Uint8Array | null;
|
||||
signatureAlgorithm: string;
|
||||
keyType: string;
|
||||
digestAlgorithm: string;
|
||||
keyLenBits: number | null;
|
||||
signAlgoParams: string | null;
|
||||
serviceTokenCiphertext: Uint8Array | null;
|
||||
serviceTokenExpiresAt: Date | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
};
|
||||
|
||||
type UpsertCscCredentialInput = {
|
||||
recipientId: number;
|
||||
providerId: string;
|
||||
credentialId: string;
|
||||
/** Length-prefixed X.509 chain — produced from `cscCredentialsInfo.cert.certificates`. */
|
||||
certCache: Uint8Array;
|
||||
/** OID persisted from {@link CscAlgorithmPolicy.signAlgoOid}. */
|
||||
signatureAlgorithm: string;
|
||||
/** `'RSA'` or `'ECDSA'` from the resolved policy. */
|
||||
keyType: string;
|
||||
/** `'SHA-256'` / `'SHA-384'` / `'SHA-512'` from the resolved policy. */
|
||||
digestAlgorithm: string;
|
||||
keyLenBits: number;
|
||||
/** RSASSA-PSS only; omit otherwise. */
|
||||
signAlgoParams?: string;
|
||||
/** Output of {@link encryptCscToken}. */
|
||||
serviceTokenCiphertext: Uint8Array;
|
||||
/** Mirrors the TSP's `expires_in` projected onto wall-clock. */
|
||||
serviceTokenExpiresAt: Date;
|
||||
};
|
||||
|
||||
/**
|
||||
* Create or refresh the per-recipient credential row at service-scope OAuth
|
||||
* callback success. Replaces every prior byte payload — a re-auth always
|
||||
* supersedes the prior cert + token (TSPs may have rotated either).
|
||||
*/
|
||||
export const upsertCscCredential = async (input: UpsertCscCredentialInput): Promise<CscCredentialRow> => {
|
||||
const {
|
||||
recipientId,
|
||||
providerId,
|
||||
credentialId,
|
||||
certCache,
|
||||
signatureAlgorithm,
|
||||
keyType,
|
||||
digestAlgorithm,
|
||||
keyLenBits,
|
||||
signAlgoParams,
|
||||
serviceTokenCiphertext,
|
||||
serviceTokenExpiresAt,
|
||||
} = input;
|
||||
|
||||
const row = await prisma.cscCredential.upsert({
|
||||
where: { recipientId },
|
||||
create: {
|
||||
recipientId,
|
||||
providerId,
|
||||
credentialId,
|
||||
certCache,
|
||||
signatureAlgorithm,
|
||||
keyType,
|
||||
digestAlgorithm,
|
||||
keyLenBits,
|
||||
signAlgoParams: signAlgoParams ?? null,
|
||||
serviceTokenCiphertext,
|
||||
serviceTokenExpiresAt,
|
||||
},
|
||||
update: {
|
||||
providerId,
|
||||
credentialId,
|
||||
certCache,
|
||||
signatureAlgorithm,
|
||||
keyType,
|
||||
digestAlgorithm,
|
||||
keyLenBits,
|
||||
signAlgoParams: signAlgoParams ?? null,
|
||||
serviceTokenCiphertext,
|
||||
serviceTokenExpiresAt,
|
||||
},
|
||||
});
|
||||
|
||||
return toCscCredentialRow(row);
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch the credential row for a recipient. Returns `null` when absent — the
|
||||
* recipient hasn't completed service-scope OAuth yet (loader path) or the
|
||||
* recipient cascade fired (cleanup path). Both are normal terminal outcomes.
|
||||
*/
|
||||
export const loadCscCredential = async (recipientId: number): Promise<CscCredentialRow | null> => {
|
||||
const row = await prisma.cscCredential.findUnique({
|
||||
where: { recipientId },
|
||||
});
|
||||
|
||||
return row ? toCscCredentialRow(row) : null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Explicit delete by recipient id. Recipient-cascade handles routine cleanup;
|
||||
* this helper is for operator-triggered re-auth flows (force the next visit
|
||||
* to re-do service-scope OAuth even within the trust window).
|
||||
*
|
||||
* Throws `NOT_FOUND` when the row is already gone — semantically distinct
|
||||
* from {@link loadCscCredential}'s nullable return because explicit delete
|
||||
* is a deliberate operation and silent no-op would mask flow-state bugs.
|
||||
*/
|
||||
export const deleteCscCredential = async (recipientId: number): Promise<CscCredentialRow> => {
|
||||
try {
|
||||
const row = await prisma.cscCredential.delete({
|
||||
where: { recipientId },
|
||||
});
|
||||
|
||||
return toCscCredentialRow(row);
|
||||
} catch (err) {
|
||||
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2025') {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: `CSC credential for recipient ${recipientId} not found.`,
|
||||
});
|
||||
}
|
||||
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
const toCscCredentialRow = (row: {
|
||||
id: string;
|
||||
recipientId: number;
|
||||
providerId: string;
|
||||
credentialId: string;
|
||||
certCache: Uint8Array | null;
|
||||
signatureAlgorithm: string;
|
||||
keyType: string;
|
||||
digestAlgorithm: string;
|
||||
keyLenBits: number | null;
|
||||
signAlgoParams: string | null;
|
||||
serviceTokenCiphertext: Uint8Array | null;
|
||||
serviceTokenExpiresAt: Date | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}): CscCredentialRow => ({
|
||||
id: row.id,
|
||||
recipientId: row.recipientId,
|
||||
providerId: row.providerId,
|
||||
credentialId: row.credentialId,
|
||||
certCache: row.certCache,
|
||||
signatureAlgorithm: row.signatureAlgorithm,
|
||||
keyType: row.keyType,
|
||||
digestAlgorithm: row.digestAlgorithm,
|
||||
keyLenBits: row.keyLenBits,
|
||||
signAlgoParams: row.signAlgoParams,
|
||||
serviceTokenCiphertext: row.serviceTokenCiphertext,
|
||||
serviceTokenExpiresAt: row.serviceTokenExpiresAt,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
});
|
||||
@@ -0,0 +1,546 @@
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { jobs } from '@documenso/lib/jobs/client';
|
||||
import { sendPendingEmail } from '@documenso/lib/server-only/document/send-pending-email';
|
||||
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
|
||||
import { triggerWebhook } from '@documenso/lib/server-only/webhooks/trigger/trigger-webhook';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
||||
import { mapEnvelopeToWebhookDocumentPayload, ZWebhookDocumentSchema } from '@documenso/lib/types/webhook-payload';
|
||||
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||
import { getFileServerSide } from '@documenso/lib/universal/upload/get-file.server';
|
||||
import { putPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server';
|
||||
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
|
||||
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
|
||||
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { PDF } from '@libpdf/core';
|
||||
import {
|
||||
type DocumentDataType,
|
||||
EnvelopeType,
|
||||
RecipientRole,
|
||||
SendStatus,
|
||||
SigningStatus,
|
||||
WebhookTriggerEvents,
|
||||
} from '@prisma/client';
|
||||
|
||||
import { type CscDigest, hashOidForDigest, policyToLibpdfSignerAlgo } from './algorithm-resolver';
|
||||
import { decodeCscCertChain } from './cert-chain';
|
||||
import { decryptCscToken } from './ciphers';
|
||||
import { cscSignHash } from './client/signatures';
|
||||
import { loadCscCredential } from './credential';
|
||||
import { buildTspAnchorName } from './pdf-names';
|
||||
import { consumeCscSession, loadCscSession } from './sign-session';
|
||||
import { CscCaptureSigner } from './signers/capture-signer';
|
||||
import { CscFifoSigner } from './signers/fifo-signer';
|
||||
import { getCscTransport } from './transport';
|
||||
import { resolveCscSignTimeTsa } from './tsa-resolver';
|
||||
|
||||
/**
|
||||
* CSC TSP sign-time orchestrator.
|
||||
*
|
||||
* Two-pass run, both passes operating on the same prep-time-persisted PDF
|
||||
* bytes (`CscSession.items[i].documentDataId` pins an immutable rendered
|
||||
* orphan row — see `prepare-recipient-signing.ts`):
|
||||
*
|
||||
* 1. Capture re-derives each item's `signedAttrs` digest under the
|
||||
* session-pinned `signingTime` and asserts it matches the prep-time hash
|
||||
* bit-for-bit. Defense in depth — the bytes are identical so a mismatch
|
||||
* means libpdf changed between prep and sign or the row was tampered
|
||||
* with. Throws `CSC_BASE_DOCUMENT_MUTATED` on divergence.
|
||||
* 2. A single batched `signatures/signHash` (§11.9) returns position-ordered
|
||||
* signatures that the embed pass writes back into the same anchors via
|
||||
* `CscFifoSigner`.
|
||||
*
|
||||
* Output bytes are in-place-copied onto `envelopeItem.documentData` (the
|
||||
* row id stays stable; only `type` + `data` change) — same pattern as
|
||||
* `materializeTspAnchorsForEnvelope`. The uploaded rows from
|
||||
* `putPdfFileServerSide` orbit as orphans.
|
||||
*
|
||||
* Persistence is bundled into one outer transaction so document-content
|
||||
* updates, recipient signing-status, audit log, and session consume commit
|
||||
* atomically. Post-tx side effects (webhooks, emails) run after.
|
||||
*/
|
||||
|
||||
export type ExecuteTspSignOptions = {
|
||||
sessionId: string;
|
||||
recipientToken: string;
|
||||
requestMetadata?: RequestMetadata;
|
||||
};
|
||||
|
||||
export type ExecuteTspSignResult = { outcome: 'signed' } | { outcome: 'already_signed' };
|
||||
|
||||
type CapturedItem = {
|
||||
envelopeItemId: string;
|
||||
recapturedDigestB64: string;
|
||||
anchorName: string;
|
||||
pdfBytes: Uint8Array;
|
||||
};
|
||||
|
||||
type SignedItemDataUpdate = {
|
||||
/** Existing `envelopeItem.documentDataId` — receives the in-place data update. */
|
||||
envelopeItemDataId: string;
|
||||
/** Payload to copy onto the existing row. */
|
||||
uploadedType: DocumentDataType;
|
||||
uploadedData: string;
|
||||
};
|
||||
|
||||
export const executeTspSign = async (opts: ExecuteTspSignOptions): Promise<ExecuteTspSignResult> => {
|
||||
const { sessionId, recipientToken, requestMetadata } = opts;
|
||||
|
||||
const session = await loadCscSession(sessionId);
|
||||
|
||||
if (!session) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: `CSC session "${sessionId}" not found.`,
|
||||
});
|
||||
}
|
||||
|
||||
const recipient = await getRecipientByToken({ token: recipientToken }).catch(() => null);
|
||||
|
||||
if (!recipient) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: `Recipient with token "${recipientToken}" not found.`,
|
||||
});
|
||||
}
|
||||
|
||||
if (recipient.id !== session.recipientId) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'CSC session does not belong to the recipient identified by token.',
|
||||
});
|
||||
}
|
||||
|
||||
// Idempotency: a 15s tRPC timeout that races with a successful sign can
|
||||
// leave the client retrying after the recipient row already flipped to
|
||||
// SIGNED. Return success rather than re-running.
|
||||
if (recipient.signingStatus === SigningStatus.SIGNED) {
|
||||
return { outcome: 'already_signed' };
|
||||
}
|
||||
|
||||
if (!session.encryptedSad || !session.sadExpiresAt) {
|
||||
throw new AppError(AppErrorCode.CSC_SAD_EXPIRED_PRE_SIGN, {
|
||||
message: 'CSC session has no attached SAD — credential-scope OAuth must complete first.',
|
||||
});
|
||||
}
|
||||
|
||||
if (session.sadExpiresAt.getTime() <= Date.now()) {
|
||||
throw new AppError(AppErrorCode.CSC_SAD_EXPIRED_PRE_SIGN, {
|
||||
message: 'CSC SAD expired before sign-time execution.',
|
||||
});
|
||||
}
|
||||
|
||||
const sad = decryptCscToken(session.encryptedSad);
|
||||
|
||||
if (!sad) {
|
||||
throw new AppError(AppErrorCode.CSC_SAD_EXPIRED_PRE_SIGN, {
|
||||
message: 'CSC SAD decrypt failed — key rotation or row corruption.',
|
||||
});
|
||||
}
|
||||
|
||||
const credential = await loadCscCredential(recipient.id);
|
||||
|
||||
if (!credential) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'CSC credential missing at sign time.',
|
||||
});
|
||||
}
|
||||
|
||||
if (!credential.certCache) {
|
||||
throw new AppError(AppErrorCode.CSC_CERT_INVALID, {
|
||||
message: 'CSC credential has no persisted certificate chain.',
|
||||
});
|
||||
}
|
||||
|
||||
if (credential.keyLenBits === null) {
|
||||
throw new AppError(AppErrorCode.CSC_ALGORITHM_REFUSED, {
|
||||
message: 'CSC credential omits persisted keyLenBits — service-scope OAuth must re-run.',
|
||||
});
|
||||
}
|
||||
|
||||
if (!credential.serviceTokenCiphertext || !credential.serviceTokenExpiresAt) {
|
||||
throw new AppError(AppErrorCode.CSC_REQUEST_FAILED, {
|
||||
message: 'CSC credential has no persisted service token — recipient must re-auth.',
|
||||
});
|
||||
}
|
||||
|
||||
if (credential.serviceTokenExpiresAt.getTime() <= Date.now()) {
|
||||
throw new AppError(AppErrorCode.CSC_REQUEST_FAILED, {
|
||||
message: 'CSC service token expired — recipient must re-auth via service-scope OAuth.',
|
||||
});
|
||||
}
|
||||
|
||||
const serviceToken = decryptCscToken(credential.serviceTokenCiphertext);
|
||||
|
||||
if (!serviceToken) {
|
||||
throw new AppError(AppErrorCode.CSC_REQUEST_FAILED, {
|
||||
message: 'CSC service token decrypt failed — operator re-auth required.',
|
||||
});
|
||||
}
|
||||
|
||||
const chain = decodeCscCertChain(credential.certCache);
|
||||
|
||||
const algo = policyToLibpdfSignerAlgo({
|
||||
keyType: credential.keyType as 'RSA' | 'ECDSA',
|
||||
digestAlgorithm: credential.digestAlgorithm as CscDigest,
|
||||
signAlgoOid: credential.signatureAlgorithm,
|
||||
keyLenBits: credential.keyLenBits,
|
||||
hashAlgoOid: '',
|
||||
});
|
||||
|
||||
const envelope = await prisma.envelope.findUniqueOrThrow({
|
||||
where: { id: session.envelopeId },
|
||||
include: {
|
||||
envelopeItems: { include: { documentData: true } },
|
||||
recipients: true,
|
||||
documentMeta: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Capture pass: iterate session.items in order so the resulting hash array
|
||||
// is position-bound to session.items[*].ordinal.
|
||||
const capturedItems: CapturedItem[] = [];
|
||||
|
||||
for (let i = 0; i < session.items.length; i++) {
|
||||
const sessionItem = session.items[i];
|
||||
|
||||
const envelopeItem = envelope.envelopeItems.find((item) => item.id === sessionItem.envelopeItemId);
|
||||
|
||||
if (!envelopeItem) {
|
||||
throw new AppError(AppErrorCode.CSC_BASE_DOCUMENT_MUTATED, {
|
||||
message: `Session references envelope item "${sessionItem.envelopeItemId}" not on envelope.`,
|
||||
});
|
||||
}
|
||||
|
||||
const pinnedDocumentData = await prisma.documentData.findUniqueOrThrow({
|
||||
where: { id: sessionItem.documentDataId },
|
||||
});
|
||||
|
||||
const bytes = await getFileServerSide(pinnedDocumentData);
|
||||
const pdfDoc = await PDF.load(bytes);
|
||||
|
||||
const captureSigner = new CscCaptureSigner({
|
||||
certificate: chain[0],
|
||||
certificateChain: chain.slice(1),
|
||||
algo,
|
||||
});
|
||||
|
||||
const anchorName = buildTspAnchorName(recipient.id, envelopeItem.id);
|
||||
|
||||
// Capture pass stays at B-B even though the embed pass below is B-T:
|
||||
// libpdf's B-T signature timestamp is added as a CMS *unsigned*
|
||||
// attribute *after* `signer.sign()` runs over the signed-attrs digest.
|
||||
// The signed-attrs builder (see CAdESDetachedBuilder.create in
|
||||
// @libpdf/core) takes only (signer, documentHash, digestAlgorithm,
|
||||
// signingTime) — no level-conditional attributes — so B-B and B-T
|
||||
// produce byte-identical signed-attrs for the same inputs. Capturing
|
||||
// at B-B avoids dragging the TSA into the dry-run.
|
||||
await pdfDoc.sign({
|
||||
signer: captureSigner,
|
||||
fieldName: anchorName,
|
||||
signingTime: session.signingTime,
|
||||
level: 'B-B',
|
||||
digestAlgorithm: algo.digestAlgorithm,
|
||||
});
|
||||
|
||||
if (captureSigner.capturedDigest === null) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'CscCaptureSigner was not invoked by pdf.sign during sign-time capture.',
|
||||
});
|
||||
}
|
||||
|
||||
const recapturedDigestB64 = Buffer.from(captureSigner.capturedDigest).toString('base64');
|
||||
|
||||
if (recapturedDigestB64 !== sessionItem.hashB64) {
|
||||
throw new AppError(AppErrorCode.CSC_BASE_DOCUMENT_MUTATED, {
|
||||
message: `Re-derived signedAttrs digest at sign time diverged from prep-time hash for envelope item "${envelopeItem.id}".`,
|
||||
});
|
||||
}
|
||||
|
||||
capturedItems.push({
|
||||
envelopeItemId: envelopeItem.id,
|
||||
recapturedDigestB64,
|
||||
anchorName,
|
||||
pdfBytes: bytes,
|
||||
});
|
||||
}
|
||||
|
||||
// Defensive: session-item / captured-item position binding must hold.
|
||||
for (let i = 0; i < capturedItems.length; i++) {
|
||||
if (capturedItems[i].envelopeItemId !== session.items[i].envelopeItemId) {
|
||||
throw new AppError(AppErrorCode.CSC_EMBED_FAILED, {
|
||||
message: 'Capture-pass item ordering diverged from session-pinned ordering.',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (capturedItems.length === 0) {
|
||||
throw new AppError(AppErrorCode.CSC_EMBED_FAILED, {
|
||||
message: 'CSC session contains no items — nothing to sign.',
|
||||
});
|
||||
}
|
||||
|
||||
const hashes = capturedItems.map((c) => c.recapturedDigestB64);
|
||||
// The cscSignHash request schema requires a non-empty tuple; the explicit
|
||||
// check above narrows the array literal for the type system.
|
||||
const [firstHash, ...restHashes] = hashes;
|
||||
|
||||
const transport = await getCscTransport();
|
||||
|
||||
const signHashResp = await cscSignHash({
|
||||
baseUrl: transport.serviceBaseUrl,
|
||||
accessToken: serviceToken,
|
||||
credentialID: credential.credentialId,
|
||||
SAD: sad,
|
||||
hash: [firstHash, ...restHashes],
|
||||
signAlgo: credential.signatureAlgorithm,
|
||||
hashAlgo: hashOidForDigest(algo.digestAlgorithm),
|
||||
});
|
||||
|
||||
if (signHashResp.signatures.length !== capturedItems.length) {
|
||||
throw new AppError(AppErrorCode.CSC_EMBED_FAILED, {
|
||||
message: `CSC signHash returned ${signHashResp.signatures.length} signatures for ${capturedItems.length} hashes.`,
|
||||
});
|
||||
}
|
||||
|
||||
// Embed pass: per-item, reload the same prep-persisted PDF bytes and sign
|
||||
// with a single-signature FIFO signer. No re-render — bytes are exactly
|
||||
// the ones whose digest the TSP just authorised. Level is B-T: each
|
||||
// recipient's CMS gets a TSA-attested signature timestamp embedded as an
|
||||
// unsigned attribute, binding proven time to the signature itself (the
|
||||
// actual eIDAS AES/QES requirement). The TSA is resolved per-recipient
|
||||
// via the sign-time resolver — TSP if advertised (authorised with this
|
||||
// recipient's service-scope bearer), env otherwise.
|
||||
const timestampAuthority = resolveCscSignTimeTsa(transport, serviceToken);
|
||||
|
||||
const signedItemDataUpdates: SignedItemDataUpdate[] = [];
|
||||
|
||||
for (let i = 0; i < capturedItems.length; i++) {
|
||||
const captured = capturedItems[i];
|
||||
const sigBytes = Buffer.from(signHashResp.signatures[i], 'base64');
|
||||
|
||||
const pdfDoc = await PDF.load(captured.pdfBytes);
|
||||
|
||||
const fifoSigner = new CscFifoSigner({
|
||||
certificate: chain[0],
|
||||
certificateChain: chain.slice(1),
|
||||
algo,
|
||||
signatures: [sigBytes],
|
||||
});
|
||||
|
||||
const signResult = await pdfDoc.sign({
|
||||
signer: fifoSigner,
|
||||
fieldName: captured.anchorName,
|
||||
signingTime: session.signingTime,
|
||||
level: 'B-T',
|
||||
timestampAuthority,
|
||||
digestAlgorithm: algo.digestAlgorithm,
|
||||
});
|
||||
|
||||
const envelopeItem = envelope.envelopeItems.find((item) => item.id === captured.envelopeItemId);
|
||||
|
||||
if (!envelopeItem) {
|
||||
throw new AppError(AppErrorCode.CSC_EMBED_FAILED, {
|
||||
message: `Envelope item "${captured.envelopeItemId}" missing during embed pass.`,
|
||||
});
|
||||
}
|
||||
|
||||
const fileName = envelope.title.endsWith('.pdf') ? envelope.title : `${envelope.title || 'envelope'}.pdf`;
|
||||
|
||||
const uploaded = await putPdfFileServerSide(
|
||||
{
|
||||
name: fileName,
|
||||
type: 'application/pdf',
|
||||
arrayBuffer: async () => Promise.resolve(signResult.bytes),
|
||||
},
|
||||
envelopeItem.documentData.initialData ?? undefined,
|
||||
);
|
||||
|
||||
// In-place data update target: the existing envelopeItem.documentDataId
|
||||
// row. `uploaded.documentData` is the freshly-created row whose payload
|
||||
// we'll copy on; that row stays orphan after the copy. Mirrors the
|
||||
// `materializeTspAnchorsForEnvelope` pattern.
|
||||
signedItemDataUpdates.push({
|
||||
envelopeItemDataId: envelopeItem.documentDataId,
|
||||
uploadedType: uploaded.documentData.type,
|
||||
uploadedData: uploaded.documentData.data,
|
||||
});
|
||||
}
|
||||
|
||||
const legacyDocumentId = mapSecondaryIdToDocumentId(envelope.secondaryId);
|
||||
|
||||
// Single tx: per-item in-place data updates + recipient flip + audit log +
|
||||
// session consume. Atomic across items — if any write fails, the recipient
|
||||
// stays unsigned and the session row stays attached. `envelopeItem.
|
||||
// documentDataId` is preserved across the run; only `documentData.{type,
|
||||
// data}` changes. Mirrors `materializeTspAnchorsForEnvelope`.
|
||||
await prisma.$transaction(async (tx) => {
|
||||
for (const { envelopeItemDataId, uploadedType, uploadedData } of signedItemDataUpdates) {
|
||||
await tx.documentData.update({
|
||||
where: { id: envelopeItemDataId },
|
||||
data: { type: uploadedType, data: uploadedData },
|
||||
});
|
||||
}
|
||||
|
||||
await tx.recipient.update({
|
||||
where: { id: recipient.id },
|
||||
data: {
|
||||
signingStatus: SigningStatus.SIGNED,
|
||||
signedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
const authOptions = extractDocumentAuthMethods({
|
||||
documentAuth: envelope.authOptions,
|
||||
recipientAuth: recipient.authOptions,
|
||||
});
|
||||
|
||||
await tx.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED,
|
||||
envelopeId: envelope.id,
|
||||
user: {
|
||||
name: recipient.name,
|
||||
email: recipient.email,
|
||||
},
|
||||
requestMetadata,
|
||||
data: {
|
||||
recipientEmail: recipient.email,
|
||||
recipientName: recipient.name,
|
||||
recipientId: recipient.id,
|
||||
recipientRole: recipient.role,
|
||||
actionAuth: authOptions.derivedRecipientActionAuth,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
await tx.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_CSC_SIGNED,
|
||||
envelopeId: envelope.id,
|
||||
user: { name: recipient.name, email: recipient.email },
|
||||
requestMetadata,
|
||||
data: {
|
||||
recipientEmail: recipient.email,
|
||||
recipientName: recipient.name,
|
||||
recipientId: recipient.id,
|
||||
recipientRole: recipient.role,
|
||||
providerId: credential.providerId,
|
||||
credentialId: credential.credentialId,
|
||||
sessionId,
|
||||
numItemsSigned: signedItemDataUpdates.length,
|
||||
signatureAlgorithm: credential.signatureAlgorithm,
|
||||
digestAlgorithm: credential.digestAlgorithm,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
await consumeCscSession(sessionId, tx);
|
||||
});
|
||||
|
||||
// Post-tx side effects (webhooks, emails, next-signer advancement, seal
|
||||
// job dispatch). Inlined rather than shared with the SES completion path —
|
||||
// the in-tx shape diverges enough (TSP swaps documentDataIds + consumes
|
||||
// the CSC session; SES doesn't) that a shared helper would obscure both.
|
||||
const envelopeWithRelations = await prisma.envelope.findUniqueOrThrow({
|
||||
where: { id: envelope.id },
|
||||
include: { documentMeta: true, recipients: true },
|
||||
});
|
||||
|
||||
await triggerWebhook({
|
||||
event: WebhookTriggerEvents.DOCUMENT_RECIPIENT_COMPLETED,
|
||||
data: ZWebhookDocumentSchema.parse(mapEnvelopeToWebhookDocumentPayload(envelopeWithRelations)),
|
||||
userId: envelope.userId,
|
||||
teamId: envelope.teamId,
|
||||
});
|
||||
|
||||
await jobs.triggerJob({
|
||||
name: 'send.recipient.signed.email',
|
||||
payload: {
|
||||
documentId: legacyDocumentId,
|
||||
recipientId: recipient.id,
|
||||
},
|
||||
});
|
||||
|
||||
const pendingRecipients = await prisma.recipient.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
signingOrder: true,
|
||||
role: true,
|
||||
},
|
||||
where: {
|
||||
envelopeId: envelope.id,
|
||||
signingStatus: { not: SigningStatus.SIGNED },
|
||||
role: { not: RecipientRole.CC },
|
||||
},
|
||||
orderBy: [{ signingOrder: { sort: 'asc', nulls: 'last' } }, { id: 'asc' }],
|
||||
});
|
||||
|
||||
if (pendingRecipients.length > 0) {
|
||||
await sendPendingEmail({
|
||||
id: { type: 'envelopeId', id: envelope.id },
|
||||
recipientId: recipient.id,
|
||||
});
|
||||
|
||||
// TSP envelopes are forced SEQUENTIAL at send-time; this branch always
|
||||
// fires when pending recipients exist. No `nextSigner` dictation path
|
||||
// — `prepareCscRecipientSigning` doesn't accept one.
|
||||
const [nextRecipient] = pendingRecipients;
|
||||
|
||||
await prisma.recipient.update({
|
||||
where: { id: nextRecipient.id },
|
||||
data: {
|
||||
sendStatus: SendStatus.SENT,
|
||||
sentAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
await jobs.triggerJob({
|
||||
name: 'send.signing.requested.email',
|
||||
payload: {
|
||||
userId: envelope.userId,
|
||||
documentId: legacyDocumentId,
|
||||
recipientId: nextRecipient.id,
|
||||
requestMetadata,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const haveAllRecipientsSigned = await prisma.envelope.findFirst({
|
||||
where: {
|
||||
id: envelope.id,
|
||||
recipients: {
|
||||
every: {
|
||||
OR: [{ signingStatus: SigningStatus.SIGNED }, { role: RecipientRole.CC }],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (haveAllRecipientsSigned) {
|
||||
await jobs.triggerJob({
|
||||
name: 'internal.seal-document',
|
||||
payload: {
|
||||
documentId: legacyDocumentId,
|
||||
requestMetadata,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const updatedDocument = await prisma.envelope.findFirstOrThrow({
|
||||
where: {
|
||||
id: envelope.id,
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
},
|
||||
include: {
|
||||
documentMeta: true,
|
||||
recipients: true,
|
||||
},
|
||||
});
|
||||
|
||||
await triggerWebhook({
|
||||
event: WebhookTriggerEvents.DOCUMENT_SIGNED,
|
||||
data: ZWebhookDocumentSchema.parse(mapEnvelopeToWebhookDocumentPayload(updatedDocument)),
|
||||
userId: updatedDocument.userId,
|
||||
teamId: updatedDocument.teamId ?? undefined,
|
||||
});
|
||||
|
||||
return { outcome: 'signed' };
|
||||
};
|
||||
@@ -0,0 +1,130 @@
|
||||
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||
import { getFileServerSide } from '@documenso/lib/universal/upload/get-file.server';
|
||||
import { putPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server';
|
||||
import type { CreateDocumentAuditLogDataResponse } from '@documenso/lib/utils/document-audit-logs';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { HttpTimestampAuthority, PDF, type TimestampAuthority } from '@libpdf/core';
|
||||
import type { DocumentData, DocumentMeta, Envelope, EnvelopeItem, Recipient, User } from '@prisma/client';
|
||||
import { DocumentStatus } from '@prisma/client';
|
||||
|
||||
import { resolveCscSealTimeTsa } from './tsa-resolver';
|
||||
|
||||
/**
|
||||
* TSP envelope finalisation step run from the `seal-document` job.
|
||||
*
|
||||
* Replaces the SES "decorate + p12 sign" pass: recipient bytes are already
|
||||
* PAdES-signed by each recipient's CSC TSP, so the seal step is reduced to
|
||||
* a per-item PAdES B-LTA upgrade — libpdf's `pdf.addArchivalData()` runs
|
||||
* the full archive sequence (DSS for every existing signature + archival
|
||||
* `/DocTimeStamp` + DSS for the timestamp's own chain), and the resulting
|
||||
* bytes are copied in-place onto each `envelopeItem.documentData` row.
|
||||
* `envelopeItem.documentDataId` stays stable across the whole envelope
|
||||
* lifecycle (materialise → per-recipient signs → finalise) — mirrors the
|
||||
* pattern used by `materializeTspAnchorsForEnvelope` and `executeTspSign`.
|
||||
*
|
||||
* Certificate / audit-log sidecar PDFs are intentionally NOT merged into
|
||||
* the signed bytes here — they're rendered on-demand at download time so
|
||||
* the signed PDF stays byte-identical to what each recipient's SAD
|
||||
* authorised. Rejection and resealing are unsupported in V1 and rejected
|
||||
* by the caller before this runs.
|
||||
*/
|
||||
|
||||
export type FinalizeTspEnvelopeCompletionOptions = {
|
||||
envelope: Envelope & {
|
||||
documentMeta: DocumentMeta | null;
|
||||
recipients: Recipient[];
|
||||
envelopeItems: Array<EnvelopeItem & { documentData: DocumentData }>;
|
||||
user: Pick<User, 'name' | 'email'>;
|
||||
};
|
||||
envelopeCompletedAuditLog: CreateDocumentAuditLogDataResponse;
|
||||
requestMetadata?: RequestMetadata;
|
||||
};
|
||||
|
||||
type ArchivedItem = {
|
||||
/** Existing `envelopeItem.documentDataId` — target of the in-place update. */
|
||||
envelopeItemDataId: string;
|
||||
uploadedType: DocumentData['type'];
|
||||
uploadedData: string;
|
||||
};
|
||||
|
||||
export const finalizeTspEnvelopeCompletion = async (opts: FinalizeTspEnvelopeCompletionOptions): Promise<void> => {
|
||||
const { envelope, envelopeCompletedAuditLog } = opts;
|
||||
|
||||
// Resolve the TSA up-front — fail fast if the instance is mis-configured
|
||||
// before we start round-tripping PDF bytes through storage.
|
||||
const tsa = resolveCscSealTimeTsa();
|
||||
const timestampAuthority = buildLibpdfTsa(tsa);
|
||||
|
||||
const archivedItems: ArchivedItem[] = [];
|
||||
|
||||
for (const envelopeItem of envelope.envelopeItems) {
|
||||
const pdfBytes = await getFileServerSide(envelopeItem.documentData);
|
||||
const pdfDoc = await PDF.load(pdfBytes);
|
||||
|
||||
// PAdES B-LTA in one call. Internally:
|
||||
// 1. Gather LTV (certs/OCSP/CRL) for every existing signed field and
|
||||
// write a single DSS incremental update.
|
||||
// 2. Add an archival `/DocTimeStamp` over the result.
|
||||
// 3. Gather LTV for the new timestamp's own certificate chain.
|
||||
// All three are append-only incremental updates — every prior recipient
|
||||
// signature's `/ByteRange` stays valid.
|
||||
const archived = await pdfDoc.addArchivalData({ timestampAuthority });
|
||||
|
||||
const { documentData: uploaded } = await putPdfFileServerSide(
|
||||
{
|
||||
name: envelopeItem.title.endsWith('.pdf') ? envelopeItem.title : `${envelopeItem.title}.pdf`,
|
||||
type: 'application/pdf',
|
||||
arrayBuffer: async () => Promise.resolve(archived.bytes),
|
||||
},
|
||||
envelopeItem.documentData.initialData,
|
||||
);
|
||||
|
||||
archivedItems.push({
|
||||
envelopeItemDataId: envelopeItem.documentData.id,
|
||||
uploadedType: uploaded.type,
|
||||
uploadedData: uploaded.data,
|
||||
});
|
||||
}
|
||||
|
||||
// Single tx: per-item in-place data updates + envelope status flip +
|
||||
// completion audit log. `envelopeItem.documentDataId` is preserved; the
|
||||
// freshly-uploaded `DocumentData` rows orbit as orphans.
|
||||
await prisma.$transaction(async (tx) => {
|
||||
for (const { envelopeItemDataId, uploadedType, uploadedData } of archivedItems) {
|
||||
await tx.documentData.update({
|
||||
where: { id: envelopeItemDataId },
|
||||
data: { type: uploadedType, data: uploadedData },
|
||||
});
|
||||
}
|
||||
|
||||
await tx.envelope.update({
|
||||
where: { id: envelope.id },
|
||||
data: {
|
||||
status: DocumentStatus.COMPLETED,
|
||||
completedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
await tx.documentAuditLog.create({
|
||||
data: envelopeCompletedAuditLog,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Wrap a resolved seal-time TSA config into a libpdf `TimestampAuthority`.
|
||||
*
|
||||
* Env only at seal time — the archival `/DocTimeStamp` is the operator's
|
||||
* long-term trust anchor and SHOULD point at a dedicated qualified archival
|
||||
* TSA (e.g. DigiCert) that's independent of the per-recipient TSP. We
|
||||
* deliberately don't fall back to the TSP here: doing so would couple the
|
||||
* archive's longevity to a TSP that may revoke or rotate without notice,
|
||||
* and would require keeping a live service-scope bearer around at the
|
||||
* seal-document job which has no recipient context anyway.
|
||||
*
|
||||
* First URL only — multi-URL fallback can layer on later via a composite
|
||||
* wrapper if operators need it.
|
||||
*/
|
||||
const buildLibpdfTsa = (tsa: { urls: string[] }): TimestampAuthority => {
|
||||
return new HttpTimestampAuthority(tsa.urls[0]);
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { logger } from '@documenso/lib/utils/logger';
|
||||
|
||||
/**
|
||||
* CSC subapp Hono context. Mirrors the subset of `apps/remix/server/router.ts`
|
||||
* `HonoEnv` that CSC handlers actually read. Duplicated (rather than imported
|
||||
* from `apps/remix/`) to keep the `packages/ee` → `apps/remix` dep direction
|
||||
* unidirectional.
|
||||
*
|
||||
* Runtime contract: the remix host's middleware sets `logger` on every request
|
||||
* before the CSC subapp runs; the CSC subapp does not set it itself.
|
||||
*/
|
||||
export type HonoCscEnv = {
|
||||
Variables: {
|
||||
logger: typeof logger;
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { Hono } from 'hono';
|
||||
import { HTTPException } from 'hono/http-exception';
|
||||
import type { ContentfulStatusCode } from 'hono/utils/http-status';
|
||||
|
||||
import type { HonoCscEnv } from './context';
|
||||
import { cscOAuthAuthorizeRoute } from './oauth-authorize';
|
||||
import { cscOAuthCallbackRoute } from './oauth-callback';
|
||||
|
||||
/**
|
||||
* `@documenso/ee` CSC subapp. Mount under `/api/csc` in the remix host (see
|
||||
* `apps/remix/server/router.ts`). All CSC endpoints — OAuth authorize +
|
||||
* callback — are composed here so the host only has to wire one route.
|
||||
*
|
||||
* Routes throw `AppError` freely; the `.onError` handler below normalises
|
||||
* them into REST responses (mirrors `@documenso/auth/server`'s pattern).
|
||||
*/
|
||||
export const csc = new Hono<HonoCscEnv>()
|
||||
.route('/oauth/authorize', cscOAuthAuthorizeRoute)
|
||||
.route('/oauth/callback', cscOAuthCallbackRoute);
|
||||
|
||||
csc.onError((err, c) => {
|
||||
const logger = c.get('logger');
|
||||
|
||||
if (err instanceof HTTPException) {
|
||||
return c.json(
|
||||
{
|
||||
code: AppErrorCode.UNKNOWN_ERROR,
|
||||
message: err.message,
|
||||
statusCode: err.status,
|
||||
},
|
||||
err.status,
|
||||
);
|
||||
}
|
||||
|
||||
if (err instanceof AppError) {
|
||||
const { status, body } = AppError.toRestAPIError(err);
|
||||
|
||||
logger.error({
|
||||
event: 'csc.error',
|
||||
code: err.code,
|
||||
message: err.message,
|
||||
});
|
||||
|
||||
return c.json(body, status as ContentfulStatusCode);
|
||||
}
|
||||
|
||||
logger.error({
|
||||
event: 'csc.unknown_error',
|
||||
error: err,
|
||||
});
|
||||
|
||||
return c.json(
|
||||
{
|
||||
code: AppErrorCode.UNKNOWN_ERROR,
|
||||
message: 'Internal Server Error',
|
||||
statusCode: 500,
|
||||
},
|
||||
500,
|
||||
);
|
||||
});
|
||||
|
||||
export type CscAppType = typeof csc;
|
||||
@@ -0,0 +1,154 @@
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { sValidator } from '@hono/standard-validator';
|
||||
import { Hono } from 'hono';
|
||||
import { z } from 'zod';
|
||||
|
||||
import {
|
||||
buildCscCredentialScopeAuthorizeUrl,
|
||||
buildCscServiceScopeAuthorizeUrl,
|
||||
generateCodeVerifier,
|
||||
generateState,
|
||||
} from '../client/oauth';
|
||||
import { setCscOAuthFlowCookie } from '../cookies/oauth-flow-cookie';
|
||||
import { loadCscCredential } from '../credential';
|
||||
import { loadCscSession } from '../sign-session';
|
||||
import { getCscTransport } from '../transport';
|
||||
import type { HonoCscEnv } from './context';
|
||||
|
||||
/**
|
||||
* `GET /api/csc/oauth/authorize` — initiates the CSC OAuth round-trip and
|
||||
* 302-redirects to the TSP's authorize URL with a signed `csc_oauth_flow`
|
||||
* cookie carrying the state, PKCE verifier, and recipient context the
|
||||
* callback needs to resume the flow.
|
||||
*
|
||||
* Branches on `?scope=service|credential`:
|
||||
* - `service`: authorised by recipient token; precedes credentials/list.
|
||||
* - `credential`: authorised by an active `CscSession`; binds the issued SAD
|
||||
* to the per-item hashes captured at prep.
|
||||
*
|
||||
* Errors bubble to the parent app's `.onError` handler (see `./index.ts`).
|
||||
*/
|
||||
|
||||
const ZAuthorizeQuerySchema = z.discriminatedUnion('scope', [
|
||||
z.object({
|
||||
scope: z.literal('service'),
|
||||
token: z.string().min(1),
|
||||
}),
|
||||
z.object({
|
||||
scope: z.literal('credential'),
|
||||
session: z.string().min(1),
|
||||
}),
|
||||
]);
|
||||
|
||||
export const cscOAuthAuthorizeRoute = new Hono<HonoCscEnv>().get(
|
||||
'/',
|
||||
sValidator('query', ZAuthorizeQuerySchema),
|
||||
async (c) => {
|
||||
const logger = c.get('logger');
|
||||
|
||||
const query = c.req.valid('query');
|
||||
|
||||
const transport = await getCscTransport();
|
||||
|
||||
if (query.scope === 'service') {
|
||||
const recipient = await getRecipientByToken({ token: query.token }).catch(() => null);
|
||||
|
||||
if (!recipient) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Recipient not found for the provided token.',
|
||||
});
|
||||
}
|
||||
|
||||
logger.info({
|
||||
event: 'csc.oauth.authorize.start',
|
||||
scope: 'service',
|
||||
recipientId: recipient.id,
|
||||
});
|
||||
|
||||
const state = generateState();
|
||||
const codeVerifier = generateCodeVerifier();
|
||||
|
||||
const authorizeUrl = buildCscServiceScopeAuthorizeUrl({
|
||||
client: transport.oauthClient,
|
||||
oauthBaseUrl: transport.oauthBaseUrl,
|
||||
state,
|
||||
codeVerifier,
|
||||
});
|
||||
|
||||
await setCscOAuthFlowCookie({
|
||||
c,
|
||||
payload: {
|
||||
scope: 'service',
|
||||
state,
|
||||
codeVerifier,
|
||||
recipientToken: query.token,
|
||||
},
|
||||
});
|
||||
|
||||
return c.redirect(authorizeUrl.toString(), 302);
|
||||
}
|
||||
|
||||
const session = await loadCscSession(query.session);
|
||||
|
||||
if (!session) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'CSC session not found or already consumed.',
|
||||
});
|
||||
}
|
||||
|
||||
const credential = await loadCscCredential(session.recipientId);
|
||||
|
||||
if (!credential) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'CSC credential missing — service-scope OAuth must complete first.',
|
||||
});
|
||||
}
|
||||
|
||||
const recipient = await prisma.recipient.findUnique({
|
||||
where: { id: session.recipientId },
|
||||
select: { token: true },
|
||||
});
|
||||
|
||||
if (!recipient) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Recipient not found for the CSC session.',
|
||||
});
|
||||
}
|
||||
|
||||
logger.info({
|
||||
event: 'csc.oauth.authorize.start',
|
||||
scope: 'credential',
|
||||
recipientId: session.recipientId,
|
||||
sessionId: session.id,
|
||||
numSignatures: session.items.length,
|
||||
});
|
||||
|
||||
const state = generateState();
|
||||
const codeVerifier = generateCodeVerifier();
|
||||
|
||||
const authorizeUrl = buildCscCredentialScopeAuthorizeUrl({
|
||||
client: transport.oauthClient,
|
||||
oauthBaseUrl: transport.oauthBaseUrl,
|
||||
state,
|
||||
codeVerifier,
|
||||
credentialId: credential.credentialId,
|
||||
numSignatures: session.items.length,
|
||||
hashes: session.items.map((item) => item.hashB64),
|
||||
});
|
||||
|
||||
await setCscOAuthFlowCookie({
|
||||
c,
|
||||
payload: {
|
||||
scope: 'credential',
|
||||
state,
|
||||
codeVerifier,
|
||||
recipientToken: recipient.token,
|
||||
sessionId: session.id,
|
||||
},
|
||||
});
|
||||
|
||||
return c.redirect(authorizeUrl.toString(), 302);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,303 @@
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
||||
import { extractRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
|
||||
import { formatSigningLink } from '@documenso/lib/utils/recipients';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { sValidator } from '@hono/standard-validator';
|
||||
import { Hono } from 'hono';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { resolveCscAlgorithmPolicy } from '../algorithm-resolver';
|
||||
import { encodeCscCertChain } from '../cert-chain';
|
||||
import { encryptCscToken } from '../ciphers';
|
||||
import { cscCredentialsInfo, cscCredentialsList } from '../client/credentials';
|
||||
import { exchangeCscAuthorizationCode } from '../client/oauth';
|
||||
import { setCscBlockingErrorCookie } from '../cookies/blocking-error-cookie';
|
||||
import { clearCscOAuthFlowCookie, getCscOAuthFlowCookie } from '../cookies/oauth-flow-cookie';
|
||||
import { setCscSadSessionCookie } from '../cookies/sad-session-cookie';
|
||||
import { setCscServiceSessionCookie } from '../cookies/service-session-cookie';
|
||||
import { loadCscCredential, upsertCscCredential } from '../credential';
|
||||
import { updateCscSessionWithSad } from '../sign-session';
|
||||
import { getCscTransport } from '../transport';
|
||||
import type { HonoCscEnv } from './context';
|
||||
|
||||
/**
|
||||
* `GET /api/csc/oauth/callback` — landing point for the recipient's return
|
||||
* from the TSP after the round-trip initiated by `oauth-authorize`. Reads
|
||||
* the `csc_oauth_flow` cookie, verifies CSRF, exchanges the code, and
|
||||
* branches on the cookie's `scope`:
|
||||
*
|
||||
* - `service`: pulls `credentials/list` + `credentials/info`, validates the
|
||||
* cert + algorithm policy, persists the `CscCredential` row + service
|
||||
* token, sets the `csc_service_session` cookie, and redirects to
|
||||
* `/sign/{token}`. Blocking validation errors (empty list, bad cert,
|
||||
* refused algorithm) round-trip via the `csc_blocking_error` cookie so the
|
||||
* signing-page loader can render a stable error UI.
|
||||
* - `credential`: exchanges code → SAD, stamps it onto the existing
|
||||
* `CscSession`, sets the `csc_sad_session` cookie, and redirects to
|
||||
* `/sign/{token}`. Credential-scope failures bubble to `.onError` — the
|
||||
* recipient simply re-clicks Sign.
|
||||
*
|
||||
* Non-blocking errors bubble to the parent app's `.onError` (see
|
||||
* `./index.ts`) — mirrors `oauth-authorize.ts`.
|
||||
*/
|
||||
|
||||
const ZCallbackQuerySchema = z.object({
|
||||
state: z.string().min(1),
|
||||
code: z.string().min(1).optional(),
|
||||
error: z.string().min(1).optional(),
|
||||
error_description: z.string().optional(),
|
||||
});
|
||||
|
||||
const BLOCKING_SERVICE_ERROR_CODES = new Set<string>([
|
||||
AppErrorCode.CSC_CREDENTIAL_LIST_EMPTY,
|
||||
AppErrorCode.CSC_CERT_INVALID,
|
||||
AppErrorCode.CSC_ALGORITHM_REFUSED,
|
||||
]);
|
||||
|
||||
const isBlockingServiceError = (code: string): boolean => BLOCKING_SERVICE_ERROR_CODES.has(code);
|
||||
|
||||
export const cscOAuthCallbackRoute = new Hono<HonoCscEnv>().get(
|
||||
'/',
|
||||
sValidator('query', ZCallbackQuerySchema),
|
||||
async (c) => {
|
||||
const logger = c.get('logger');
|
||||
|
||||
const query = c.req.valid('query');
|
||||
|
||||
const cookie = await getCscOAuthFlowCookie(c);
|
||||
|
||||
if (!cookie) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'CSC OAuth flow cookie missing or expired.',
|
||||
});
|
||||
}
|
||||
|
||||
if (query.state !== cookie.state) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'CSC OAuth callback state mismatch — possible CSRF.',
|
||||
});
|
||||
}
|
||||
|
||||
// The single-round-trip carrier is spent regardless of subsequent
|
||||
// outcome; clear it now so a retry restarts from `/api/csc/oauth/authorize`.
|
||||
clearCscOAuthFlowCookie(c);
|
||||
|
||||
if (query.error) {
|
||||
throw new AppError(AppErrorCode.CSC_REQUEST_FAILED, {
|
||||
message: `CSC TSP returned OAuth error: ${query.error}${query.error_description ? ' — ' + query.error_description : ''}`,
|
||||
});
|
||||
}
|
||||
|
||||
if (!query.code) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'CSC OAuth callback missing code parameter.',
|
||||
});
|
||||
}
|
||||
|
||||
const transport = await getCscTransport();
|
||||
|
||||
const recipient = await getRecipientByToken({ token: cookie.recipientToken }).catch(() => null);
|
||||
|
||||
if (!recipient) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Recipient not found for CSC OAuth flow cookie.',
|
||||
});
|
||||
}
|
||||
|
||||
if (cookie.scope === 'service') {
|
||||
const tokens = await exchangeCscAuthorizationCode({
|
||||
client: transport.oauthClient,
|
||||
oauthBaseUrl: transport.oauthBaseUrl,
|
||||
code: query.code,
|
||||
codeVerifier: cookie.codeVerifier,
|
||||
});
|
||||
|
||||
try {
|
||||
const listResp = await cscCredentialsList({
|
||||
baseUrl: transport.serviceBaseUrl,
|
||||
accessToken: tokens.accessToken(),
|
||||
});
|
||||
|
||||
// V1 picks the first credential per spec section "Out of scope for
|
||||
// V1": multi-credential selection UI lands in a later iteration.
|
||||
const credentialId = listResp.credentialIDs[0];
|
||||
|
||||
const infoResp = await cscCredentialsInfo({
|
||||
baseUrl: transport.serviceBaseUrl,
|
||||
accessToken: tokens.accessToken(),
|
||||
credentialID: credentialId,
|
||||
certificates: 'chain',
|
||||
certInfo: true,
|
||||
});
|
||||
|
||||
const policy = resolveCscAlgorithmPolicy(infoResp);
|
||||
|
||||
if (!infoResp.cert.certificates || infoResp.cert.certificates.length === 0) {
|
||||
throw new AppError(AppErrorCode.CSC_CERT_INVALID, {
|
||||
message: 'CSC credential info response omitted required certificate chain.',
|
||||
});
|
||||
}
|
||||
|
||||
const certCache = encodeCscCertChain(infoResp.cert.certificates);
|
||||
const serviceTokenCiphertext = encryptCscToken(tokens.accessToken());
|
||||
const serviceTokenExpiresAt = tokens.accessTokenExpiresAt();
|
||||
|
||||
await upsertCscCredential({
|
||||
recipientId: recipient.id,
|
||||
providerId: transport.serviceBaseUrl,
|
||||
credentialId,
|
||||
certCache,
|
||||
signatureAlgorithm: policy.signAlgoOid,
|
||||
keyType: policy.keyType,
|
||||
digestAlgorithm: policy.digestAlgorithm,
|
||||
keyLenBits: policy.keyLenBits,
|
||||
serviceTokenCiphertext,
|
||||
serviceTokenExpiresAt,
|
||||
});
|
||||
|
||||
await setCscServiceSessionCookie({
|
||||
c,
|
||||
recipientToken: cookie.recipientToken,
|
||||
ttlSeconds: tokens.accessTokenExpiresInSeconds(),
|
||||
});
|
||||
|
||||
await prisma.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_CSC_AUTHENTICATED,
|
||||
envelopeId: recipient.envelopeId,
|
||||
user: { name: recipient.name, email: recipient.email },
|
||||
requestMetadata: extractRequestMetadata(c.req.raw),
|
||||
data: {
|
||||
recipientEmail: recipient.email,
|
||||
recipientName: recipient.name,
|
||||
recipientId: recipient.id,
|
||||
recipientRole: recipient.role,
|
||||
providerId: transport.serviceBaseUrl,
|
||||
credentialId,
|
||||
signatureAlgorithm: policy.signAlgoOid,
|
||||
digestAlgorithm: policy.digestAlgorithm,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
logger.info({
|
||||
event: 'csc.oauth.callback.service.complete',
|
||||
recipientId: recipient.id,
|
||||
});
|
||||
|
||||
return c.redirect(formatSigningLink(cookie.recipientToken), 302);
|
||||
} catch (err) {
|
||||
if (err instanceof AppError && isBlockingServiceError(err.code)) {
|
||||
await setCscBlockingErrorCookie({
|
||||
c,
|
||||
payload: { code: err.code, recipientToken: cookie.recipientToken },
|
||||
});
|
||||
|
||||
await prisma.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_CSC_AUTHENTICATION_FAILED,
|
||||
envelopeId: recipient.envelopeId,
|
||||
user: { name: recipient.name, email: recipient.email },
|
||||
requestMetadata: extractRequestMetadata(c.req.raw),
|
||||
data: {
|
||||
recipientEmail: recipient.email,
|
||||
recipientName: recipient.name,
|
||||
recipientId: recipient.id,
|
||||
recipientRole: recipient.role,
|
||||
providerId: transport.serviceBaseUrl,
|
||||
reason: err.code,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
logger.warn({
|
||||
event: 'csc.oauth.callback.service.blocking',
|
||||
recipientId: recipient.id,
|
||||
code: err.code,
|
||||
});
|
||||
|
||||
return c.redirect(formatSigningLink(cookie.recipientToken), 302);
|
||||
}
|
||||
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
if (!cookie.sessionId) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'CSC credential-scope OAuth callback missing sessionId in cookie.',
|
||||
});
|
||||
}
|
||||
|
||||
const tokens = await exchangeCscAuthorizationCode({
|
||||
client: transport.oauthClient,
|
||||
oauthBaseUrl: transport.oauthBaseUrl,
|
||||
code: query.code,
|
||||
codeVerifier: cookie.codeVerifier,
|
||||
});
|
||||
|
||||
// CSC §8.3.3 says credential-scope returns `token_type === 'SAD'`. We
|
||||
// don't hard-fail on a divergent label — the binding is by scope + hash,
|
||||
// not by `token_type` — but we log so operator metrics can spot loose
|
||||
// TSPs.
|
||||
if (tokens.tokenType() !== 'SAD') {
|
||||
logger.warn({
|
||||
event: 'csc.oauth.callback.credential.unexpected_token_type',
|
||||
actual: tokens.tokenType(),
|
||||
});
|
||||
}
|
||||
|
||||
const sadCiphertext = encryptCscToken(tokens.accessToken());
|
||||
const sadExpiresAt = tokens.accessTokenExpiresAt();
|
||||
|
||||
await updateCscSessionWithSad({
|
||||
sessionId: cookie.sessionId,
|
||||
encryptedSad: sadCiphertext,
|
||||
sadExpiresAt,
|
||||
});
|
||||
|
||||
await setCscSadSessionCookie({
|
||||
c,
|
||||
sessionId: cookie.sessionId,
|
||||
expiresAt: sadExpiresAt,
|
||||
});
|
||||
|
||||
const credential = await loadCscCredential(recipient.id);
|
||||
|
||||
if (!credential) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'CSC credential missing at credential-scope callback.',
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_CSC_AUTHORIZED,
|
||||
envelopeId: recipient.envelopeId,
|
||||
user: { name: recipient.name, email: recipient.email },
|
||||
requestMetadata: extractRequestMetadata(c.req.raw),
|
||||
data: {
|
||||
recipientEmail: recipient.email,
|
||||
recipientName: recipient.name,
|
||||
recipientId: recipient.id,
|
||||
recipientRole: recipient.role,
|
||||
providerId: credential.providerId,
|
||||
credentialId: credential.credentialId,
|
||||
sessionId: cookie.sessionId,
|
||||
sadExpiresAt,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
logger.info({
|
||||
event: 'csc.oauth.callback.credential.complete',
|
||||
recipientId: recipient.id,
|
||||
sessionId: cookie.sessionId,
|
||||
});
|
||||
|
||||
return c.redirect(formatSigningLink(cookie.recipientToken), 302);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,230 @@
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { isTspEnvelope } from '@documenso/lib/types/signature-level';
|
||||
import { getFileServerSide } from '@documenso/lib/universal/upload/get-file.server';
|
||||
import { putPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { PDF } from '@libpdf/core';
|
||||
|
||||
import { buildTspAnchorName, buildTspStampName } from './pdf-names';
|
||||
|
||||
export type MaterializeTspAnchorsForEnvelopeOptions = {
|
||||
envelopeId: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Pre-allocate per-recipient AcroForm signature anchors and per-page `/Stamp`
|
||||
* overlay annotations on every envelope item of a TSP (AES/QES) envelope.
|
||||
*
|
||||
* Mutates the existing `DocumentData` row in place — the `envelopeItem.
|
||||
* documentDataId` pointer is preserved across materialisation. Materialise
|
||||
* is distribution housekeeping (pre-allocate fixed anchor slots before any
|
||||
* recipient signs), not a content version bump, so a pointer swap +
|
||||
* audit-log entry would mis-attribute the change. The new uploaded row
|
||||
* created by `putPdfFileServerSide` is kept as an orphan rather than
|
||||
* deleted — it preserves the standard upload mechanics (S3 PUT or BYTES_64
|
||||
* encode) without a separate "copy then drop" dance.
|
||||
*
|
||||
* Idempotent: re-runs are no-ops when every expected anchor/stamp is
|
||||
* already present. No-op for SES envelopes.
|
||||
*/
|
||||
export const materializeTspAnchorsForEnvelope = async ({
|
||||
envelopeId,
|
||||
}: MaterializeTspAnchorsForEnvelopeOptions): Promise<void> => {
|
||||
const envelope = await prisma.envelope.findUnique({
|
||||
where: {
|
||||
id: envelopeId,
|
||||
},
|
||||
include: {
|
||||
recipients: true,
|
||||
envelopeItems: {
|
||||
include: {
|
||||
documentData: true,
|
||||
},
|
||||
},
|
||||
fields: {
|
||||
select: {
|
||||
recipientId: true,
|
||||
envelopeItemId: true,
|
||||
page: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!envelope) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: `Envelope ${envelopeId} not found`,
|
||||
});
|
||||
}
|
||||
|
||||
if (!isTspEnvelope(envelope)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (envelope.recipients.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const envelopeItem of envelope.envelopeItems) {
|
||||
const expectedAnchorNames = envelope.recipients.map((recipient) =>
|
||||
buildTspAnchorName(recipient.id, envelopeItem.id),
|
||||
);
|
||||
|
||||
const expectedStampNames: string[] = [];
|
||||
|
||||
for (const recipient of envelope.recipients) {
|
||||
const pagesWithFields = new Set<number>();
|
||||
|
||||
for (const field of envelope.fields) {
|
||||
if (field.recipientId === recipient.id && field.envelopeItemId === envelopeItem.id) {
|
||||
pagesWithFields.add(field.page);
|
||||
}
|
||||
}
|
||||
|
||||
for (const page of pagesWithFields) {
|
||||
expectedStampNames.push(buildTspStampName(recipient.id, envelopeItem.id, page));
|
||||
}
|
||||
}
|
||||
|
||||
const bytes = await getFileServerSide(envelopeItem.documentData);
|
||||
const pdfDoc = await PDF.load(bytes);
|
||||
|
||||
if (isAlreadyMaterialised(pdfDoc, expectedAnchorNames, expectedStampNames)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Bake operator AcroForm, annotations and OCG layers into static graphics
|
||||
// so the materialised PDF is a deterministic surface. `skipSignatures`
|
||||
// preserves any operator-placed signature widgets and (on re-materialise)
|
||||
// the TSP anchors created previously.
|
||||
pdfDoc.flattenAll({
|
||||
form: {
|
||||
skipSignatures: true,
|
||||
},
|
||||
});
|
||||
|
||||
const form = pdfDoc.getOrCreateForm();
|
||||
|
||||
if (pdfDoc.getPageCount() === 0) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: `Envelope item ${envelopeItem.id} PDF has no pages`,
|
||||
});
|
||||
}
|
||||
|
||||
// Anchors are AcroForm signature fields with no pre-attached widget.
|
||||
// libpdf forbids `drawField` for signature fields — at sign time
|
||||
// `pdf.sign({ fieldName })` promotes the existing field dict in place
|
||||
// to a merged field/widget (Type=Annot, Subtype=Widget, P=page0,
|
||||
// Rect=[0,0,0,0]) without modifying the page object. That preserves the
|
||||
// per-recipient `/ByteRange` invariant across sequential signatures.
|
||||
for (const anchorName of expectedAnchorNames) {
|
||||
if (form.getSignatureField(anchorName)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
form.createSignatureField(anchorName);
|
||||
}
|
||||
|
||||
for (const recipient of envelope.recipients) {
|
||||
const pagesWithFields = new Set<number>();
|
||||
|
||||
for (const field of envelope.fields) {
|
||||
if (field.recipientId === recipient.id && field.envelopeItemId === envelopeItem.id) {
|
||||
pagesWithFields.add(field.page);
|
||||
}
|
||||
}
|
||||
|
||||
for (const pageNumber of pagesWithFields) {
|
||||
const stampName = buildTspStampName(recipient.id, envelopeItem.id, pageNumber);
|
||||
const page = pdfDoc.getPage(pageNumber - 1);
|
||||
|
||||
if (!page) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: `Envelope item ${envelopeItem.id} missing page ${pageNumber} referenced by field`,
|
||||
});
|
||||
}
|
||||
|
||||
const existing = page.getStampAnnotations().some((stamp) => stamp.stampName === stampName);
|
||||
|
||||
if (existing) {
|
||||
continue;
|
||||
}
|
||||
|
||||
page.addStampAnnotation({
|
||||
name: stampName,
|
||||
rect: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: page.width,
|
||||
height: page.height,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const newBytes = await pdfDoc.save({ useXRefStream: true });
|
||||
|
||||
// CRITICAL: persist via `putPdfFileServerSide` (raw). The normalised path
|
||||
// would call `form.flatten()` without `skipSignatures` and wipe anchors.
|
||||
const fileName = envelope.title.endsWith('.pdf') ? envelope.title : `${envelope.title || 'envelope'}.pdf`;
|
||||
|
||||
const uploaded = await putPdfFileServerSide(
|
||||
{
|
||||
name: fileName,
|
||||
type: 'application/pdf',
|
||||
arrayBuffer: async () => Promise.resolve(newBytes),
|
||||
},
|
||||
envelopeItem.documentData.initialData ?? undefined,
|
||||
);
|
||||
|
||||
// Copy the persisted bytes reference (S3 key or BYTES_64 payload) onto the
|
||||
// existing DocumentData row in place. `envelopeItem.documentDataId` stays
|
||||
// put — see file-level docblock for the rationale.
|
||||
await prisma.documentData.update({
|
||||
where: { id: envelopeItem.documentDataId },
|
||||
data: {
|
||||
type: uploaded.documentData.type,
|
||||
data: uploaded.documentData.data,
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Whole-item idempotency probe: returns true only when every expected anchor
|
||||
* and stamp name is already present on the loaded PDF. Partial state is
|
||||
* treated as not-materialised — the whole item is rebuilt.
|
||||
*/
|
||||
const isAlreadyMaterialised = (pdfDoc: PDF, expectedAnchorNames: string[], expectedStampNames: string[]): boolean => {
|
||||
const form = pdfDoc.getForm();
|
||||
|
||||
if (!form) {
|
||||
return expectedAnchorNames.length === 0 && expectedStampNames.length === 0;
|
||||
}
|
||||
|
||||
for (const anchorName of expectedAnchorNames) {
|
||||
if (!form.getSignatureField(anchorName)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (expectedStampNames.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const presentStampNames = new Set<string>();
|
||||
|
||||
for (let i = 0; i < pdfDoc.getPageCount(); i++) {
|
||||
const page = pdfDoc.getPage(i);
|
||||
|
||||
if (!page) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const stamp of page.getStampAnnotations()) {
|
||||
presentStampNames.add(stamp.stampName);
|
||||
}
|
||||
}
|
||||
|
||||
return expectedStampNames.every((name) => presentStampNames.has(name));
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
import { bytesToHex, utf8ToBytes } from '@noble/ciphers/utils';
|
||||
import { sha1 } from '@noble/hashes/legacy';
|
||||
|
||||
/**
|
||||
* Deterministic PDF object names for CSC TSP signing.
|
||||
*
|
||||
* Materialise-time and sign-time both derive these from the same
|
||||
* `(recipient, item [, page])` tuple — they MUST agree byte-for-byte.
|
||||
*
|
||||
* Output is opaque: SHA-1(label) hex-encoded uppercase (40 chars). The PDF
|
||||
* persists only the hex serial so recipient / envelope-item IDs never leak
|
||||
* into the document.
|
||||
*/
|
||||
|
||||
const hashToOpaqueSerial = (label: string): string => bytesToHex(sha1(utf8ToBytes(label))).toUpperCase();
|
||||
|
||||
/** AcroForm signature-field name (TSP anchor) for a recipient + envelope item. */
|
||||
export const buildTspAnchorName = (recipientId: number, envelopeItemId: string): string =>
|
||||
hashToOpaqueSerial(`recipient:${recipientId}|item:${envelopeItemId}`);
|
||||
|
||||
/** `/Stamp` annotation name for a recipient + envelope item on a specific page. */
|
||||
export const buildTspStampName = (recipientId: number, envelopeItemId: string, pageNumber: number): string =>
|
||||
hashToOpaqueSerial(`recipient:${recipientId}|item:${envelopeItemId}|page:${pageNumber}`);
|
||||
@@ -0,0 +1,248 @@
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import type { TCscSessionItems } from '@documenso/lib/types/csc-session';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
||||
import { isTspEnvelope } from '@documenso/lib/types/signature-level';
|
||||
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||
import { getFileServerSide } from '@documenso/lib/universal/upload/get-file.server';
|
||||
import { putPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server';
|
||||
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
||||
import { PDF } from '@libpdf/core';
|
||||
|
||||
import { type CscDigest, policyToLibpdfSignerAlgo } from './algorithm-resolver';
|
||||
import { decodeCscCertChain } from './cert-chain';
|
||||
import { loadCscCredential } from './credential';
|
||||
import { buildTspAnchorName, buildTspStampName } from './pdf-names';
|
||||
import { renderRecipientOverlay } from './render-overlay';
|
||||
import { upsertCscSession } from './sign-session';
|
||||
import { CscCaptureSigner } from './signers/capture-signer';
|
||||
|
||||
/**
|
||||
* CSC TSP prep-phase orchestrator.
|
||||
*
|
||||
* Per envelope item:
|
||||
*
|
||||
* 1. Render the recipient's overlay into the materialised PDF in memory.
|
||||
* 2. Persist the rendered bytes as a fresh `DocumentData` row — this is the
|
||||
* immutable byte-source the sign pass will load. Pinning the rendered PDF
|
||||
* (rather than re-rendering at sign time) eliminates the determinism risk
|
||||
* of running Konva twice across the OAuth round-trip.
|
||||
* 3. Reload `pdfDoc` from the persisted bytes and dry-run `pdf.sign` with
|
||||
* `CscCaptureSigner` to derive the `signedAttrs` digest — captured over
|
||||
* the same bytes the sign pass will load.
|
||||
*
|
||||
* The resulting `{ envelopeItemId, documentDataId, hashB64, ordinal }` tuples
|
||||
* are stored on `CscSession.itemsJson`. `documentDataId` pins the orphan
|
||||
* rendered row, not `envelopeItem.documentDataId` — the latter stays stable
|
||||
* (in-place data updates only, mirroring the materialise pattern).
|
||||
*
|
||||
* Sequential per item — PDF parse + libpdf sign is CPU-heavy and per-recipient
|
||||
* concurrency is wasted on a single Node event loop.
|
||||
*/
|
||||
|
||||
export type PrepareCscRecipientSigningOptions = {
|
||||
/** Recipient token from `/sign/{token}` URL. */
|
||||
recipientToken: string;
|
||||
/** Forwarded for audit log attribution. */
|
||||
requestMetadata?: RequestMetadata;
|
||||
};
|
||||
|
||||
export type PrepareCscRecipientSigningResult = {
|
||||
status: 'REDIRECT';
|
||||
redirectUrl: string;
|
||||
};
|
||||
|
||||
export const prepareCscRecipientSigning = async (
|
||||
opts: PrepareCscRecipientSigningOptions,
|
||||
): Promise<PrepareCscRecipientSigningResult> => {
|
||||
const { recipientToken, requestMetadata } = opts;
|
||||
|
||||
const recipient = await prisma.recipient
|
||||
.findFirst({
|
||||
where: { token: recipientToken },
|
||||
// `signature` must be eager-loaded — `renderRecipientOverlay` runs the
|
||||
// field renderer in `export` mode, which throws `MISSING_SIGNATURE` for
|
||||
// any inserted SIGNATURE field without signature data. Mirrors the
|
||||
// include pattern in `seal-document.handler.ts`.
|
||||
include: { fields: { include: { signature: true } } },
|
||||
})
|
||||
.catch(() => null);
|
||||
|
||||
if (!recipient) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: `Recipient with token "${recipientToken}" not found.`,
|
||||
});
|
||||
}
|
||||
|
||||
const envelope = await prisma.envelope.findUniqueOrThrow({
|
||||
where: { id: recipient.envelopeId },
|
||||
include: {
|
||||
envelopeItems: {
|
||||
include: {
|
||||
documentData: true,
|
||||
},
|
||||
},
|
||||
recipients: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!isTspEnvelope(envelope)) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'prepareCscRecipientSigning called for a non-TSP envelope.',
|
||||
});
|
||||
}
|
||||
|
||||
const credential = await loadCscCredential(recipient.id);
|
||||
|
||||
if (!credential) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'CSC credential missing — service-scope OAuth must complete first.',
|
||||
});
|
||||
}
|
||||
|
||||
if (!credential.certCache) {
|
||||
throw new AppError(AppErrorCode.CSC_CERT_INVALID, {
|
||||
message: 'CSC credential has no persisted certificate chain.',
|
||||
});
|
||||
}
|
||||
|
||||
if (credential.keyLenBits === null) {
|
||||
throw new AppError(AppErrorCode.CSC_ALGORITHM_REFUSED, {
|
||||
message: 'CSC credential omits persisted keyLenBits — service-scope OAuth must re-run.',
|
||||
});
|
||||
}
|
||||
|
||||
const chain = decodeCscCertChain(credential.certCache);
|
||||
|
||||
const algo = policyToLibpdfSignerAlgo({
|
||||
keyType: credential.keyType as 'RSA' | 'ECDSA',
|
||||
digestAlgorithm: credential.digestAlgorithm as CscDigest,
|
||||
signAlgoOid: credential.signatureAlgorithm,
|
||||
keyLenBits: credential.keyLenBits,
|
||||
// `policyToLibpdfSignerAlgo` does not read `hashAlgoOid`; passing empty
|
||||
// string keeps the synthetic policy type-correct without re-derivation.
|
||||
hashAlgoOid: '',
|
||||
});
|
||||
|
||||
// Pin a single signingTime for every per-item capture so the embed pass
|
||||
// re-derives byte-identical signedAttrs digests.
|
||||
const signingTime = new Date();
|
||||
|
||||
const items: TCscSessionItems = [];
|
||||
|
||||
for (const envelopeItem of envelope.envelopeItems) {
|
||||
const recipientFieldsOnItem = recipient.fields.filter((field) => field.envelopeItemId === envelopeItem.id);
|
||||
|
||||
const pagesWithFields = new Set<number>();
|
||||
|
||||
for (const field of recipientFieldsOnItem) {
|
||||
pagesWithFields.add(field.page);
|
||||
}
|
||||
|
||||
const bytes = await getFileServerSide(envelopeItem.documentData);
|
||||
const pdfDoc = await PDF.load(bytes);
|
||||
|
||||
for (const pageNumber of pagesWithFields) {
|
||||
const fieldsOnPage: FieldWithSignature[] = recipientFieldsOnItem.filter((field) => field.page === pageNumber);
|
||||
|
||||
await renderRecipientOverlay({
|
||||
pdfDoc,
|
||||
stampName: buildTspStampName(recipient.id, envelopeItem.id, pageNumber),
|
||||
pageNumber,
|
||||
fields: fieldsOnPage,
|
||||
});
|
||||
}
|
||||
|
||||
// Persist the rendered PDF as an orphan `DocumentData` row before the
|
||||
// capture pass so sign-time can load byte-identical input — eliminates
|
||||
// the determinism risk of running Konva again after the OAuth round-trip.
|
||||
const renderedBytes = await pdfDoc.save({ incremental: true });
|
||||
|
||||
const fileName = envelope.title.endsWith('.pdf') ? envelope.title : `${envelope.title || 'envelope'}.pdf`;
|
||||
|
||||
const renderedUpload = await putPdfFileServerSide(
|
||||
{
|
||||
name: fileName,
|
||||
type: 'application/pdf',
|
||||
arrayBuffer: async () => Promise.resolve(renderedBytes),
|
||||
},
|
||||
envelopeItem.documentData.initialData ?? undefined,
|
||||
);
|
||||
|
||||
// Reload from the persisted bytes so the capture pass operates on the
|
||||
// exact same bytes the sign pass will fetch from storage. Skipping the
|
||||
// reload would compute the digest over an in-memory incremental update
|
||||
// that diverges from what `PDF.load(renderedBytes)` produces.
|
||||
const capturePdfDoc = await PDF.load(renderedBytes);
|
||||
|
||||
const captureSigner = new CscCaptureSigner({
|
||||
certificate: chain[0],
|
||||
certificateChain: chain.slice(1),
|
||||
algo,
|
||||
});
|
||||
|
||||
const anchorName = buildTspAnchorName(recipient.id, envelopeItem.id);
|
||||
|
||||
// Capture at B-B even though the eventual embed pass is B-T. The B-T
|
||||
// signature timestamp is a CMS *unsigned* attribute, added by libpdf
|
||||
// after `signer.sign()` runs over the signed-attrs digest — so B-B and
|
||||
// B-T produce byte-identical signed-attrs for the same `(signer,
|
||||
// documentHash, digestAlgorithm, signingTime)` tuple. See the matching
|
||||
// note in `execute-tsp-sign.ts`.
|
||||
await capturePdfDoc.sign({
|
||||
signer: captureSigner,
|
||||
fieldName: anchorName,
|
||||
signingTime,
|
||||
level: 'B-B',
|
||||
digestAlgorithm: algo.digestAlgorithm,
|
||||
});
|
||||
|
||||
if (captureSigner.capturedDigest === null) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'CscCaptureSigner was not invoked by pdf.sign during prep.',
|
||||
});
|
||||
}
|
||||
|
||||
items.push({
|
||||
envelopeItemId: envelopeItem.id,
|
||||
documentDataId: renderedUpload.documentData.id,
|
||||
hashB64: Buffer.from(captureSigner.capturedDigest).toString('base64'),
|
||||
ordinal: items.length,
|
||||
});
|
||||
}
|
||||
|
||||
const session = await upsertCscSession({
|
||||
recipientId: recipient.id,
|
||||
envelopeId: envelope.id,
|
||||
signingTime,
|
||||
items,
|
||||
});
|
||||
|
||||
await prisma.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_CSC_SIGN_REQUESTED,
|
||||
envelopeId: envelope.id,
|
||||
user: { name: recipient.name, email: recipient.email },
|
||||
requestMetadata,
|
||||
data: {
|
||||
recipientEmail: recipient.email,
|
||||
recipientName: recipient.name,
|
||||
recipientId: recipient.id,
|
||||
recipientRole: recipient.role,
|
||||
providerId: credential.providerId,
|
||||
credentialId: credential.credentialId,
|
||||
sessionId: session.id,
|
||||
numSignatures: items.length,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const redirectUrl = `${NEXT_PUBLIC_WEBAPP_URL()}/api/csc/oauth/authorize?scope=credential&session=${session.id}`;
|
||||
|
||||
return {
|
||||
status: 'REDIRECT',
|
||||
redirectUrl,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,162 @@
|
||||
import { AnnotationFlags, ops, PDF, PdfArray, PdfDict, PdfName, PdfNumber } from '@libpdf/core';
|
||||
|
||||
// `Operator` is declared in `@libpdf/core` but not exported. Derive it from
|
||||
// `ops.pushGraphicsState`'s return type instead of importing.
|
||||
type LibpdfOperator = ReturnType<typeof ops.pushGraphicsState>;
|
||||
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { insertFieldInPDFV2 } from '@documenso/lib/server-only/pdf/insert-field-in-pdf-v2';
|
||||
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
||||
|
||||
/**
|
||||
* CSC TSP recipient overlay renderer.
|
||||
*
|
||||
* Writes a recipient's per-page field values into the pre-allocated
|
||||
* `/Stamp` annotation's normal appearance (`/AP /N`), reusing the Konva
|
||||
* overlay generator that powers the SES path.
|
||||
*
|
||||
* SES uses `page.drawPage(embeddedPage)` to paint directly onto the page
|
||||
* content stream. For TSP that would create a new page object in the
|
||||
* incremental update and invalidate prior recipients' `/ByteRange`. Routing
|
||||
* the same embedded FormXObject through a stamp's appearance keeps the page
|
||||
* dict untouched while reusing the embed pipeline `drawPage` does.
|
||||
*
|
||||
* The appearance stream mirrors `drawPage`'s `x=0, y=0, scale=1, no-rotate`
|
||||
* branch: a single `concatMatrix(1, 0, 0, 1, -box.x, -box.y)` compensates
|
||||
* for any non-origin MediaBox on the overlay PDF before `paintXObject`. The
|
||||
* stamp's `/Rect` and the appearance `/BBox` both span `[0, 0, page.width,
|
||||
* page.height]`, so the PDF reader maps content 1:1 and page rotation
|
||||
* applies at the page level (not inside the appearance).
|
||||
*/
|
||||
|
||||
export type RenderRecipientOverlayOptions = {
|
||||
/** The loaded PDF the stamp lives on. */
|
||||
pdfDoc: PDF;
|
||||
/** Stamp name from `buildTspStampName(recipientId, envelopeItemId, pageNumber)`. */
|
||||
stampName: string;
|
||||
/** 1-based page number. */
|
||||
pageNumber: number;
|
||||
/** Recipient's fields for THIS page only. */
|
||||
fields: FieldWithSignature[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Render `fields` into the pre-allocated `/Stamp` annotation named `stampName`
|
||||
* on `pageNumber`. Mutates `pdfDoc` in place.
|
||||
*
|
||||
* Throws when the named stamp can't be located — every call site must have
|
||||
* materialised the stamp first via `materializeTspAnchorsForEnvelope`.
|
||||
*/
|
||||
export const renderRecipientOverlay = async ({
|
||||
pdfDoc,
|
||||
stampName,
|
||||
pageNumber,
|
||||
fields,
|
||||
}: RenderRecipientOverlayOptions): Promise<void> => {
|
||||
const page = pdfDoc.getPage(pageNumber - 1);
|
||||
|
||||
if (!page) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: `Page ${pageNumber} not found on PDF.`,
|
||||
});
|
||||
}
|
||||
|
||||
const stamp = page.getStampAnnotations().find((annotation) => annotation.stampName === stampName);
|
||||
|
||||
if (!stamp) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: `TSP stamp ${stampName} not found on page ${pageNumber}.`,
|
||||
});
|
||||
}
|
||||
|
||||
const overlayBytes = await insertFieldInPDFV2({
|
||||
pageWidth: page.width,
|
||||
pageHeight: page.height,
|
||||
fields,
|
||||
});
|
||||
|
||||
const overlayDoc = await PDF.load(overlayBytes);
|
||||
const embedded = await pdfDoc.embedPage(overlayDoc, 0);
|
||||
|
||||
// Bind the embedded page under a local XObject name in the appearance's
|
||||
// own /Resources. Appearance streams are scoped — they can't see the
|
||||
// parent page's resource dict.
|
||||
const xobjectName = 'X0';
|
||||
|
||||
// Mirror `PDFPage.drawPage`'s no-rotation, no-scale branch:
|
||||
// translateX = x - embedded.box.x * scaleX (x = 0, scaleX = 1)
|
||||
// translateY = y - embedded.box.y * scaleY (y = 0, scaleY = 1)
|
||||
// concatMatrix(scaleX, 0, 0, scaleY, translateX, translateY)
|
||||
// Identity matrix when the overlay PDF has an origin-aligned MediaBox;
|
||||
// a translate-only shift otherwise. No-op cost is negligible.
|
||||
const operators: LibpdfOperator[] = [
|
||||
ops.pushGraphicsState(),
|
||||
ops.concatMatrix(1, 0, 0, 1, -embedded.box.x, -embedded.box.y),
|
||||
ops.paintXObject(xobjectName),
|
||||
ops.popGraphicsState(),
|
||||
];
|
||||
|
||||
const contentBytes = serializeOperators(operators);
|
||||
|
||||
const appearanceRef = pdfDoc.createStream(
|
||||
{
|
||||
Type: PdfName.of('XObject'),
|
||||
Subtype: PdfName.of('Form'),
|
||||
FormType: PdfNumber.of(1),
|
||||
BBox: new PdfArray([PdfNumber.of(0), PdfNumber.of(0), PdfNumber.of(page.width), PdfNumber.of(page.height)]),
|
||||
Resources: PdfDict.of({
|
||||
XObject: PdfDict.of({
|
||||
[xobjectName]: embedded.ref,
|
||||
}),
|
||||
}),
|
||||
},
|
||||
contentBytes,
|
||||
);
|
||||
|
||||
// Direct dict write — bypasses `PDFAnnotation.setNormalAppearance`, which
|
||||
// (a) re-registers the stream and (b) has a no-op branch when `/AP` is
|
||||
// absent on the annotation. See `node_modules/@libpdf/core/dist/index.mjs:
|
||||
// 4347-4357`. The PDF reader and libpdf's `getAppearance` (index.mjs:4337)
|
||||
// both follow refs transparently, so `/AP -> { N: <ref> }` is valid.
|
||||
stamp.dict.set('AP', PdfDict.of({ N: appearanceRef }));
|
||||
|
||||
stamp.setFlag(AnnotationFlags.Print, true);
|
||||
stamp.setFlag(AnnotationFlags.ReadOnly, true);
|
||||
stamp.setFlag(AnnotationFlags.Locked, true);
|
||||
stamp.setFlag(AnnotationFlags.LockedContents, true);
|
||||
};
|
||||
|
||||
/**
|
||||
* Serialize a content-stream operator sequence into a single byte buffer,
|
||||
* newline-separated. Mirrors libpdf's internal `serializeOperators` (not
|
||||
* exported from `@libpdf/core`); each `Operator.toBytes()` returns one
|
||||
* operator's `operand1 operand2 ... op` slice.
|
||||
*/
|
||||
const serializeOperators = (operators: LibpdfOperator[]): Uint8Array => {
|
||||
if (operators.length === 0) {
|
||||
return new Uint8Array(0);
|
||||
}
|
||||
|
||||
const chunks = operators.map((operator) => operator.toBytes());
|
||||
|
||||
let totalLength = 0;
|
||||
|
||||
for (const chunk of chunks) {
|
||||
totalLength += chunk.length + 1; // +1 for trailing newline
|
||||
}
|
||||
|
||||
const out = new Uint8Array(totalLength);
|
||||
let offset = 0;
|
||||
|
||||
for (const chunk of chunks) {
|
||||
out.set(chunk, offset);
|
||||
|
||||
offset += chunk.length;
|
||||
|
||||
out[offset] = 0x0a;
|
||||
|
||||
offset += 1;
|
||||
}
|
||||
|
||||
return out;
|
||||
};
|
||||
@@ -0,0 +1,181 @@
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { type TCscSessionItems, ZCscSessionItemsSchema } from '@documenso/lib/types/csc-session';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { Prisma } from '@prisma/client';
|
||||
|
||||
/**
|
||||
* DB helpers for `CscSession` — the per-recipient transient row that bridges
|
||||
* prep, the credential-scope OAuth round-trip, and the sync sign mutation.
|
||||
*
|
||||
* Four operations cover the spec's lifecycle:
|
||||
*
|
||||
* - {@link upsertCscSession} — prep time; clears any prior SAD by writing
|
||||
* `encryptedSad = null` so a re-clicked Sign starts fresh.
|
||||
* - {@link updateCscSessionWithSad} — credential-scope callback; sets the
|
||||
* SAD + its TSP-asserted expiry.
|
||||
* - {@link loadCscSession} — authorize route, signing-page loader, sync
|
||||
* mutation. Returns null on missing (cookie referenced a deleted session).
|
||||
* - {@link consumeCscSession} — sync mutation success path; single-use delete
|
||||
* returning the consumed row so the caller can use its data post-deletion.
|
||||
*
|
||||
* `itemsJson` is parsed through `ZCscSessionItemsSchema` on every read so the
|
||||
* caller works with typed {@link TCscSessionItems}.
|
||||
*/
|
||||
|
||||
export type CscSessionRow = {
|
||||
id: string;
|
||||
recipientId: number;
|
||||
envelopeId: string;
|
||||
signingTime: Date;
|
||||
items: TCscSessionItems;
|
||||
encryptedSad: Uint8Array | null;
|
||||
sadExpiresAt: Date | null;
|
||||
createdAt: Date;
|
||||
};
|
||||
|
||||
type UpsertCscSessionInput = {
|
||||
recipientId: number;
|
||||
envelopeId: string;
|
||||
signingTime: Date;
|
||||
items: TCscSessionItems;
|
||||
};
|
||||
|
||||
/**
|
||||
* Create or refresh the per-recipient session row at prep time. The recipient
|
||||
* has at most one in-flight session (`@@unique([recipientId])`); re-clicking
|
||||
* Sign overwrites prior `itemsJson` + clears `encryptedSad` / `sadExpiresAt`
|
||||
* so the next credential-scope callback starts from a clean SAD slot.
|
||||
*/
|
||||
export const upsertCscSession = async (input: UpsertCscSessionInput): Promise<CscSessionRow> => {
|
||||
const { recipientId, envelopeId, signingTime, items } = input;
|
||||
|
||||
const row = await prisma.cscSession.upsert({
|
||||
where: { recipientId },
|
||||
create: {
|
||||
recipientId,
|
||||
envelopeId,
|
||||
signingTime,
|
||||
itemsJson: items,
|
||||
encryptedSad: null,
|
||||
sadExpiresAt: null,
|
||||
},
|
||||
update: {
|
||||
envelopeId,
|
||||
signingTime,
|
||||
itemsJson: items,
|
||||
encryptedSad: null,
|
||||
sadExpiresAt: null,
|
||||
},
|
||||
});
|
||||
|
||||
return toCscSessionRow(row);
|
||||
};
|
||||
|
||||
type UpdateCscSessionWithSadInput = {
|
||||
sessionId: string;
|
||||
encryptedSad: Uint8Array;
|
||||
sadExpiresAt: Date;
|
||||
};
|
||||
|
||||
/**
|
||||
* Stamp the credential-scope SAD onto an existing session at the OAuth
|
||||
* callback. Throws when the session id was already consumed or never existed
|
||||
* — that's a flow-state bug the caller must surface, not silently skip.
|
||||
*/
|
||||
export const updateCscSessionWithSad = async (input: UpdateCscSessionWithSadInput): Promise<CscSessionRow> => {
|
||||
const { sessionId, encryptedSad, sadExpiresAt } = input;
|
||||
|
||||
try {
|
||||
const row = await prisma.cscSession.update({
|
||||
where: {
|
||||
id: sessionId,
|
||||
},
|
||||
data: {
|
||||
encryptedSad: Buffer.from(encryptedSad),
|
||||
sadExpiresAt,
|
||||
},
|
||||
});
|
||||
|
||||
return toCscSessionRow(row);
|
||||
} catch (err) {
|
||||
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2025') {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: `CSC session "${sessionId}" not found at SAD attach time.`,
|
||||
});
|
||||
}
|
||||
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch a session by id. Returns `null` when the row is absent — callers MUST
|
||||
* handle the missing case (cookie outliving the row is a normal terminal
|
||||
* outcome, not an error).
|
||||
*/
|
||||
export const loadCscSession = async (sessionId: string): Promise<CscSessionRow | null> => {
|
||||
const row = await prisma.cscSession.findUnique({
|
||||
where: { id: sessionId },
|
||||
});
|
||||
|
||||
return row ? toCscSessionRow(row) : null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Atomically delete the session row and return its parsed contents. Used by
|
||||
* the sync mutation's success path so the caller still has the session data
|
||||
* for post-sign side effects (audit log, webhook payloads).
|
||||
*
|
||||
* Throws `NOT_FOUND` when the row is already gone — semantically distinct
|
||||
* from {@link loadCscSession}'s nullable return because consume is the
|
||||
* success-path single-use closer; a missing row at that point means another
|
||||
* branch raced to consume and the caller should not double-count.
|
||||
*/
|
||||
export const consumeCscSession = async (sessionId: string, tx?: Prisma.TransactionClient): Promise<CscSessionRow> => {
|
||||
const client = tx ?? prisma;
|
||||
|
||||
try {
|
||||
const row = await client.cscSession.delete({
|
||||
where: { id: sessionId },
|
||||
});
|
||||
|
||||
return toCscSessionRow(row);
|
||||
} catch (err) {
|
||||
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2025') {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: `CSC session "${sessionId}" already consumed or never existed.`,
|
||||
});
|
||||
}
|
||||
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Project a raw Prisma `CscSession` into the helper's parsed shape. Throws
|
||||
* on `itemsJson` parse failure — that's a data-integrity issue, not a
|
||||
* recoverable runtime case.
|
||||
*/
|
||||
const toCscSessionRow = (row: {
|
||||
id: string;
|
||||
recipientId: number;
|
||||
envelopeId: string;
|
||||
signingTime: Date;
|
||||
itemsJson: Prisma.JsonValue;
|
||||
encryptedSad: Uint8Array | null;
|
||||
sadExpiresAt: Date | null;
|
||||
createdAt: Date;
|
||||
}): CscSessionRow => {
|
||||
const items = ZCscSessionItemsSchema.parse(row.itemsJson);
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
recipientId: row.recipientId,
|
||||
envelopeId: row.envelopeId,
|
||||
signingTime: row.signingTime,
|
||||
items,
|
||||
encryptedSad: row.encryptedSad,
|
||||
sadExpiresAt: row.sadExpiresAt,
|
||||
createdAt: row.createdAt,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* CSC dry-run capture signer.
|
||||
*
|
||||
* Libpdf's signing flow expects an inline signer that hashes the
|
||||
* `signedAttrs` bytes and returns a CMS signature. For the CSC §11.9
|
||||
* `signatures/signHash` contract the actual signature is produced
|
||||
* remotely by the TSP, so a single libpdf sign cycle has to be split
|
||||
* into two passes:
|
||||
*
|
||||
* 1. Dry-run — drive `pdf.sign()` with this capture signer to derive
|
||||
* the `signedAttrs` digest libpdf would otherwise sign. The
|
||||
* resulting PDF is discarded; only `capturedDigest` matters.
|
||||
* 2. Embed pass — the `CscFifoSigner` re-runs `pdf.sign()` and feeds
|
||||
* the TSP-produced signature bytes back into the same byte slots.
|
||||
*
|
||||
* The placeholder bytes returned from `sign()` are sized to the
|
||||
* chosen algorithm so libpdf's downstream CMS construction is not
|
||||
* surprised by an unexpectedly short signature.
|
||||
*/
|
||||
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import type { Signer } from '@libpdf/core';
|
||||
import { sha256, sha384, sha512 } from '@noble/hashes/sha2';
|
||||
|
||||
import type { LibpdfSignerAlgo } from '../algorithm-resolver';
|
||||
|
||||
type DigestAlgorithm = 'SHA-256' | 'SHA-384' | 'SHA-512';
|
||||
|
||||
type KeyType = 'RSA' | 'EC';
|
||||
|
||||
type SignatureAlgorithm = 'RSASSA-PKCS1-v1_5' | 'RSA-PSS' | 'ECDSA';
|
||||
|
||||
export type CscCaptureSignerOptions = {
|
||||
certificate: Uint8Array;
|
||||
certificateChain?: Uint8Array[];
|
||||
algo: LibpdfSignerAlgo;
|
||||
};
|
||||
|
||||
export class CscCaptureSigner implements Signer {
|
||||
readonly certificate: Uint8Array;
|
||||
readonly certificateChain?: Uint8Array[];
|
||||
readonly keyType: KeyType;
|
||||
readonly signatureAlgorithm: SignatureAlgorithm;
|
||||
private readonly algo: LibpdfSignerAlgo;
|
||||
|
||||
/** Populated by `sign()`. `null` until libpdf calls into the signer. */
|
||||
capturedDigest: Uint8Array | null = null;
|
||||
|
||||
constructor(options: CscCaptureSignerOptions) {
|
||||
this.certificate = options.certificate;
|
||||
this.certificateChain = options.certificateChain;
|
||||
this.keyType = options.algo.keyType;
|
||||
this.signatureAlgorithm = options.algo.signatureAlgorithm;
|
||||
this.algo = options.algo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash `data` with `algorithm` to derive the `signedAttrs` digest libpdf
|
||||
* would normally sign, stash it on `capturedDigest`, then return a
|
||||
* placeholder buffer sized to the chosen key so libpdf's CMS scaffolding
|
||||
* accepts it. The placeholder bytes are never inspected — the resulting
|
||||
* PDF is discarded after the digest is read.
|
||||
*/
|
||||
|
||||
// biome-ignore lint/suspicious/useAwait: intentional
|
||||
async sign(data: Uint8Array, algorithm: DigestAlgorithm): Promise<Uint8Array> {
|
||||
if (this.capturedDigest !== null) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'CscCaptureSigner.sign() called more than once — capture signers are single-use.',
|
||||
});
|
||||
}
|
||||
|
||||
this.capturedDigest = hashData(data, algorithm);
|
||||
|
||||
return new Uint8Array(placeholderSize(this.algo));
|
||||
}
|
||||
}
|
||||
|
||||
const hashData = (data: Uint8Array, algorithm: DigestAlgorithm): Uint8Array => {
|
||||
if (algorithm === 'SHA-256') {
|
||||
return sha256(data);
|
||||
}
|
||||
|
||||
if (algorithm === 'SHA-384') {
|
||||
return sha384(data);
|
||||
}
|
||||
|
||||
if (algorithm === 'SHA-512') {
|
||||
return sha512(data);
|
||||
}
|
||||
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: `CscCaptureSigner.sign() called with unsupported digest algorithm '${String(algorithm)}'.`,
|
||||
});
|
||||
};
|
||||
|
||||
const placeholderSize = (algo: LibpdfSignerAlgo): number => {
|
||||
if (algo.keyType === 'RSA') {
|
||||
// RSA signature length === modulus length in bytes.
|
||||
if (algo.keyLenBits >= 4096) {
|
||||
return 512;
|
||||
}
|
||||
|
||||
if (algo.keyLenBits >= 3072) {
|
||||
return 384;
|
||||
}
|
||||
|
||||
return 256;
|
||||
}
|
||||
|
||||
// ECDSA DER-encoded SEQUENCE { INTEGER r, INTEGER s }. Upper bounds:
|
||||
// P-256 ≈ 72 bytes, P-384 ≈ 104, P-521 ≈ 139. The dry-run PDF is
|
||||
// discarded — exact size is informational, not load-bearing.
|
||||
if (algo.keyLenBits >= 512) {
|
||||
return 139;
|
||||
}
|
||||
|
||||
if (algo.keyLenBits >= 384) {
|
||||
return 104;
|
||||
}
|
||||
|
||||
return 72;
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* CSC embed-pass FIFO signer.
|
||||
*
|
||||
* `signatures/signHash` (CSC §11.9) returns one signature per submitted
|
||||
* hash, in the same position-bound order as the request `hash[]` array.
|
||||
* The embed pass re-runs `pdf.sign()` once per anchor in that same order,
|
||||
* so a FIFO queue of signature bytes — popped on each `sign()` call —
|
||||
* is sufficient to feed libpdf without any per-anchor binding metadata.
|
||||
*/
|
||||
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import type { Signer } from '@libpdf/core';
|
||||
|
||||
import type { LibpdfSignerAlgo } from '../algorithm-resolver';
|
||||
|
||||
type DigestAlgorithm = 'SHA-256' | 'SHA-384' | 'SHA-512';
|
||||
|
||||
type KeyType = 'RSA' | 'EC';
|
||||
|
||||
type SignatureAlgorithm = 'RSASSA-PKCS1-v1_5' | 'RSA-PSS' | 'ECDSA';
|
||||
|
||||
export type CscFifoSignerOptions = {
|
||||
certificate: Uint8Array;
|
||||
certificateChain?: Uint8Array[];
|
||||
algo: LibpdfSignerAlgo;
|
||||
/** Base64-decoded raw signature bytes in the order produced by `signatures/signHash`. */
|
||||
signatures: Uint8Array[];
|
||||
};
|
||||
|
||||
export class CscFifoSigner implements Signer {
|
||||
readonly certificate: Uint8Array;
|
||||
readonly certificateChain?: Uint8Array[];
|
||||
readonly keyType: KeyType;
|
||||
readonly signatureAlgorithm: SignatureAlgorithm;
|
||||
private readonly queue: Uint8Array[];
|
||||
|
||||
constructor(options: CscFifoSignerOptions) {
|
||||
this.certificate = options.certificate;
|
||||
this.certificateChain = options.certificateChain;
|
||||
this.keyType = options.algo.keyType;
|
||||
this.signatureAlgorithm = options.algo.signatureAlgorithm;
|
||||
this.queue = [...options.signatures];
|
||||
}
|
||||
|
||||
// biome-ignore lint/suspicious/useAwait: intentional
|
||||
async sign(_data: Uint8Array, _algorithm: DigestAlgorithm): Promise<Uint8Array> {
|
||||
const next = this.queue.shift();
|
||||
|
||||
if (next === undefined) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'CSC FIFO signer exhausted — more sign() calls than queued signatures.',
|
||||
});
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { IS_INSTANCE_CSC_MODE, NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { assertLicensedFor } from '@documenso/lib/server-only/license/assert-licensed-for';
|
||||
import { requireEnv } from '@documenso/lib/utils/env';
|
||||
import type { OAuth2Client } from 'arctic';
|
||||
|
||||
import { cscInfo } from './client/info';
|
||||
import { createCscOAuthClient } from './client/oauth';
|
||||
import type { TCscInfoResponse } from './client/types';
|
||||
import { isEnvTsaConfigured } from './tsa-resolver';
|
||||
|
||||
/**
|
||||
* Lazily-built, globally-cached CSC transport.
|
||||
*
|
||||
* Boot-discovers `cscInfo` (§11.1) once, caches the OAuth base URL +
|
||||
* `signatures/timestamp` capability, and exposes a configured arctic
|
||||
* `OAuth2Client`. License + env + discovery are gated at construction so a
|
||||
* misconfigured instance fails at the first call site, not at sign time.
|
||||
*
|
||||
* Cached on `globalThis` so Hono routes and Remix loaders share one instance
|
||||
* across bundles (mirrors {@link LicenseClient}'s strategy).
|
||||
*
|
||||
* A failed build is **not** cached — the next caller retries. This keeps a
|
||||
* transient discovery hiccup from permanently breaking the transport while
|
||||
* still amortising the success path to one round-trip per process.
|
||||
*/
|
||||
|
||||
const DISCOVERY_TIMEOUT_MS = 10_000;
|
||||
|
||||
const CSC_TIMESTAMP_METHOD = 'signatures/timestamp';
|
||||
|
||||
export type CscTransport = {
|
||||
/** Service base URI from `NEXT_PRIVATE_SIGNING_CSC_PROVIDER_BASE_URL`. */
|
||||
serviceBaseUrl: string;
|
||||
/** OAuth base URI from `info.oauth2` (§11.1). MAY differ from `serviceBaseUrl`. */
|
||||
oauthBaseUrl: string;
|
||||
/** Pre-configured arctic client bound to the TSP's OAuth registration. */
|
||||
oauthClient: OAuth2Client;
|
||||
/**
|
||||
* Documenso's callback URL registered with the TSP. Derived from
|
||||
* `NEXT_PUBLIC_WEBAPP_URL` and the fixed `/api/csc/oauth/callback` mount —
|
||||
* mirrors `packages/auth/server/config.ts` for the sign-in OAuth providers.
|
||||
* Operators must register this exact URL with the TSP.
|
||||
*/
|
||||
oauthRedirectUri: string;
|
||||
/** True when the TSP advertises `signatures/timestamp` in `info.methods`. */
|
||||
supportsTimestamp: boolean;
|
||||
/** Raw discovery response, exposed for callers needing other fields (`name`, `region`, `lang`). */
|
||||
info: TCscInfoResponse;
|
||||
};
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line no-var
|
||||
var __documenso_csc_transport__: CscTransport | undefined;
|
||||
// eslint-disable-next-line no-var
|
||||
var __documenso_csc_transport_promise__: Promise<CscTransport> | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current CSC transport, building + caching it on first call.
|
||||
*
|
||||
* Throws:
|
||||
* - `NOT_SETUP` — instance is not in CSC mode, or a required env var is unset.
|
||||
* - `CSC_UNLICENSED` — `instanceCscSigning` license flag missing.
|
||||
* - `CSC_PROVIDER_INFO_FAILED` — `info` discovery failed or response omits
|
||||
* the REQUIRED `oauth2` base URL.
|
||||
*
|
||||
* Safe to call concurrently — a second call during in-flight discovery
|
||||
* awaits the same promise instead of starting a duplicate request.
|
||||
*/
|
||||
export const getCscTransport = async (): Promise<CscTransport> => {
|
||||
if (globalThis.__documenso_csc_transport__) {
|
||||
return globalThis.__documenso_csc_transport__;
|
||||
}
|
||||
|
||||
if (!globalThis.__documenso_csc_transport_promise__) {
|
||||
globalThis.__documenso_csc_transport_promise__ = buildCscTransport()
|
||||
.then((transport) => {
|
||||
globalThis.__documenso_csc_transport__ = transport;
|
||||
return transport;
|
||||
})
|
||||
.finally(() => {
|
||||
globalThis.__documenso_csc_transport_promise__ = undefined;
|
||||
});
|
||||
}
|
||||
|
||||
return await globalThis.__documenso_csc_transport_promise__;
|
||||
};
|
||||
|
||||
/**
|
||||
* Drop the cached transport. Intended for tests / operator-triggered reload
|
||||
* after a license-key swap. Next {@link getCscTransport} call re-runs the
|
||||
* full build pipeline (license + env + discovery).
|
||||
*/
|
||||
export const resetCscTransport = (): void => {
|
||||
globalThis.__documenso_csc_transport__ = undefined;
|
||||
globalThis.__documenso_csc_transport_promise__ = undefined;
|
||||
};
|
||||
|
||||
const buildCscTransport = async (): Promise<CscTransport> => {
|
||||
if (!IS_INSTANCE_CSC_MODE()) {
|
||||
throw new AppError(AppErrorCode.NOT_SETUP, {
|
||||
message: 'CSC transport requested but NEXT_PRIVATE_SIGNING_TRANSPORT is not "csc".',
|
||||
});
|
||||
}
|
||||
|
||||
await assertLicensedFor('instanceCscSigning', { errorCode: AppErrorCode.CSC_UNLICENSED });
|
||||
|
||||
const serviceBaseUrl = requireEnv('NEXT_PRIVATE_SIGNING_CSC_PROVIDER_BASE_URL');
|
||||
const clientId = requireEnv('NEXT_PRIVATE_SIGNING_CSC_OAUTH_CLIENT_ID');
|
||||
const clientSecret = requireEnv('NEXT_PRIVATE_SIGNING_CSC_OAUTH_CLIENT_SECRET');
|
||||
const oauthRedirectUri = `${NEXT_PUBLIC_WEBAPP_URL()}/api/csc/oauth/callback`;
|
||||
|
||||
const oauthClient = createCscOAuthClient({ clientId, clientSecret, redirectUri: oauthRedirectUri });
|
||||
|
||||
const info = await cscInfo({
|
||||
baseUrl: serviceBaseUrl,
|
||||
signal: AbortSignal.timeout(DISCOVERY_TIMEOUT_MS),
|
||||
});
|
||||
|
||||
if (!info.oauth2) {
|
||||
throw new AppError(AppErrorCode.CSC_PROVIDER_INFO_FAILED, {
|
||||
message:
|
||||
'CSC TSP info response omits the required `oauth2` base URL. CSC QES V1 only supports OAuth-based authorization (§8.3) — non-OAuth TSPs are not compatible.',
|
||||
});
|
||||
}
|
||||
|
||||
const supportsTimestamp = info.methods.includes(CSC_TIMESTAMP_METHOD);
|
||||
|
||||
// Boot-time TSA invariant: `NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY` is
|
||||
// required unconditionally in CSC mode. Sign-time B-T can use the TSP's
|
||||
// own `signatures/timestamp` endpoint when advertised, but seal-time
|
||||
// B-LTA archival is env-only by design (operators should pin a dedicated
|
||||
// qualified archival TSA — see `resolveCscSealTimeTsa`). Without env, an
|
||||
// envelope would sign successfully and then hang in
|
||||
// WAITING_FOR_SIGNATURE_COMPLETION when the seal job throws. Catch the
|
||||
// misconfiguration at boot instead so the instance refuses to start.
|
||||
if (!isEnvTsaConfigured()) {
|
||||
throw new AppError(AppErrorCode.CSC_PROVIDER_NO_TSA, {
|
||||
message:
|
||||
'NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY is unset. AES/QES envelopes require a TSA for B-LTA archival at seal time regardless of whether the CSC TSP advertises signatures/timestamp for B-T sign-time. Configure NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY.',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
serviceBaseUrl,
|
||||
oauthBaseUrl: info.oauth2,
|
||||
oauthClient,
|
||||
oauthRedirectUri,
|
||||
supportsTimestamp,
|
||||
info,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,105 @@
|
||||
import { NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY } from '@documenso/lib/constants/app';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { HttpTimestampAuthority, type TimestampAuthority } from '@libpdf/core';
|
||||
|
||||
import type { CscTransport } from './transport';
|
||||
import { CscTspTimestampAuthority } from './tsp-timestamp-authority';
|
||||
|
||||
/**
|
||||
* Two-phase TSA resolution for the CSC transport.
|
||||
*
|
||||
* Phase 1 — sign time (PAdES B-T, per recipient signature).
|
||||
* Each recipient's CMS gets a signature timestamp embedded as an unsigned
|
||||
* attribute. {@link resolveCscSignTimeTsa} returns a libpdf-shaped
|
||||
* `TimestampAuthority` bound to either the TSP's `signatures/timestamp`
|
||||
* endpoint (authorised with the recipient's own service-scope bearer) or
|
||||
* the operator's env-configured RFC 3161 TSA, whichever is configured.
|
||||
* TSP wins precedence so a TSP-supplied TSA is the default when the TSP
|
||||
* advertises the method.
|
||||
*
|
||||
* Phase 2 — seal time (PAdES B-LTA archival timestamp).
|
||||
* The seal-document job emits one `/DocTimeStamp` over the fully-signed
|
||||
* envelope. {@link resolveCscSealTimeTsa} returns the env-configured TSA
|
||||
* only — the archival anchor SHOULD be a dedicated qualified archival
|
||||
* TSA, independent of the per-recipient TSP. Using the TSP here would
|
||||
* couple archive longevity to a TSP that may rotate or revoke, and seal
|
||||
* time has no recipient context to carry a service-scope bearer anyway.
|
||||
*
|
||||
* Boot-time guard: {@link buildCscTransport} asserts
|
||||
* `NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY` is set unconditionally — seal
|
||||
* time always needs it, so making it env-or-fail at boot also satisfies
|
||||
* the sign-time fallback. The defensive throws inside the resolvers below
|
||||
* should be unreachable in practice.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Build a libpdf `TimestampAuthority` for a recipient's B-T sign-time
|
||||
* signature timestamp.
|
||||
*
|
||||
* Precedence: TSP first, env fallback. Selection is made up-front based on
|
||||
* the boot-discovered transport capability — we don't try TSP then fall
|
||||
* through to env on a runtime error. If the chosen source fails at call
|
||||
* time, the recipient's sign attempt fails (operator's recourse is to
|
||||
* configure env, which then wins on the next sign).
|
||||
*
|
||||
* `serviceToken` is the decrypted, non-expired service-scope bearer for
|
||||
* the current recipient — used only when the TSP source is selected.
|
||||
*/
|
||||
export const resolveCscSignTimeTsa = (transport: CscTransport, serviceToken: string): TimestampAuthority => {
|
||||
if (transport.supportsTimestamp) {
|
||||
return new CscTspTimestampAuthority({ transport, serviceToken });
|
||||
}
|
||||
|
||||
const envUrls = parseTsaEnv(NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY());
|
||||
|
||||
if (envUrls.length > 0) {
|
||||
return new HttpTimestampAuthority(envUrls[0]);
|
||||
}
|
||||
|
||||
// Boot-time guard in `buildCscTransport` should have rejected this
|
||||
// configuration before any recipient hit this code path.
|
||||
throw new AppError(AppErrorCode.CSC_PROVIDER_NO_TSA, {
|
||||
message:
|
||||
'CSC sign-time TSA unresolved: TSP does not advertise signatures/timestamp and NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY is unset. This should have been caught by the boot-time guard in buildCscTransport.',
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve the seal-time archival TSA URLs (env only).
|
||||
*
|
||||
* Returns the parsed env list; the caller picks how to consume it (today
|
||||
* `finalize-tsp-completion.ts` uses the first URL).
|
||||
*/
|
||||
export const resolveCscSealTimeTsa = (): { urls: string[] } => {
|
||||
const envUrls = parseTsaEnv(NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY());
|
||||
|
||||
if (envUrls.length === 0) {
|
||||
throw new AppError(AppErrorCode.CSC_PROVIDER_NO_TSA, {
|
||||
message:
|
||||
'CSC seal-time archival timestamps require NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY. This should have been caught by the boot-time guard in buildCscTransport — the env var is required at seal time even when the TSP advertises signatures/timestamp.',
|
||||
});
|
||||
}
|
||||
|
||||
return { urls: envUrls };
|
||||
};
|
||||
|
||||
/**
|
||||
* Cheap boot-time predicate — used by `buildCscTransport` to decide
|
||||
* whether the env TSA satisfies the "at least one source must be
|
||||
* configured" invariant. Keeping the env parsing in one place avoids
|
||||
* drift between the guard and the resolvers.
|
||||
*/
|
||||
export const isEnvTsaConfigured = (): boolean => {
|
||||
return parseTsaEnv(NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY()).length > 0;
|
||||
};
|
||||
|
||||
const parseTsaEnv = (raw: string | undefined): string[] => {
|
||||
if (!raw) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return raw
|
||||
.split(',')
|
||||
.map((url) => url.trim())
|
||||
.filter(Boolean);
|
||||
};
|
||||
@@ -0,0 +1,82 @@
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import type { DigestAlgorithm, TimestampAuthority } from '@libpdf/core';
|
||||
|
||||
import { hashOidForDigest } from './algorithm-resolver';
|
||||
import { cscTimestamp } from './client/signatures';
|
||||
import type { CscTransport } from './transport';
|
||||
|
||||
/**
|
||||
* libpdf {@link TimestampAuthority} backed by the CSC TSP's
|
||||
* `signatures/timestamp` endpoint (§11.10).
|
||||
*
|
||||
* Used only at sign time, per recipient, when {@link resolveCscSignTimeTsa}
|
||||
* selects the TSP source — that is, when the TSP advertises
|
||||
* `signatures/timestamp` in `info.methods`. The token wired in is the
|
||||
* current recipient's own service-scope bearer (the same one authorising
|
||||
* the `signatures/signHash` call alongside it), so the timestamp gets
|
||||
* attributed to the same identity that just authorised the signature.
|
||||
*
|
||||
* Seal-time archival timestamps do not use this class — they go through
|
||||
* the env-only path in `finalize-tsp-completion.ts`.
|
||||
*
|
||||
* Failure semantics: a single `signatures/timestamp` call. On any error
|
||||
* (HTTP, schema, expired token) we surface `CSC_PROVIDER_NO_TSA` with the
|
||||
* upstream message folded in. There's no try-in-order — at sign time the
|
||||
* recipient is fixed, so there's no other token to fall through to.
|
||||
*/
|
||||
|
||||
type CscTspTimestampAuthorityOptions = {
|
||||
transport: CscTransport;
|
||||
/** Decrypted service-scope access token for the current recipient. */
|
||||
serviceToken: string;
|
||||
/** Optional deadline for the `signatures/timestamp` call. */
|
||||
signal?: AbortSignal;
|
||||
};
|
||||
|
||||
export class CscTspTimestampAuthority implements TimestampAuthority {
|
||||
private readonly transport: CscTransport;
|
||||
|
||||
private readonly serviceToken: string;
|
||||
|
||||
private readonly signal?: AbortSignal;
|
||||
|
||||
constructor(opts: CscTspTimestampAuthorityOptions) {
|
||||
this.transport = opts.transport;
|
||||
this.serviceToken = opts.serviceToken;
|
||||
this.signal = opts.signal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request a CSC §11.10 timestamp for the supplied digest, authorised with
|
||||
* the recipient's service-scope bearer. Returns the decoded TimeStampToken
|
||||
* bytes. Throws `CSC_PROVIDER_NO_TSA` carrying the upstream error message
|
||||
* on failure.
|
||||
*
|
||||
* `algorithm` is libpdf's `DigestAlgorithm` (`SHA-256` / `SHA-384` /
|
||||
* `SHA-512`), translated to the matching `hashAlgo` OID via the existing
|
||||
* {@link hashOidForDigest} mapping so the spec's OID-typed payload stays
|
||||
* in one place.
|
||||
*/
|
||||
async timestamp(digest: Uint8Array, algorithm: DigestAlgorithm): Promise<Uint8Array> {
|
||||
const hash = Buffer.from(digest).toString('base64');
|
||||
const hashAlgo = hashOidForDigest(algorithm);
|
||||
|
||||
try {
|
||||
const response = await cscTimestamp({
|
||||
baseUrl: this.transport.serviceBaseUrl,
|
||||
accessToken: this.serviceToken,
|
||||
hash,
|
||||
hashAlgo,
|
||||
signal: this.signal,
|
||||
});
|
||||
|
||||
return Buffer.from(response.timestamp, 'base64');
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
|
||||
throw new AppError(AppErrorCode.CSC_PROVIDER_NO_TSA, {
|
||||
message: `CSC TSP timestamp endpoint refused the recipient's service token: ${message}.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { IS_INSTANCE_CSC_MODE } from '@documenso/lib/constants/app';
|
||||
import { ZRecipientActionAuthTypesSchema, ZRecipientAuthOptionsSchema } from '@documenso/lib/types/document-auth';
|
||||
import type { TEditorEnvelope } from '@documenso/lib/types/envelope-editor';
|
||||
import { ZRecipientEmailSchema } from '@documenso/lib/types/recipient';
|
||||
@@ -22,11 +23,49 @@ const LocalRecipientSchema = z.object({
|
||||
|
||||
type TLocalRecipient = z.infer<typeof LocalRecipientSchema>;
|
||||
|
||||
export const ZEditorRecipientsFormSchema = z.object({
|
||||
signers: z.array(LocalRecipientSchema),
|
||||
signingOrder: z.nativeEnum(DocumentSigningOrder),
|
||||
allowDictateNextSigner: z.boolean().default(false),
|
||||
});
|
||||
/**
|
||||
* Backstop validation that mirrors the CSC-mode UI overrides in
|
||||
* `EnvelopeEditorProvider`. If anything bypasses the disabled controls (URL
|
||||
* tampering, legacy form state, embedded host) the form refuses to submit
|
||||
* rather than persisting values the TSP flow can't honour.
|
||||
*/
|
||||
export const ZEditorRecipientsFormSchema = z
|
||||
.object({
|
||||
signers: z.array(LocalRecipientSchema),
|
||||
signingOrder: z.nativeEnum(DocumentSigningOrder),
|
||||
allowDictateNextSigner: z.boolean().default(false),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (!IS_INSTANCE_CSC_MODE()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.signingOrder !== DocumentSigningOrder.SEQUENTIAL) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'CSC envelopes must use SEQUENTIAL signing order.',
|
||||
path: ['signingOrder'],
|
||||
});
|
||||
}
|
||||
|
||||
if (data.allowDictateNextSigner) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'CSC envelopes do not support next-signer dictation.',
|
||||
path: ['allowDictateNextSigner'],
|
||||
});
|
||||
}
|
||||
|
||||
data.signers.forEach((signer, index) => {
|
||||
if (signer.role === RecipientRole.ASSISTANT) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'CSC envelopes do not support the assistant role.',
|
||||
path: ['signers', index, 'role'],
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
export type TEditorRecipientsFormSchema = z.infer<typeof ZEditorRecipientsFormSchema>;
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { IS_INSTANCE_CSC_MODE } from '@documenso/lib/constants/app';
|
||||
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
|
||||
import {
|
||||
DEFAULT_EDITOR_CONFIG,
|
||||
@@ -36,6 +37,12 @@ type EnvelopeEditorProviderValue = {
|
||||
isEmbedded: boolean;
|
||||
isDocument: boolean;
|
||||
isTemplate: boolean;
|
||||
/**
|
||||
* Whether the instance is running in CSC (Cloud Signature Consortium) mode.
|
||||
* Components can branch on this for any additional CSC-only UI gating
|
||||
* beyond the overrides already baked into `editorConfig`.
|
||||
*/
|
||||
isCscMode: boolean;
|
||||
|
||||
setLocalEnvelope: (localEnvelope: Partial<TEditorEnvelope>) => void;
|
||||
updateEnvelope: (envelopeUpdates: UpdateEnvelopePayload) => void;
|
||||
@@ -91,7 +98,7 @@ export const useCurrentEnvelopeEditor = () => {
|
||||
|
||||
export const EnvelopeEditorProvider = ({
|
||||
children,
|
||||
editorConfig = DEFAULT_EDITOR_CONFIG,
|
||||
editorConfig: providedEditorConfig = DEFAULT_EDITOR_CONFIG,
|
||||
initialEnvelope,
|
||||
organisationEmails,
|
||||
}: EnvelopeEditorProviderProps) => {
|
||||
@@ -103,6 +110,31 @@ export const EnvelopeEditorProvider = ({
|
||||
const [envelope, _setEnvelope] = useState(initialEnvelope);
|
||||
const [autosaveError, setAutosaveError] = useState<boolean>(false);
|
||||
|
||||
const isCscMode = IS_INSTANCE_CSC_MODE();
|
||||
|
||||
/**
|
||||
* CSC-mode overrides applied on top of any caller-supplied editor config.
|
||||
* TSP envelopes are forced SEQUENTIAL at send-time and the sign path has no
|
||||
* nextSigner dictation; the assistant role's pre-fill semantics don't map
|
||||
* onto each recipient signing their own complete PDF state. Hide all three
|
||||
* up-front so authors don't pick options that would get silently coerced.
|
||||
*/
|
||||
const editorConfig = useMemo<EnvelopeEditorConfig>(() => {
|
||||
if (!isCscMode || !providedEditorConfig.recipients) {
|
||||
return providedEditorConfig;
|
||||
}
|
||||
|
||||
return {
|
||||
...providedEditorConfig,
|
||||
recipients: {
|
||||
...providedEditorConfig.recipients,
|
||||
allowConfigureSigningOrder: false,
|
||||
allowConfigureDictateNextSigner: false,
|
||||
allowAssistantRole: false,
|
||||
},
|
||||
};
|
||||
}, [isCscMode, providedEditorConfig]);
|
||||
|
||||
const envelopeRef = useRef(initialEnvelope);
|
||||
|
||||
const externalFlushCallbacksRef = useRef<Map<string, () => Promise<void>>>(new Map());
|
||||
@@ -467,6 +499,7 @@ export const EnvelopeEditorProvider = ({
|
||||
isEmbedded,
|
||||
isDocument: envelope.type === EnvelopeType.DOCUMENT,
|
||||
isTemplate: envelope.type === EnvelopeType.TEMPLATE,
|
||||
isCscMode,
|
||||
setLocalEnvelope,
|
||||
getRecipientColorKey,
|
||||
updateEnvelope,
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { env } from '@documenso/lib/utils/env';
|
||||
import { AppError, AppErrorCode } from '../errors/app-error';
|
||||
import { SignatureLevel, type TSignatureLevel } from '../types/signature-level';
|
||||
|
||||
export const APP_DOCUMENT_UPLOAD_SIZE_LIMIT = Number(env('NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT')) || 50;
|
||||
|
||||
@@ -33,3 +35,54 @@ export const IS_AI_FEATURES_CONFIGURED = () => !!env('GOOGLE_VERTEX_PROJECT_ID')
|
||||
export const NEXT_PRIVATE_USE_PLAYWRIGHT_PDF = () => env('NEXT_PRIVATE_USE_PLAYWRIGHT_PDF') === 'true';
|
||||
|
||||
export const NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY = () => env('NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY');
|
||||
|
||||
/**
|
||||
* Whether this Documenso instance is running in CSC (Cloud Signature Consortium) mode.
|
||||
*
|
||||
* CSC mode routes signing through a third-party Trust Service Provider for
|
||||
* Advanced and Qualified Electronic Signatures (AES/QES). It is instance-wide
|
||||
* and mutually exclusive with the other signing transports.
|
||||
*/
|
||||
export const IS_INSTANCE_CSC_MODE = (): boolean => {
|
||||
if (typeof window === 'undefined') {
|
||||
return env('NEXT_PRIVATE_SIGNING_TRANSPORT') === 'csc';
|
||||
}
|
||||
|
||||
return env('NEXT_PUBLIC_SIGNING_TRANSPORT_IS_CSC') === 'true';
|
||||
};
|
||||
|
||||
/**
|
||||
* The default signature level applied to envelopes created on a CSC-mode
|
||||
* instance when the caller doesn't specify one (or asks for `SES` and the
|
||||
* resolver is in loose-coerce mode).
|
||||
*
|
||||
* Set via `NEXT_PRIVATE_SIGNING_CSC_SIGNATURE_LEVEL`; accepts `AES` or `QES`
|
||||
* only; defaults to `AES` when unset. An explicit `AES`/`QES` request on
|
||||
* envelope create still passes through unchanged — this constant only affects
|
||||
* the coerced default.
|
||||
*
|
||||
* Throws on an invalid value rather than silently falling back. A typo here
|
||||
* (e.g. `qes`) would otherwise silently downgrade qualified-tier instances
|
||||
* to advanced-tier, which has legal consequences.
|
||||
*
|
||||
* Only consulted on CSC-mode instances. Non-CSC instances always default to
|
||||
* `SES` regardless of this var.
|
||||
*/
|
||||
export const CSC_INSTANCE_SIGNATURE_LEVEL = (): TSignatureLevel => {
|
||||
// Cast through `string | undefined` because shells can deliver
|
||||
// `NEXT_PRIVATE_SIGNING_CSC_SIGNATURE_LEVEL=` as an empty string at runtime
|
||||
// — the typed env signature narrows to `'AES' | 'QES' | undefined` only.
|
||||
const value = env('NEXT_PRIVATE_SIGNING_CSC_SIGNATURE_LEVEL');
|
||||
|
||||
if (!value) {
|
||||
return SignatureLevel.AES;
|
||||
}
|
||||
|
||||
if (value !== SignatureLevel.AES && value !== SignatureLevel.QES) {
|
||||
throw new AppError(AppErrorCode.NOT_SETUP, {
|
||||
message: `NEXT_PRIVATE_SIGNING_CSC_SIGNATURE_LEVEL must be '${SignatureLevel.AES}' or '${SignatureLevel.QES}', got '${value}'.`,
|
||||
});
|
||||
}
|
||||
|
||||
return value;
|
||||
};
|
||||
|
||||
@@ -14,6 +14,7 @@ export enum AppErrorCode {
|
||||
NOT_FOUND = 'NOT_FOUND',
|
||||
NOT_IMPLEMENTED = 'NOT_IMPLEMENTED',
|
||||
NOT_SETUP = 'NOT_SETUP',
|
||||
MISSING_ENV_VAR = 'MISSING_ENV_VAR',
|
||||
INVALID_CAPTCHA = 'INVALID_CAPTCHA',
|
||||
UNAUTHORIZED = 'UNAUTHORIZED',
|
||||
FORBIDDEN = 'FORBIDDEN',
|
||||
@@ -27,6 +28,35 @@ export enum AppErrorCode {
|
||||
ENVELOPE_COMPLETED = 'ENVELOPE_COMPLETED',
|
||||
ENVELOPE_REJECTED = 'ENVELOPE_REJECTED',
|
||||
ENVELOPE_LEGACY = 'ENVELOPE_LEGACY',
|
||||
/**
|
||||
* Authoring mutation rejected because the envelope is an AES/QES envelope
|
||||
* past DRAFT — the TSP mutation lock fires at distribution to preserve
|
||||
* WYSIWYS. SES envelopes never hit this code.
|
||||
*/
|
||||
ENVELOPE_TSP_LOCKED = 'ENVELOPE_TSP_LOCKED',
|
||||
|
||||
/**
|
||||
* CSC (Cloud Signature Consortium) error codes. See the CSC QES V1 spec
|
||||
* for the recovery taxonomy.
|
||||
*/
|
||||
CSC_INSTANCE_MODE_MISMATCH = 'CSC_INSTANCE_MODE_MISMATCH',
|
||||
CSC_UNLICENSED = 'CSC_UNLICENSED',
|
||||
CSC_PROVIDER_INFO_FAILED = 'CSC_PROVIDER_INFO_FAILED',
|
||||
CSC_PROVIDER_NO_TSA = 'CSC_PROVIDER_NO_TSA',
|
||||
CSC_CREDENTIAL_LIST_EMPTY = 'CSC_CREDENTIAL_LIST_EMPTY',
|
||||
CSC_CERT_INVALID = 'CSC_CERT_INVALID',
|
||||
CSC_ALGORITHM_REFUSED = 'CSC_ALGORITHM_REFUSED',
|
||||
CSC_SAD_EXPIRED_PRE_SIGN = 'CSC_SAD_EXPIRED_PRE_SIGN',
|
||||
CSC_TSP_TIMEOUT = 'CSC_TSP_TIMEOUT',
|
||||
CSC_EMBED_FAILED = 'CSC_EMBED_FAILED',
|
||||
CSC_BASE_DOCUMENT_MUTATED = 'CSC_BASE_DOCUMENT_MUTATED',
|
||||
/**
|
||||
* Generic catch-all for CSC HTTP transport failures — network error, non-2xx
|
||||
* response without a more specific semantic match, malformed JSON, or
|
||||
* response schema mismatch. Carries the TSP's HTTP status in `statusCode`
|
||||
* and the TSP's `error` / `error_description` in the message when available.
|
||||
*/
|
||||
CSC_REQUEST_FAILED = 'CSC_REQUEST_FAILED',
|
||||
}
|
||||
|
||||
export const genericErrorCodeToTrpcErrorCodeMap: Record<string, { code: string; status: number }> = {
|
||||
@@ -39,6 +69,7 @@ export const genericErrorCodeToTrpcErrorCodeMap: Record<string, { code: string;
|
||||
[AppErrorCode.NOT_FOUND]: { code: 'NOT_FOUND', status: 404 },
|
||||
[AppErrorCode.NOT_IMPLEMENTED]: { code: 'INTERNAL_SERVER_ERROR', status: 501 },
|
||||
[AppErrorCode.NOT_SETUP]: { code: 'BAD_REQUEST', status: 400 },
|
||||
[AppErrorCode.MISSING_ENV_VAR]: { code: 'INTERNAL_SERVER_ERROR', status: 500 },
|
||||
[AppErrorCode.UNAUTHORIZED]: { code: 'UNAUTHORIZED', status: 401 },
|
||||
[AppErrorCode.FORBIDDEN]: { code: 'FORBIDDEN', status: 403 },
|
||||
[AppErrorCode.UNKNOWN_ERROR]: { code: 'INTERNAL_SERVER_ERROR', status: 500 },
|
||||
@@ -50,6 +81,23 @@ export const genericErrorCodeToTrpcErrorCodeMap: Record<string, { code: string;
|
||||
[AppErrorCode.ENVELOPE_COMPLETED]: { code: 'BAD_REQUEST', status: 400 },
|
||||
[AppErrorCode.ENVELOPE_REJECTED]: { code: 'BAD_REQUEST', status: 400 },
|
||||
[AppErrorCode.ENVELOPE_LEGACY]: { code: 'BAD_REQUEST', status: 400 },
|
||||
[AppErrorCode.ENVELOPE_TSP_LOCKED]: { code: 'BAD_REQUEST', status: 400 },
|
||||
[AppErrorCode.CSC_INSTANCE_MODE_MISMATCH]: { code: 'BAD_REQUEST', status: 400 },
|
||||
[AppErrorCode.CSC_UNLICENSED]: { code: 'FORBIDDEN', status: 403 },
|
||||
[AppErrorCode.CSC_PROVIDER_INFO_FAILED]: { code: 'INTERNAL_SERVER_ERROR', status: 500 },
|
||||
[AppErrorCode.CSC_PROVIDER_NO_TSA]: { code: 'INTERNAL_SERVER_ERROR', status: 500 },
|
||||
[AppErrorCode.CSC_CREDENTIAL_LIST_EMPTY]: { code: 'BAD_REQUEST', status: 400 },
|
||||
[AppErrorCode.CSC_CERT_INVALID]: { code: 'BAD_REQUEST', status: 400 },
|
||||
[AppErrorCode.CSC_ALGORITHM_REFUSED]: { code: 'BAD_REQUEST', status: 400 },
|
||||
[AppErrorCode.CSC_SAD_EXPIRED_PRE_SIGN]: { code: 'BAD_REQUEST', status: 400 },
|
||||
[AppErrorCode.CSC_TSP_TIMEOUT]: { code: 'TIMEOUT', status: 408 },
|
||||
[AppErrorCode.CSC_EMBED_FAILED]: { code: 'BAD_REQUEST', status: 400 },
|
||||
[AppErrorCode.CSC_BASE_DOCUMENT_MUTATED]: { code: 'INTERNAL_SERVER_ERROR', status: 500 },
|
||||
// Generic transport failure — the TSP is upstream so server-side from our
|
||||
// perspective; 500 keeps the caller surface conservative. The TSP's actual
|
||||
// HTTP status rides along in AppError.statusCode for the few callers that
|
||||
// need to discriminate (e.g. 401 → re-auth, 429 → backoff).
|
||||
[AppErrorCode.CSC_REQUEST_FAILED]: { code: 'INTERNAL_SERVER_ERROR', status: 500 },
|
||||
};
|
||||
|
||||
export const ZAppErrorJsonSchema = z.object({
|
||||
@@ -239,10 +287,17 @@ export class AppError extends Error {
|
||||
AppErrorCode.ENVELOPE_COMPLETED,
|
||||
AppErrorCode.ENVELOPE_REJECTED,
|
||||
AppErrorCode.ENVELOPE_LEGACY,
|
||||
AppErrorCode.ENVELOPE_TSP_LOCKED,
|
||||
AppErrorCode.CSC_INSTANCE_MODE_MISMATCH,
|
||||
AppErrorCode.CSC_CREDENTIAL_LIST_EMPTY,
|
||||
AppErrorCode.CSC_CERT_INVALID,
|
||||
AppErrorCode.CSC_ALGORITHM_REFUSED,
|
||||
AppErrorCode.CSC_SAD_EXPIRED_PRE_SIGN,
|
||||
AppErrorCode.CSC_EMBED_FAILED,
|
||||
() => 400 as const,
|
||||
)
|
||||
.with(AppErrorCode.UNAUTHORIZED, () => 401 as const)
|
||||
.with(AppErrorCode.FORBIDDEN, () => 403 as const)
|
||||
.with(AppErrorCode.FORBIDDEN, AppErrorCode.CSC_UNLICENSED, () => 403 as const)
|
||||
.with(AppErrorCode.NOT_FOUND, () => 404 as const)
|
||||
.with(AppErrorCode.NOT_IMPLEMENTED, () => 501 as const)
|
||||
.otherwise(() => 500 as const);
|
||||
|
||||
@@ -20,6 +20,7 @@ const BACKPORT_SUBSCRIPTION_CLAIM_JOB_DEFINITION_SCHEMA = z.object({
|
||||
cfr21: z.literal(true).optional(),
|
||||
hipaa: z.literal(true).optional(),
|
||||
signingReminders: z.literal(true).optional(),
|
||||
cscQesSigning: z.literal(true).optional(),
|
||||
// Do NOT backport disableEmails.
|
||||
// Todo: Envelopes - Do we need to check?
|
||||
// authenticationPortal & emailDomains missing here.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import path from 'node:path';
|
||||
import { PDFDocument } from '@cantoo/pdf-lib';
|
||||
import { finalizeTspEnvelopeCompletion } from '@documenso/ee/server-only/signing/csc/finalize-tsp-completion';
|
||||
import { addRejectionStampToPdf } from '@documenso/lib/server-only/pdf/add-rejection-stamp-to-pdf';
|
||||
import { generateAuditLogPdf } from '@documenso/lib/server-only/pdf/generate-audit-log-pdf';
|
||||
import { generateCertificatePdf } from '@documenso/lib/server-only/pdf/generate-certificate-pdf';
|
||||
@@ -22,6 +23,7 @@ import { legacy_insertFieldInPDF } from '../../../server-only/pdf/legacy-insert-
|
||||
import { getTeamSettings } from '../../../server-only/team/get-team-settings';
|
||||
import { triggerWebhook } from '../../../server-only/webhooks/trigger/trigger-webhook';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE, type TDocumentAuditLog } from '../../../types/document-audit-logs';
|
||||
import { isTspEnvelope } from '../../../types/signature-level';
|
||||
import { mapEnvelopeToWebhookDocumentPayload, ZWebhookDocumentSchema } from '../../../types/webhook-payload';
|
||||
import { prefixedId } from '../../../universal/id';
|
||||
import { getFileServerSide } from '../../../universal/upload/get-file.server';
|
||||
@@ -164,6 +166,33 @@ export const run = async ({ payload, io }: { payload: TSealDocumentJobDefinition
|
||||
|
||||
const finalEnvelopeStatus = isRejected ? DocumentStatus.REJECTED : DocumentStatus.COMPLETED;
|
||||
|
||||
if (isTspEnvelope(envelope)) {
|
||||
if (isResealing) {
|
||||
throw new AppError(AppErrorCode.NOT_SETUP, {
|
||||
message: 'Re-sealing TSP envelopes is not supported — recipient signatures cannot be regenerated externally.',
|
||||
});
|
||||
}
|
||||
|
||||
if (isRejected) {
|
||||
throw new AppError(AppErrorCode.NOT_SETUP, {
|
||||
message:
|
||||
'TSP envelope rejection is not supported in V1 — rejection stamps would invalidate PAdES signatures.',
|
||||
});
|
||||
}
|
||||
|
||||
await finalizeTspEnvelopeCompletion({
|
||||
envelope,
|
||||
envelopeCompletedAuditLog,
|
||||
requestMetadata,
|
||||
});
|
||||
|
||||
return {
|
||||
envelopeId: envelope.id,
|
||||
envelopeStatus: envelope.status,
|
||||
isRejected,
|
||||
};
|
||||
}
|
||||
|
||||
// Pre-fetch all PDF data so we can read dimensions and pass it
|
||||
// to decorateAndSignPdf without fetching again.
|
||||
const prefetchedItems = await Promise.all(
|
||||
|
||||
@@ -8,7 +8,10 @@ import type { SupportedLanguageCodes } from '../../constants/i18n';
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import type { TDocumentEmailSettings } from '../../types/document-email';
|
||||
import type { EnvelopeIdOptions } from '../../utils/envelope';
|
||||
import { assertEnvelopeMutable } from '../envelope/assert-envelope-mutable';
|
||||
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
|
||||
import { assertCompatibleDictateNextSigner } from '../signature-level/assert-compatible-dictate-next-signer';
|
||||
import { assertCompatibleSigningOrder } from '../signature-level/assert-compatible-signing-order';
|
||||
|
||||
export type CreateDocumentMetaOptions = {
|
||||
userId: number;
|
||||
@@ -73,6 +76,22 @@ export const updateDocumentMeta = async ({
|
||||
});
|
||||
}
|
||||
|
||||
await assertEnvelopeMutable(envelope);
|
||||
|
||||
if (signingOrder !== undefined) {
|
||||
assertCompatibleSigningOrder({
|
||||
signatureLevel: envelope.signatureLevel,
|
||||
signingOrder,
|
||||
});
|
||||
}
|
||||
|
||||
if (allowDictateNextSigner !== undefined) {
|
||||
assertCompatibleDictateNextSigner({
|
||||
signatureLevel: envelope.signatureLevel,
|
||||
allowDictateNextSigner,
|
||||
});
|
||||
}
|
||||
|
||||
const { documentMeta: originalDocumentMeta } = envelope;
|
||||
|
||||
// Validate the emailId belongs to the organisation.
|
||||
@@ -92,6 +111,8 @@ export const updateDocumentMeta = async ({
|
||||
}
|
||||
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
await assertEnvelopeMutable(envelope, tx);
|
||||
|
||||
const upsertedDocumentMeta = await tx.documentMeta.update({
|
||||
where: {
|
||||
id: envelope.documentMetaId,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { materializeTspAnchorsForEnvelope } from '@documenso/ee/server-only/signing/csc/materialize-anchors';
|
||||
import { resolveExpiresAt } from '@documenso/lib/constants/envelope-expiration';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
||||
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||
@@ -29,6 +30,7 @@ import {
|
||||
ZRadioFieldMeta,
|
||||
ZTextFieldMeta,
|
||||
} from '../../types/field-meta';
|
||||
import { isTspEnvelope } from '../../types/signature-level';
|
||||
import { mapEnvelopeToWebhookDocumentPayload, ZWebhookDocumentSchema } from '../../types/webhook-payload';
|
||||
import { getFileServerSide } from '../../universal/upload/get-file.server';
|
||||
import { putNormalizedPdfFileServerSide } from '../../universal/upload/put-file.server';
|
||||
@@ -124,7 +126,26 @@ export const sendDocument = async ({ id, userId, teamId, sendEmail, requestMetad
|
||||
|
||||
const legacyDocumentId = mapSecondaryIdToDocumentId(envelope.secondaryId);
|
||||
|
||||
const signingOrder = envelope.documentMeta?.signingOrder || DocumentSigningOrder.PARALLEL;
|
||||
let signingOrder = envelope.documentMeta?.signingOrder || DocumentSigningOrder.PARALLEL;
|
||||
|
||||
if (isTspEnvelope(envelope) && signingOrder === DocumentSigningOrder.PARALLEL && envelope.documentMeta) {
|
||||
console.warn(
|
||||
`[CSC] Coercing signingOrder=PARALLEL → SEQUENTIAL for ${envelope.signatureLevel} envelope ${envelope.id} at send time. The schema-layer guard should have caught this earlier.`,
|
||||
);
|
||||
|
||||
await prisma.documentMeta.update({
|
||||
where: {
|
||||
id: envelope.documentMeta.id,
|
||||
},
|
||||
data: {
|
||||
signingOrder: DocumentSigningOrder.SEQUENTIAL,
|
||||
},
|
||||
});
|
||||
|
||||
signingOrder = DocumentSigningOrder.SEQUENTIAL;
|
||||
|
||||
envelope.documentMeta.signingOrder = DocumentSigningOrder.SEQUENTIAL;
|
||||
}
|
||||
|
||||
let recipientsToNotify = envelope.recipients;
|
||||
|
||||
@@ -139,7 +160,7 @@ export const sendDocument = async ({ id, userId, teamId, sendEmail, requestMetad
|
||||
throw new Error('Missing envelope items');
|
||||
}
|
||||
|
||||
if (envelope.formValues) {
|
||||
if (envelope.formValues && envelope.status === DocumentStatus.DRAFT) {
|
||||
await Promise.all(
|
||||
envelope.envelopeItems.map(async (envelopeItem) => {
|
||||
await injectFormValuesIntoDocument(envelope, envelopeItem);
|
||||
@@ -225,6 +246,12 @@ export const sendDocument = async ({ id, userId, teamId, sendEmail, requestMetad
|
||||
}
|
||||
}
|
||||
|
||||
if (isTspEnvelope(envelope) && envelope.status === DocumentStatus.DRAFT) {
|
||||
await materializeTspAnchorsForEnvelope({
|
||||
envelopeId: envelope.id,
|
||||
});
|
||||
}
|
||||
|
||||
const updatedEnvelope = await prisma.$transaction(async (tx) => {
|
||||
if (envelope.status === DocumentStatus.DRAFT) {
|
||||
await tx.documentAuditLog.create({
|
||||
|
||||
@@ -6,6 +6,7 @@ import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import type { Envelope, Field, Recipient } from '@prisma/client';
|
||||
|
||||
import { assertEnvelopeMutable } from '../envelope/assert-envelope-mutable';
|
||||
import { convertPlaceholdersToFieldInputs, extractPdfPlaceholders } from '../pdf/auto-place-fields';
|
||||
import { findRecipientByPlaceholder } from '../pdf/helpers';
|
||||
import { insertFormValuesInPdf } from '../pdf/insert-form-values-in-pdf';
|
||||
@@ -96,6 +97,8 @@ export const UNSAFE_replaceEnvelopeItemPdf = async ({
|
||||
let didFieldsChange = false;
|
||||
|
||||
const updatedEnvelopeItem = await prisma.$transaction(async (tx) => {
|
||||
await assertEnvelopeMutable(envelope, tx);
|
||||
|
||||
const updatedItem = await tx.envelopeItem.update({
|
||||
where: {
|
||||
id: envelopeItemId,
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { DocumentStatus, type Envelope, type Prisma } from '@prisma/client';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { isTspEnvelope } from '../../types/signature-level';
|
||||
|
||||
type EnvelopeMutableSnapshot = {
|
||||
signatureLevel: string;
|
||||
status: DocumentStatus;
|
||||
};
|
||||
|
||||
type EnvelopeIdRef = Pick<Envelope, 'id'>;
|
||||
|
||||
/**
|
||||
* Reject authoring mutations on an AES/QES envelope past DRAFT.
|
||||
*
|
||||
* The TSP mutation lock fires at distribution so the owner cannot replace the
|
||||
* PDF between a recipient completing service-scope OAuth (against PDF_v1) and
|
||||
* clicking Sign (now against PDF_v2). The SAD would authorise PDF_v2's digest
|
||||
* while the recipient viewed PDF_v1 — a WYSIWYS break.
|
||||
*
|
||||
* SES envelopes pass through unchanged. The existing per-route guards still
|
||||
* enforce COMPLETED/REJECTED rejection for them.
|
||||
*
|
||||
* Call this **twice** at every TSP-eligible authoring route:
|
||||
*
|
||||
* 1. Outside the transaction with the pre-fetched envelope snapshot —
|
||||
* `assertEnvelopeMutable(envelope)` — fast-fail without a DB round-trip.
|
||||
* 2. Inside the transaction with `tx` — `assertEnvelopeMutable(envelope, tx)`
|
||||
* — re-fetches under the transaction's snapshot, closing the TOCTOU
|
||||
* window against a concurrent `sendDocument` committing DRAFT → PENDING
|
||||
* between the snapshot read and the mutation.
|
||||
*
|
||||
* Throws:
|
||||
* - `ENVELOPE_TSP_LOCKED` when the envelope is PENDING (the case unique to
|
||||
* the TSP lock — SES routes happily allow PENDING).
|
||||
* - `ENVELOPE_COMPLETED` / `ENVELOPE_REJECTED` for those terminal states, to
|
||||
* stay consistent with the existing envelope-state error vocabulary.
|
||||
*/
|
||||
export function assertEnvelopeMutable(envelope: EnvelopeMutableSnapshot): Promise<void>;
|
||||
export function assertEnvelopeMutable(envelope: EnvelopeIdRef, tx: Prisma.TransactionClient): Promise<void>;
|
||||
|
||||
export async function assertEnvelopeMutable(
|
||||
envelope: EnvelopeMutableSnapshot | EnvelopeIdRef,
|
||||
tx?: Prisma.TransactionClient,
|
||||
): Promise<void> {
|
||||
if (tx) {
|
||||
return await refetchAndAssert(tx, (envelope as EnvelopeIdRef).id);
|
||||
}
|
||||
|
||||
assertSnapshotMutable(envelope as EnvelopeMutableSnapshot);
|
||||
}
|
||||
|
||||
const refetchAndAssert = async (tx: Prisma.TransactionClient, envelopeId: string): Promise<void> => {
|
||||
const refetched = await tx.envelope.findFirstOrThrow({
|
||||
where: { id: envelopeId },
|
||||
select: { signatureLevel: true, status: true },
|
||||
});
|
||||
|
||||
assertSnapshotMutable(refetched);
|
||||
};
|
||||
|
||||
const assertSnapshotMutable = (envelope: EnvelopeMutableSnapshot): void => {
|
||||
if (!isTspEnvelope(envelope)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (envelope.status === DocumentStatus.DRAFT) {
|
||||
return;
|
||||
}
|
||||
|
||||
const errorCode = match(envelope.status)
|
||||
.with(DocumentStatus.PENDING, () => AppErrorCode.ENVELOPE_TSP_LOCKED)
|
||||
.with(DocumentStatus.COMPLETED, () => AppErrorCode.ENVELOPE_COMPLETED)
|
||||
.with(DocumentStatus.REJECTED, () => AppErrorCode.ENVELOPE_REJECTED)
|
||||
.otherwise(() => AppErrorCode.INVALID_REQUEST);
|
||||
|
||||
throw new AppError(errorCode, {
|
||||
message: `Envelope is locked — AES/QES envelopes cannot be modified after leaving DRAFT (current status: ${envelope.status}).`,
|
||||
});
|
||||
};
|
||||
@@ -29,6 +29,7 @@ import type {
|
||||
import type { TDocumentFormValues } from '../../types/document-form-values';
|
||||
import type { TEnvelopeAttachmentType } from '../../types/envelope-attachment';
|
||||
import type { TFieldAndMeta } from '../../types/field-meta';
|
||||
import type { TSignatureLevel } from '../../types/signature-level';
|
||||
import { mapEnvelopeToWebhookDocumentPayload, ZWebhookDocumentSchema } from '../../types/webhook-payload';
|
||||
import { getFileServerSide } from '../../universal/upload/get-file.server';
|
||||
import { putPdfFileServerSide } from '../../universal/upload/put-file.server';
|
||||
@@ -38,6 +39,8 @@ import { getRecipientSigningOrder } from '../../utils/recipients';
|
||||
import { buildTeamWhereQuery } from '../../utils/teams';
|
||||
import { incrementDocumentId, incrementTemplateId } from '../envelope/increment-id';
|
||||
import { assertOrganisationRatesAndLimits } from '../rate-limit/assert-organisation-rates-and-limits';
|
||||
import { assertCompatibleRecipientRole } from '../signature-level/assert-compatible-recipient-role';
|
||||
import { resolveSignatureLevel } from '../signature-level/resolve-signature-level';
|
||||
import { getTeamSettings } from '../team/get-team-settings';
|
||||
import { assertUserNotDisabledById } from '../user/assert-user-not-disabled';
|
||||
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||
@@ -90,6 +93,7 @@ export type CreateEnvelopeOptions = {
|
||||
recipients?: CreateEnvelopeRecipientOptions[];
|
||||
folderId?: string;
|
||||
delegatedDocumentOwner?: string;
|
||||
signatureLevel?: TSignatureLevel;
|
||||
};
|
||||
attachments?: Array<{
|
||||
label: string;
|
||||
@@ -138,8 +142,14 @@ export const createEnvelope = async ({
|
||||
publicDescription,
|
||||
visibility: visibilityOverride,
|
||||
delegatedDocumentOwner,
|
||||
signatureLevel: requestedSignatureLevel,
|
||||
} = data;
|
||||
|
||||
const signatureLevel = resolveSignatureLevel({
|
||||
requested: requestedSignatureLevel,
|
||||
strict: true,
|
||||
});
|
||||
|
||||
const team = await prisma.team.findFirst({
|
||||
where: buildTeamWhereQuery({ teamId, userId }),
|
||||
include: {
|
||||
@@ -196,6 +206,17 @@ export const createEnvelope = async ({
|
||||
});
|
||||
}
|
||||
|
||||
// CSC / TSP signing flows assume the V2 envelope shape: per-recipient
|
||||
// anchors, materialised PDF lineage, sequential signing, mutation lock.
|
||||
// The legacy V1 (Document) model can't carry that state, so AES/QES on V1
|
||||
// is structurally unsupported and must fail at create time — not later at
|
||||
// sign or seal time when the cause is harder to attribute.
|
||||
if (signatureLevel !== 'SES' && internalVersion === 1) {
|
||||
throw new AppError(AppErrorCode.INVALID_BODY, {
|
||||
message: `Envelopes signed at '${signatureLevel}' require internalVersion=2; the legacy V1 envelope shape cannot host TSP signing.`,
|
||||
});
|
||||
}
|
||||
|
||||
let envelopeItems = data.envelopeItems;
|
||||
|
||||
// Todo: Envelopes - Remove
|
||||
@@ -256,6 +277,10 @@ export const createEnvelope = async ({
|
||||
});
|
||||
}
|
||||
|
||||
for (const recipient of data.recipients ?? []) {
|
||||
assertCompatibleRecipientRole({ signatureLevel, role: recipient.role });
|
||||
}
|
||||
|
||||
const visibility = visibilityOverride || settings.documentVisibility;
|
||||
|
||||
const emailId = meta?.emailId;
|
||||
@@ -312,10 +337,14 @@ export const createEnvelope = async ({
|
||||
|
||||
const [documentMeta, secondaryId, delegatedOwner] = await Promise.all([
|
||||
prisma.documentMeta.create({
|
||||
data: extractDerivedDocumentMeta(settings, {
|
||||
...meta,
|
||||
timezone: timezoneToUse,
|
||||
}),
|
||||
data: extractDerivedDocumentMeta(
|
||||
settings,
|
||||
{
|
||||
...meta,
|
||||
timezone: timezoneToUse,
|
||||
},
|
||||
signatureLevel,
|
||||
),
|
||||
}),
|
||||
type === EnvelopeType.DOCUMENT
|
||||
? incrementDocumentId().then((v) => v.formattedDocumentId)
|
||||
@@ -332,6 +361,7 @@ export const createEnvelope = async ({
|
||||
internalVersion,
|
||||
type,
|
||||
title,
|
||||
signatureLevel,
|
||||
qrToken: prefixedId('qr'),
|
||||
externalId,
|
||||
envelopeItems: {
|
||||
|
||||
@@ -4,6 +4,7 @@ import pMap from 'p-map';
|
||||
import { omit } from 'remeda';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { ZSignatureLevelSchema } from '../../types/signature-level';
|
||||
import { mapEnvelopeToWebhookDocumentPayload, ZWebhookDocumentSchema } from '../../types/webhook-payload';
|
||||
import { nanoid, prefixedId } from '../../universal/id';
|
||||
import type { EnvelopeIdOptions } from '../../utils/envelope';
|
||||
@@ -11,6 +12,7 @@ import { getRecipientSigningOrder } from '../../utils/recipients';
|
||||
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
|
||||
import { incrementDocumentId, incrementTemplateId } from '../envelope/increment-id';
|
||||
import { assertOrganisationRatesAndLimits } from '../rate-limit/assert-organisation-rates-and-limits';
|
||||
import { resolveSignatureLevel } from '../signature-level/resolve-signature-level';
|
||||
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||
|
||||
export interface DuplicateEnvelopeOptions {
|
||||
@@ -41,6 +43,7 @@ export const duplicateEnvelope = async ({ id, userId, teamId, overrides }: Dupli
|
||||
title: true,
|
||||
userId: true,
|
||||
internalVersion: true,
|
||||
signatureLevel: true,
|
||||
templateType: true,
|
||||
publicTitle: true,
|
||||
publicDescription: true,
|
||||
@@ -117,12 +120,21 @@ export const duplicateEnvelope = async ({ id, userId, teamId, overrides }: Dupli
|
||||
? 'PRIVATE'
|
||||
: (envelope.templateType ?? undefined);
|
||||
|
||||
// The source level is a free-form TEXT column — parse defensively before
|
||||
// handing to the resolver. Coerce (not strict) because instance mode may have
|
||||
// changed since the source envelope was created.
|
||||
const duplicatedSignatureLevel = resolveSignatureLevel({
|
||||
requested: ZSignatureLevelSchema.parse(envelope.signatureLevel),
|
||||
strict: false,
|
||||
});
|
||||
|
||||
const duplicatedEnvelope = await prisma.envelope.create({
|
||||
data: {
|
||||
id: prefixedId('envelope'),
|
||||
secondaryId,
|
||||
type: targetType,
|
||||
internalVersion: envelope.internalVersion,
|
||||
signatureLevel: duplicatedSignatureLevel,
|
||||
userId,
|
||||
teamId,
|
||||
title: envelope.title + ' (copy)',
|
||||
|
||||
@@ -36,6 +36,7 @@ export const ZEnvelopeForSigningResponse = z.object({
|
||||
authOptions: true,
|
||||
userId: true,
|
||||
teamId: true,
|
||||
signatureLevel: true,
|
||||
}).extend({
|
||||
documentMeta: DocumentMetaSchema.pick({
|
||||
signingOrder: true,
|
||||
|
||||
@@ -15,7 +15,10 @@ import { createDocumentAuthOptions, extractDocumentAuthMethods } from '../../uti
|
||||
import type { EnvelopeIdOptions } from '../../utils/envelope';
|
||||
import { buildTeamWhereQuery, canAccessTeamDocument } from '../../utils/teams';
|
||||
import { recomputeNextReminderForEnvelope } from '../recipient/update-recipient-next-reminder';
|
||||
import { assertCompatibleDictateNextSigner } from '../signature-level/assert-compatible-dictate-next-signer';
|
||||
import { assertCompatibleSigningOrder } from '../signature-level/assert-compatible-signing-order';
|
||||
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||
import { assertEnvelopeMutable } from './assert-envelope-mutable';
|
||||
import { getEnvelopeWhereInput } from './get-envelope-by-id';
|
||||
|
||||
export type UpdateEnvelopeOptions = {
|
||||
@@ -76,6 +79,22 @@ export const updateEnvelope = async ({
|
||||
});
|
||||
}
|
||||
|
||||
assertEnvelopeMutable(envelope);
|
||||
|
||||
if (meta.signingOrder !== undefined) {
|
||||
assertCompatibleSigningOrder({
|
||||
signatureLevel: envelope.signatureLevel,
|
||||
signingOrder: meta.signingOrder,
|
||||
});
|
||||
}
|
||||
|
||||
if (meta.allowDictateNextSigner !== undefined) {
|
||||
assertCompatibleDictateNextSigner({
|
||||
signatureLevel: envelope.signatureLevel,
|
||||
allowDictateNextSigner: meta.allowDictateNextSigner,
|
||||
});
|
||||
}
|
||||
|
||||
if (envelope.type !== EnvelopeType.TEMPLATE && (data.publicTitle || data.publicDescription || data.templateType)) {
|
||||
throw new AppError(AppErrorCode.INVALID_BODY, {
|
||||
message: 'You cannot update the template fields for document type envelopes',
|
||||
@@ -297,6 +316,8 @@ export const updateEnvelope = async ({
|
||||
// }
|
||||
|
||||
const updatedEnvelope = await prisma.$transaction(async (tx) => {
|
||||
await assertEnvelopeMutable(envelope, tx);
|
||||
|
||||
const result = await tx.envelope.update({
|
||||
where: {
|
||||
id: envelope.id,
|
||||
|
||||
@@ -12,6 +12,7 @@ import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import type { EnvelopeIdOptions } from '../../utils/envelope';
|
||||
import { mapFieldToLegacyField } from '../../utils/fields';
|
||||
import { canRecipientFieldsBeModified } from '../../utils/recipients';
|
||||
import { assertEnvelopeMutable } from '../envelope/assert-envelope-mutable';
|
||||
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
|
||||
import { type BoundingBox, whiteoutRegions } from '../pdf/auto-place-fields';
|
||||
|
||||
@@ -93,6 +94,8 @@ export const createEnvelopeFields = async ({
|
||||
});
|
||||
}
|
||||
|
||||
assertEnvelopeMutable(envelope);
|
||||
|
||||
if (envelope.type === EnvelopeType.DOCUMENT && envelope.completedAt) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'Envelope already complete',
|
||||
@@ -242,6 +245,8 @@ export const createEnvelopeFields = async ({
|
||||
});
|
||||
|
||||
const createdFields = await prisma.$transaction(async (tx) => {
|
||||
await assertEnvelopeMutable(envelope, tx);
|
||||
|
||||
const newlyCreatedFields = await tx.field.createManyAndReturn({
|
||||
data: validatedFields.map((field) => ({
|
||||
type: field.type,
|
||||
|
||||
@@ -6,6 +6,7 @@ import { EnvelopeType } from '@prisma/client';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { canRecipientFieldsBeModified } from '../../utils/recipients';
|
||||
import { assertEnvelopeMutable } from '../envelope/assert-envelope-mutable';
|
||||
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
|
||||
|
||||
export interface DeleteDocumentFieldOptions {
|
||||
@@ -59,6 +60,8 @@ export const deleteDocumentField = async ({ userId, teamId, fieldId, requestMeta
|
||||
});
|
||||
}
|
||||
|
||||
assertEnvelopeMutable(envelope);
|
||||
|
||||
if (envelope.completedAt) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'Document already complete',
|
||||
@@ -81,6 +84,8 @@ export const deleteDocumentField = async ({ userId, teamId, fieldId, requestMeta
|
||||
}
|
||||
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
await assertEnvelopeMutable(envelope, tx);
|
||||
|
||||
const deletedField = await tx.field.delete({
|
||||
where: {
|
||||
id: fieldId,
|
||||
|
||||
@@ -9,6 +9,7 @@ import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import type { EnvelopeIdOptions } from '../../utils/envelope';
|
||||
import { mapFieldToLegacyField } from '../../utils/fields';
|
||||
import { canRecipientFieldsBeModified } from '../../utils/recipients';
|
||||
import { assertEnvelopeMutable } from '../envelope/assert-envelope-mutable';
|
||||
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
|
||||
|
||||
export interface UpdateEnvelopeFieldsOptions {
|
||||
@@ -60,6 +61,8 @@ export const updateEnvelopeFields = async ({
|
||||
});
|
||||
}
|
||||
|
||||
assertEnvelopeMutable(envelope);
|
||||
|
||||
if (envelope.completedAt) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'Envelope already complete',
|
||||
@@ -115,6 +118,8 @@ export const updateEnvelopeFields = async ({
|
||||
});
|
||||
|
||||
const updatedFields = await prisma.$transaction(async (tx) => {
|
||||
await assertEnvelopeMutable(envelope, tx);
|
||||
|
||||
return await Promise.all(
|
||||
fieldsToUpdate.map(async ({ originalField, updateData, recipientEmail }) => {
|
||||
const updatedField = await tx.field.update({
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import type { LicenseFlag, TCachedLicense } from '../../types/license';
|
||||
import { env } from '../../utils/env';
|
||||
import { LicenseClient } from './license-client';
|
||||
|
||||
type AssertLicensedForOptions = {
|
||||
/**
|
||||
* Override the AppError code thrown when the assertion fails.
|
||||
*
|
||||
* Defaults to `AppErrorCode.FORBIDDEN`. Callers that need a more specific
|
||||
* surface — for example the CSC transport throwing `CSC_UNLICENSED` at
|
||||
* transport-create time — pass their own code here.
|
||||
*/
|
||||
errorCode?: string;
|
||||
|
||||
/**
|
||||
* Override the AppError message thrown when the assertion fails.
|
||||
*/
|
||||
message?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Assert the configured Documenso licence grants `flag`. Reads the
|
||||
* {@link LicenseClient} cache; never re-pings the licence server.
|
||||
*
|
||||
* - No `NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY` → throws. No licensing intent.
|
||||
* - Key set, claim unverifiable (no client, null cache, read throws,
|
||||
* `license: null`) → passes. Mirrors how org-claim gates keep running on
|
||||
* last known state when the licence server is unreachable; paying
|
||||
* operators shouldn't be locked out by transient infra.
|
||||
* - Key set, claim loaded and denies the flag (bad standing or flag falsy)
|
||||
* → throws.
|
||||
*/
|
||||
export const assertLicensedFor = async (flag: LicenseFlag, options?: AssertLicensedForOptions): Promise<void> => {
|
||||
const denied = (): AppError =>
|
||||
new AppError(options?.errorCode ?? AppErrorCode.FORBIDDEN, {
|
||||
message: options?.message ?? `License does not include the "${flag}" feature.`,
|
||||
});
|
||||
|
||||
// No licence key configured = no licensing intent. Fail closed unconditionally
|
||||
// so unlicensed instances cannot reach gated features simply because the
|
||||
// licence cache is empty.
|
||||
if (!env('NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY')) {
|
||||
throw denied();
|
||||
}
|
||||
|
||||
let cached: TCachedLicense | null = null;
|
||||
|
||||
const licenseClient = LicenseClient.getInstance();
|
||||
|
||||
if (licenseClient) {
|
||||
cached = await licenseClient?.getCachedLicense().catch(() => null);
|
||||
}
|
||||
|
||||
// Licence key is configured but we have no positively-verified claim to
|
||||
// check. Fail-open — see block comment for the full set of conditions and
|
||||
// rationale.
|
||||
if (!cached?.license) {
|
||||
return;
|
||||
}
|
||||
|
||||
const inGoodStanding = cached.derivedStatus === 'ACTIVE' || cached.derivedStatus === 'PAST_DUE';
|
||||
|
||||
const flagGranted = Boolean(cached.license.flags[flag]);
|
||||
|
||||
if (!inGoodStanding || !flagGranted) {
|
||||
throw denied();
|
||||
}
|
||||
};
|
||||
@@ -10,7 +10,9 @@ import { EnvelopeType, RecipientRole, SendStatus, SigningStatus } from '@prisma/
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import type { EnvelopeIdOptions } from '../../utils/envelope';
|
||||
import { getRecipientSigningOrder, mapRecipientToLegacyRecipient } from '../../utils/recipients';
|
||||
import { assertEnvelopeMutable } from '../envelope/assert-envelope-mutable';
|
||||
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
|
||||
import { assertCompatibleRecipientRole } from '../signature-level/assert-compatible-recipient-role';
|
||||
|
||||
export interface CreateEnvelopeRecipientsOptions {
|
||||
userId: number;
|
||||
@@ -63,6 +65,8 @@ export const createEnvelopeRecipients = async ({
|
||||
});
|
||||
}
|
||||
|
||||
assertEnvelopeMutable(envelope);
|
||||
|
||||
if (envelope.completedAt) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'Envelope already complete',
|
||||
@@ -80,12 +84,21 @@ export const createEnvelopeRecipients = async ({
|
||||
});
|
||||
}
|
||||
|
||||
for (const recipient of recipientsToCreate) {
|
||||
assertCompatibleRecipientRole({
|
||||
signatureLevel: envelope.signatureLevel,
|
||||
role: recipient.role,
|
||||
});
|
||||
}
|
||||
|
||||
const normalizedRecipients = recipientsToCreate.map((recipient) => ({
|
||||
...recipient,
|
||||
email: recipient.email.toLowerCase(),
|
||||
}));
|
||||
|
||||
const createdRecipients = await prisma.$transaction(async (tx) => {
|
||||
await assertEnvelopeMutable(envelope, tx);
|
||||
|
||||
return await Promise.all(
|
||||
normalizedRecipients.map(async (recipient) => {
|
||||
const authOptions = createRecipientAuthOptions({
|
||||
|
||||
@@ -16,6 +16,7 @@ import { canRecipientBeModified, isRecipientEmailValidForSending } from '../../u
|
||||
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
|
||||
import { buildTeamWhereQuery } from '../../utils/teams';
|
||||
import { getEmailContext } from '../email/get-email-context';
|
||||
import { assertEnvelopeMutable } from '../envelope/assert-envelope-mutable';
|
||||
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
|
||||
import { assertOrganisationRatesAndLimits } from '../rate-limit/assert-organisation-rates-and-limits';
|
||||
|
||||
@@ -72,6 +73,8 @@ export const deleteEnvelopeRecipient = async ({
|
||||
});
|
||||
}
|
||||
|
||||
assertEnvelopeMutable(envelope);
|
||||
|
||||
if (envelope.completedAt) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'Document already complete',
|
||||
@@ -109,6 +112,8 @@ export const deleteEnvelopeRecipient = async ({
|
||||
});
|
||||
|
||||
const deletedRecipient = await prisma.$transaction(async (tx) => {
|
||||
await assertEnvelopeMutable(envelope, tx);
|
||||
|
||||
if (envelope.type === EnvelopeType.DOCUMENT) {
|
||||
await tx.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
|
||||
@@ -26,8 +26,10 @@ import {
|
||||
} from '../../utils/recipients';
|
||||
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
|
||||
import { getEmailContext } from '../email/get-email-context';
|
||||
import { assertEnvelopeMutable } from '../envelope/assert-envelope-mutable';
|
||||
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
|
||||
import { assertOrganisationRatesAndLimits } from '../rate-limit/assert-organisation-rates-and-limits';
|
||||
import { assertCompatibleRecipientRole } from '../signature-level/assert-compatible-recipient-role';
|
||||
|
||||
export interface SetDocumentRecipientsOptions {
|
||||
userId: number;
|
||||
@@ -84,6 +86,8 @@ export const setDocumentRecipients = async ({
|
||||
throw new Error('Document not found');
|
||||
}
|
||||
|
||||
assertEnvelopeMutable(envelope);
|
||||
|
||||
if (envelope.completedAt) {
|
||||
throw new Error('Document already complete');
|
||||
}
|
||||
@@ -109,6 +113,13 @@ export const setDocumentRecipients = async ({
|
||||
});
|
||||
}
|
||||
|
||||
for (const recipient of recipients) {
|
||||
assertCompatibleRecipientRole({
|
||||
signatureLevel: envelope.signatureLevel,
|
||||
role: recipient.role,
|
||||
});
|
||||
}
|
||||
|
||||
const normalizedRecipients = recipients.map((recipient) => ({
|
||||
...recipient,
|
||||
email: recipient.email.toLowerCase(),
|
||||
@@ -143,6 +154,8 @@ export const setDocumentRecipients = async ({
|
||||
});
|
||||
|
||||
const persistedRecipients = await prisma.$transaction(async (tx) => {
|
||||
await assertEnvelopeMutable(envelope, tx);
|
||||
|
||||
return await Promise.all(
|
||||
linkedRecipients.map(async (recipient) => {
|
||||
let authOptions = ZRecipientAuthOptionsSchema.parse(recipient._persisted?.authOptions);
|
||||
|
||||
@@ -13,6 +13,7 @@ import { createRecipientAuthOptions } from '../../utils/document-auth';
|
||||
import { type EnvelopeIdOptions, mapSecondaryIdToTemplateId } from '../../utils/envelope';
|
||||
import { getRecipientSigningOrder } from '../../utils/recipients';
|
||||
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
|
||||
import { assertCompatibleRecipientRole } from '../signature-level/assert-compatible-recipient-role';
|
||||
|
||||
export type SetTemplateRecipientsOptions = {
|
||||
userId: number;
|
||||
@@ -61,6 +62,13 @@ export const setTemplateRecipients = async ({ userId, teamId, id, recipients }:
|
||||
});
|
||||
}
|
||||
|
||||
for (const recipient of recipients) {
|
||||
assertCompatibleRecipientRole({
|
||||
signatureLevel: envelope.signatureLevel,
|
||||
role: recipient.role,
|
||||
});
|
||||
}
|
||||
|
||||
const normalizedRecipients = recipients.map((recipient) => {
|
||||
// Force replace any changes to the name or email of the direct recipient.
|
||||
if (envelope.directLink && recipient.id === envelope.directLink.directTemplateRecipientId) {
|
||||
|
||||
@@ -12,7 +12,9 @@ import { extractLegacyIds } from '../../universal/id';
|
||||
import type { EnvelopeIdOptions } from '../../utils/envelope';
|
||||
import { mapFieldToLegacyField } from '../../utils/fields';
|
||||
import { canRecipientBeModified, getRecipientSigningOrder } from '../../utils/recipients';
|
||||
import { assertEnvelopeMutable } from '../envelope/assert-envelope-mutable';
|
||||
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
|
||||
import { assertCompatibleRecipientRole } from '../signature-level/assert-compatible-recipient-role';
|
||||
|
||||
export interface UpdateEnvelopeRecipientsOptions {
|
||||
userId: number;
|
||||
@@ -67,6 +69,8 @@ export const updateEnvelopeRecipients = async ({
|
||||
});
|
||||
}
|
||||
|
||||
assertEnvelopeMutable(envelope);
|
||||
|
||||
if (envelope.completedAt) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'Envelope already complete',
|
||||
@@ -84,6 +88,17 @@ export const updateEnvelopeRecipients = async ({
|
||||
});
|
||||
}
|
||||
|
||||
for (const recipient of recipients) {
|
||||
if (recipient.role === undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
assertCompatibleRecipientRole({
|
||||
signatureLevel: envelope.signatureLevel,
|
||||
role: recipient.role,
|
||||
});
|
||||
}
|
||||
|
||||
const recipientsToUpdate = recipients.map((recipient) => {
|
||||
const originalRecipient = envelope.recipients.find((existingRecipient) => existingRecipient.id === recipient.id);
|
||||
|
||||
@@ -106,6 +121,8 @@ export const updateEnvelopeRecipients = async ({
|
||||
});
|
||||
|
||||
const updatedRecipients = await prisma.$transaction(async (tx) => {
|
||||
await assertEnvelopeMutable(envelope, tx);
|
||||
|
||||
return await Promise.all(
|
||||
recipientsToUpdate.map(async ({ originalRecipient, updateData }) => {
|
||||
let authOptions = ZRecipientAuthOptionsSchema.parse(originalRecipient.authOptions);
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { isTspEnvelope } from '../../types/signature-level';
|
||||
|
||||
type AssertCompatibleDictateNextSignerOptions = {
|
||||
signatureLevel: string;
|
||||
allowDictateNextSigner: boolean | null | undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Reject `allowDictateNextSigner = true` on AES/QES envelopes.
|
||||
*
|
||||
* The TSP sign path has no nextSigner dictation — `prepareCscRecipientSigning`
|
||||
* doesn't accept one and `executeTspSign` always advances to the strict
|
||||
* SEQUENTIAL next signer. Allowing the flag to persist on a TSP envelope
|
||||
* would advertise a UX feature the sign-time flow silently drops.
|
||||
*
|
||||
* SES envelopes pass through unchanged. A `null` / `undefined` / `false`
|
||||
* value also passes through.
|
||||
*/
|
||||
export const assertCompatibleDictateNextSigner = ({
|
||||
signatureLevel,
|
||||
allowDictateNextSigner,
|
||||
}: AssertCompatibleDictateNextSignerOptions): void => {
|
||||
if (!isTspEnvelope({ signatureLevel })) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (allowDictateNextSigner !== true) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw new AppError(AppErrorCode.INVALID_BODY, {
|
||||
message: `Envelopes signed at '${signatureLevel}' do not support next-signer dictation — the TSP sign path always advances to the strict SEQUENTIAL next recipient.`,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
import { RecipientRole } from '@prisma/client';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { isTspEnvelope } from '../../types/signature-level';
|
||||
|
||||
type AssertCompatibleRecipientRoleOptions = {
|
||||
signatureLevel: string;
|
||||
role: RecipientRole;
|
||||
};
|
||||
|
||||
/**
|
||||
* Reject `RecipientRole.ASSISTANT` on AES/QES envelopes.
|
||||
*
|
||||
* Assistant recipients pre-fill fields on behalf of downstream signers. The
|
||||
* TSP flow signs each recipient's complete PDF state with their own CSC
|
||||
* credential, so an assistant role has no sign-time identity to bind to and
|
||||
* `prepareCscRecipientSigning` has no handler for it.
|
||||
*
|
||||
* SES envelopes pass through unchanged.
|
||||
*/
|
||||
export const assertCompatibleRecipientRole = ({ signatureLevel, role }: AssertCompatibleRecipientRoleOptions): void => {
|
||||
if (!isTspEnvelope({ signatureLevel })) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (role === RecipientRole.ASSISTANT) {
|
||||
throw new AppError(AppErrorCode.INVALID_BODY, {
|
||||
message: `Envelopes signed at '${signatureLevel}' do not support the ASSISTANT role — the TSP flow signs each recipient's bytes with their own CSC credential and has no sign-time path for an assistant.`,
|
||||
});
|
||||
}
|
||||
|
||||
return;
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
import { DocumentSigningOrder } from '@prisma/client';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { isTspEnvelope } from '../../types/signature-level';
|
||||
|
||||
type AssertCompatibleSigningOrderOptions = {
|
||||
signatureLevel: string;
|
||||
signingOrder: DocumentSigningOrder | null | undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Reject `signingOrder = PARALLEL` on AES/QES envelopes.
|
||||
*
|
||||
* Parallel signing produces conflicting incremental PDF updates over the
|
||||
* same base state, breaking the per-recipient `/ByteRange` invariant that
|
||||
* lets each TSP signature verify independently. Sequential is the only safe
|
||||
* order for TSP-signed envelopes.
|
||||
*
|
||||
* SES envelopes pass through unchanged — PARALLEL remains the SES default.
|
||||
* A `null` / `undefined` signingOrder also passes through (the create-envelope
|
||||
* caller decides the default).
|
||||
*
|
||||
* Schema-layer guard. {@link sendDocument} re-coerces at distribution time
|
||||
* as a defence-in-depth backstop.
|
||||
*/
|
||||
export const assertCompatibleSigningOrder = ({
|
||||
signatureLevel,
|
||||
signingOrder,
|
||||
}: AssertCompatibleSigningOrderOptions): void => {
|
||||
if (!isTspEnvelope({ signatureLevel })) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (signingOrder !== DocumentSigningOrder.PARALLEL) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw new AppError(AppErrorCode.INVALID_BODY, {
|
||||
message: `Envelopes signed at '${signatureLevel}' require signingOrder=SEQUENTIAL — PARALLEL breaks the per-recipient /ByteRange invariant required for TSP signatures to verify independently.`,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,87 @@
|
||||
import { CSC_INSTANCE_SIGNATURE_LEVEL, IS_INSTANCE_CSC_MODE } from '../../constants/app';
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { SignatureLevel, type TSignatureLevel } from '../../types/signature-level';
|
||||
|
||||
type ResolveSignatureLevelOptions = {
|
||||
/**
|
||||
* The signature level the caller wants the envelope created at. Optional;
|
||||
* when omitted the resolver returns the instance-mode default (`SES` for
|
||||
* non-CSC instances, `AES` for CSC instances).
|
||||
*/
|
||||
requested?: TSignatureLevel;
|
||||
|
||||
/**
|
||||
* When `true`, a conflict between `requested` and the current instance mode
|
||||
* throws `CSC_INSTANCE_MODE_MISMATCH` rather than being silently coerced.
|
||||
* When `false` (default), the resolver coerces incompatible inputs to the
|
||||
* instance default without throwing.
|
||||
*
|
||||
* Omitting `requested` is accepted in both modes — the resolver returns the
|
||||
* instance default rather than throwing.
|
||||
*
|
||||
* Use `strict: true` at call sites that take the level from external input
|
||||
* (e.g. a public API) where silent coercion would mask caller mistakes.
|
||||
*/
|
||||
strict?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve the signature level for a new envelope.
|
||||
*
|
||||
* Server-only. Reads the `NEXT_PRIVATE_SIGNING_TRANSPORT` env var via
|
||||
* {@link IS_INSTANCE_CSC_MODE} so call sites do not have to thread the
|
||||
* instance mode through their own arguments. On CSC instances the coerced
|
||||
* default also reads {@link CSC_INSTANCE_SIGNATURE_LEVEL} so operators can
|
||||
* pick `AES` (default) or `QES` per their TSP capability.
|
||||
*
|
||||
* Source of truth for the `Envelope.signatureLevel` write at create-time. The
|
||||
* column has no DB default by design — every caller flows through here so the
|
||||
* instance-mode contract is enforced consistently.
|
||||
*
|
||||
* Coerce mode (default, `strict: false`):
|
||||
*
|
||||
* | Instance | requested | Result |
|
||||
* |----------|----------------|-------------------------------------|
|
||||
* | non-CSC | omitted | `SES` |
|
||||
* | non-CSC | `SES` | `SES` |
|
||||
* | non-CSC | `AES` / `QES` | `SES` (coerced) |
|
||||
* | CSC | omitted | `CSC_INSTANCE_SIGNATURE_LEVEL()` |
|
||||
* | CSC | `SES` | `CSC_INSTANCE_SIGNATURE_LEVEL()` |
|
||||
* | CSC | `AES` / `QES` | passes through |
|
||||
*
|
||||
* Strict mode (`strict: true`): same instance defaults for the omitted case,
|
||||
* but any conflict between `requested` and the instance mode throws
|
||||
* `CSC_INSTANCE_MODE_MISMATCH` instead of silently coercing.
|
||||
*
|
||||
* Note: on CSC instances an explicit `AES`/`QES` request always passes
|
||||
* through, even when it disagrees with `CSC_INSTANCE_SIGNATURE_LEVEL`. The
|
||||
* env var sets the *default* legal tier; it doesn't restrict what callers
|
||||
* can ask for. Cert-capability checks live at the TSP boundary.
|
||||
*/
|
||||
export const resolveSignatureLevel = ({
|
||||
requested,
|
||||
strict = false,
|
||||
}: ResolveSignatureLevelOptions = {}): TSignatureLevel => {
|
||||
const isCscInstance = IS_INSTANCE_CSC_MODE();
|
||||
const instanceDefault = isCscInstance ? CSC_INSTANCE_SIGNATURE_LEVEL() : SignatureLevel.SES;
|
||||
|
||||
if (requested === undefined) {
|
||||
return instanceDefault;
|
||||
}
|
||||
|
||||
const isCompatible = isCscInstance ? requested !== SignatureLevel.SES : requested === SignatureLevel.SES;
|
||||
|
||||
if (isCompatible) {
|
||||
return requested;
|
||||
}
|
||||
|
||||
if (strict) {
|
||||
throw new AppError(AppErrorCode.CSC_INSTANCE_MODE_MISMATCH, {
|
||||
message: isCscInstance
|
||||
? `signatureLevel '${requested}' is not supported on a CSC-mode instance — every recipient must sign through the configured Trust Service Provider.`
|
||||
: `signatureLevel '${requested}' is not supported on a non-CSC instance — only 'SES' is permitted unless the CSC signing transport is configured.`,
|
||||
});
|
||||
}
|
||||
|
||||
return instanceDefault;
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
import { DocumentSigningOrder } from '@prisma/client';
|
||||
|
||||
import { isTspEnvelope } from '../../types/signature-level';
|
||||
import { assertCompatibleSigningOrder } from './assert-compatible-signing-order';
|
||||
|
||||
type ResolveSigningOrderOptions = {
|
||||
signatureLevel: string;
|
||||
requested?: DocumentSigningOrder | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve the persisted `signingOrder` for a new envelope's meta.
|
||||
*
|
||||
* - Explicit `requested` value: validated via
|
||||
* {@link assertCompatibleSigningOrder} (throws on TSP + `PARALLEL`) and
|
||||
* returned as-is.
|
||||
* - Omitted `requested`: returns the level-appropriate default —
|
||||
* `SEQUENTIAL` for AES/QES (the TSP `/ByteRange` invariant requires it),
|
||||
* `PARALLEL` for SES (preserves existing SES default behaviour).
|
||||
*
|
||||
* Use at every create-time call site instead of the bare `|| PARALLEL`
|
||||
* fallback. Mirrors {@link resolveSignatureLevel} in shape — the two pair
|
||||
* up to keep create-time defaulting + TSP-mode coercion uniform.
|
||||
*/
|
||||
export const resolveSigningOrder = ({
|
||||
signatureLevel,
|
||||
requested,
|
||||
}: ResolveSigningOrderOptions): DocumentSigningOrder => {
|
||||
if (requested) {
|
||||
assertCompatibleSigningOrder({ signatureLevel, signingOrder: requested });
|
||||
|
||||
return requested;
|
||||
}
|
||||
|
||||
return isTspEnvelope({ signatureLevel }) ? DocumentSigningOrder.SEQUENTIAL : DocumentSigningOrder.PARALLEL;
|
||||
};
|
||||
@@ -25,6 +25,7 @@ import type { TRecipientActionAuthTypes } from '../../types/document-auth';
|
||||
import { DocumentAccessAuth, ZRecipientAuthOptionsSchema } from '../../types/document-auth';
|
||||
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
|
||||
import { ZFieldMetaSchema } from '../../types/field-meta';
|
||||
import { ZSignatureLevelSchema } from '../../types/signature-level';
|
||||
import { mapEnvelopeToWebhookDocumentPayload, ZWebhookDocumentSchema } from '../../types/webhook-payload';
|
||||
import type { ApiRequestMetadata } from '../../universal/extract-request-metadata';
|
||||
import { getFileServerSide } from '../../universal/upload/get-file.server';
|
||||
@@ -44,6 +45,7 @@ import { sendDocument } from '../document/send-document';
|
||||
import { validateFieldAuth } from '../document/validate-field-auth';
|
||||
import { incrementDocumentId } from '../envelope/increment-id';
|
||||
import { assertOrganisationRatesAndLimits } from '../rate-limit/assert-organisation-rates-and-limits';
|
||||
import { resolveSignatureLevel } from '../signature-level/resolve-signature-level';
|
||||
import { getTeamSettings } from '../team/get-team-settings';
|
||||
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||
|
||||
@@ -198,6 +200,17 @@ export const createDocumentFromDirectTemplate = async ({
|
||||
(recipient) => recipient.id !== directTemplateRecipient.id,
|
||||
);
|
||||
|
||||
// Carry the template's level forward, coercing if the instance mode has
|
||||
// changed since the template was created. ZSignatureLevelSchema parses the
|
||||
// free-form TEXT column defensively. Resolved before meta extraction so
|
||||
// signingOrder picks up the TSP-appropriate default + assertion.
|
||||
const signatureLevel = resolveSignatureLevel({
|
||||
requested: ZSignatureLevelSchema.parse(directTemplateEnvelope.signatureLevel),
|
||||
strict: false,
|
||||
});
|
||||
|
||||
const derivedDocumentMeta = extractDerivedDocumentMeta(settings, directTemplateEnvelope.documentMeta, signatureLevel);
|
||||
|
||||
// The resulting document contains every non-direct template recipient plus the
|
||||
// direct recipient that is signing now. A recipientCount of 0 means unlimited.
|
||||
// This mirrors the check in `sendDocument`, but must be done here because this
|
||||
@@ -212,8 +225,6 @@ export const createDocumentFromDirectTemplate = async ({
|
||||
});
|
||||
}
|
||||
|
||||
const derivedDocumentMeta = extractDerivedDocumentMeta(settings, directTemplateEnvelope.documentMeta);
|
||||
|
||||
// Associate, validate and map to a query every direct template recipient field with the provided fields.
|
||||
// Only process fields that are either required or have been signed by the user
|
||||
const fieldsToProcess = directTemplateRecipient.fields.filter((templateField) => {
|
||||
@@ -353,6 +364,7 @@ export const createDocumentFromDirectTemplate = async ({
|
||||
secondaryId: incrementedDocumentId.formattedDocumentId,
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
internalVersion: directTemplateEnvelope.internalVersion,
|
||||
signatureLevel,
|
||||
qrToken: prefixedId('qr'),
|
||||
source: DocumentSource.TEMPLATE_DIRECT_LINK,
|
||||
templateId: directTemplateEnvelopeLegacyId,
|
||||
|
||||
@@ -33,6 +33,7 @@ import type {
|
||||
TTextFieldMeta,
|
||||
} from '../../types/field-meta';
|
||||
import { ZCheckboxFieldMeta, ZDropdownFieldMeta, ZFieldMetaSchema, ZRadioFieldMeta } from '../../types/field-meta';
|
||||
import { ZSignatureLevelSchema } from '../../types/signature-level';
|
||||
import { mapEnvelopeToWebhookDocumentPayload, ZWebhookDocumentSchema } from '../../types/webhook-payload';
|
||||
import type { ApiRequestMetadata } from '../../universal/extract-request-metadata';
|
||||
import { getFileServerSide } from '../../universal/upload/get-file.server';
|
||||
@@ -52,6 +53,7 @@ import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
|
||||
import { incrementDocumentId } from '../envelope/increment-id';
|
||||
import { insertFormValuesInPdf } from '../pdf/insert-form-values-in-pdf';
|
||||
import { assertOrganisationRatesAndLimits } from '../rate-limit/assert-organisation-rates-and-limits';
|
||||
import { resolveSignatureLevel } from '../signature-level/resolve-signature-level';
|
||||
import { getTeamSettings } from '../team/get-team-settings';
|
||||
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||
import { getOrganisationTemplateWhereInput } from './get-organisation-template-by-id';
|
||||
@@ -514,23 +516,36 @@ export const createDocumentFromTemplate = async ({
|
||||
|
||||
const incrementedDocumentId = await incrementDocumentId();
|
||||
|
||||
// Carry the template's level forward, coercing if the instance mode has
|
||||
// changed since the template was created. ZSignatureLevelSchema parses the
|
||||
// free-form TEXT column defensively. Resolved before meta extraction so
|
||||
// signingOrder picks up the TSP-appropriate default + assertion.
|
||||
const signatureLevel = resolveSignatureLevel({
|
||||
requested: ZSignatureLevelSchema.parse(template.signatureLevel),
|
||||
strict: false,
|
||||
});
|
||||
|
||||
const documentMeta = await prisma.documentMeta.create({
|
||||
data: extractDerivedDocumentMeta(settings, {
|
||||
subject: override?.subject || template.documentMeta?.subject,
|
||||
message: override?.message || template.documentMeta?.message,
|
||||
timezone: override?.timezone || template.documentMeta?.timezone,
|
||||
dateFormat: override?.dateFormat || template.documentMeta?.dateFormat,
|
||||
redirectUrl: override?.redirectUrl || template.documentMeta?.redirectUrl,
|
||||
distributionMethod: override?.distributionMethod || template.documentMeta?.distributionMethod,
|
||||
emailSettings: override?.emailSettings || template.documentMeta?.emailSettings,
|
||||
signingOrder: override?.signingOrder || template.documentMeta?.signingOrder,
|
||||
language: override?.language || template.documentMeta?.language || settings.documentLanguage,
|
||||
typedSignatureEnabled: override?.typedSignatureEnabled ?? template.documentMeta?.typedSignatureEnabled,
|
||||
uploadSignatureEnabled: override?.uploadSignatureEnabled ?? template.documentMeta?.uploadSignatureEnabled,
|
||||
drawSignatureEnabled: override?.drawSignatureEnabled ?? template.documentMeta?.drawSignatureEnabled,
|
||||
allowDictateNextSigner: override?.allowDictateNextSigner ?? template.documentMeta?.allowDictateNextSigner,
|
||||
envelopeExpirationPeriod: override?.envelopeExpirationPeriod ?? template.documentMeta?.envelopeExpirationPeriod,
|
||||
}),
|
||||
data: extractDerivedDocumentMeta(
|
||||
settings,
|
||||
{
|
||||
subject: override?.subject || template.documentMeta?.subject,
|
||||
message: override?.message || template.documentMeta?.message,
|
||||
timezone: override?.timezone || template.documentMeta?.timezone,
|
||||
dateFormat: override?.dateFormat || template.documentMeta?.dateFormat,
|
||||
redirectUrl: override?.redirectUrl || template.documentMeta?.redirectUrl,
|
||||
distributionMethod: override?.distributionMethod || template.documentMeta?.distributionMethod,
|
||||
emailSettings: override?.emailSettings || template.documentMeta?.emailSettings,
|
||||
signingOrder: override?.signingOrder || template.documentMeta?.signingOrder,
|
||||
language: override?.language || template.documentMeta?.language || settings.documentLanguage,
|
||||
typedSignatureEnabled: override?.typedSignatureEnabled ?? template.documentMeta?.typedSignatureEnabled,
|
||||
uploadSignatureEnabled: override?.uploadSignatureEnabled ?? template.documentMeta?.uploadSignatureEnabled,
|
||||
drawSignatureEnabled: override?.drawSignatureEnabled ?? template.documentMeta?.drawSignatureEnabled,
|
||||
allowDictateNextSigner: override?.allowDictateNextSigner ?? template.documentMeta?.allowDictateNextSigner,
|
||||
envelopeExpirationPeriod: override?.envelopeExpirationPeriod ?? template.documentMeta?.envelopeExpirationPeriod,
|
||||
},
|
||||
signatureLevel,
|
||||
),
|
||||
});
|
||||
|
||||
const { envelope, createdEnvelope } = await prisma.$transaction(async (tx) => {
|
||||
@@ -540,6 +555,7 @@ export const createDocumentFromTemplate = async ({
|
||||
secondaryId: incrementedDocumentId.formattedDocumentId,
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
internalVersion: template.internalVersion,
|
||||
signatureLevel,
|
||||
qrToken: prefixedId('qr'),
|
||||
source: DocumentSource.TEMPLATE,
|
||||
externalId: externalId || template.externalId,
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* One entry in a CSC sign-time session, pinning the bytes (`documentDataId`)
|
||||
* whose digest (`hashB64`) was captured at prep time for a given envelope
|
||||
* item. The `ordinal` is the position of this entry in the session items array
|
||||
* — it lines up with the position-ordered `signatures/signHash` response per
|
||||
* CSC v1.0.4.0 §11.9.
|
||||
*/
|
||||
export const ZCscSessionItemSchema = z.object({
|
||||
envelopeItemId: z.string(),
|
||||
documentDataId: z.string(),
|
||||
hashB64: z.string(),
|
||||
ordinal: z.number().int().nonnegative(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Contract between CSC prep and sign on `CscSession.itemsJson`.
|
||||
*
|
||||
* Built at prep time alongside the captured signedAttrs digests. At sign time
|
||||
* the mutation re-derives each item's digest against the pinned
|
||||
* `documentDataId` bytes and dispatches one batched `signatures/signHash` per
|
||||
* recipient.
|
||||
*/
|
||||
export const ZCscSessionItemsSchema = z.array(ZCscSessionItemSchema);
|
||||
|
||||
export type TCscSessionItem = z.infer<typeof ZCscSessionItemSchema>;
|
||||
export type TCscSessionItems = z.infer<typeof ZCscSessionItemsSchema>;
|
||||
@@ -54,6 +54,13 @@ export const ZDocumentAuditLogTypeSchema = z.enum([
|
||||
'DOCUMENT_ACCESS_AUTH_2FA_REQUESTED', // When ACCESS AUTH 2FA is requested.
|
||||
'DOCUMENT_ACCESS_AUTH_2FA_VALIDATED', // When ACCESS AUTH 2FA is successfully validated.
|
||||
'DOCUMENT_ACCESS_AUTH_2FA_FAILED', // When ACCESS AUTH 2FA validation fails.
|
||||
|
||||
// CSC / TSP signing events.
|
||||
'DOCUMENT_RECIPIENT_CSC_AUTHENTICATED', // Service-scope OAuth complete; CSC credential persisted.
|
||||
'DOCUMENT_RECIPIENT_CSC_AUTHENTICATION_FAILED', // Service-scope OAuth completed but TSP returned a blocking error (empty credential list / invalid cert / refused algorithm).
|
||||
'DOCUMENT_RECIPIENT_CSC_SIGN_REQUESTED', // Recipient clicked Sign; CSC session created with captured per-item hashes.
|
||||
'DOCUMENT_RECIPIENT_CSC_AUTHORIZED', // Credential-scope OAuth complete; SAD attached to the CSC session.
|
||||
'DOCUMENT_RECIPIENT_CSC_SIGNED', // TSP returned signatures and they were embedded into the recipient's PDF bytes.
|
||||
]);
|
||||
|
||||
export const ZDocumentAuditLogEmailTypeSchema = z.enum([
|
||||
@@ -722,6 +729,71 @@ export const ZDocumentAuditLogEventRecipientExpiredSchema = z.object({
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* Event: Recipient completed CSC service-scope OAuth — credential discovered, certificate persisted.
|
||||
*/
|
||||
export const ZDocumentAuditLogEventDocumentRecipientCscAuthenticatedSchema = z.object({
|
||||
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_CSC_AUTHENTICATED),
|
||||
data: ZBaseRecipientDataSchema.extend({
|
||||
providerId: z.string(),
|
||||
credentialId: z.string(),
|
||||
signatureAlgorithm: z.string(),
|
||||
digestAlgorithm: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* Event: Recipient's CSC service-scope OAuth completed but the TSP returned a blocking error.
|
||||
*/
|
||||
export const ZDocumentAuditLogEventDocumentRecipientCscAuthenticationFailedSchema = z.object({
|
||||
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_CSC_AUTHENTICATION_FAILED),
|
||||
data: ZBaseRecipientDataSchema.extend({
|
||||
providerId: z.string(),
|
||||
reason: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* Event: Recipient initiated TSP signing — CSC session created with per-item hashes.
|
||||
*/
|
||||
export const ZDocumentAuditLogEventDocumentRecipientCscSignRequestedSchema = z.object({
|
||||
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_CSC_SIGN_REQUESTED),
|
||||
data: ZBaseRecipientDataSchema.extend({
|
||||
providerId: z.string(),
|
||||
credentialId: z.string(),
|
||||
sessionId: z.string(),
|
||||
numSignatures: z.number(),
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* Event: Recipient completed CSC credential-scope OAuth — SAD attached to session.
|
||||
*/
|
||||
export const ZDocumentAuditLogEventDocumentRecipientCscAuthorizedSchema = z.object({
|
||||
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_CSC_AUTHORIZED),
|
||||
data: ZBaseRecipientDataSchema.extend({
|
||||
providerId: z.string(),
|
||||
credentialId: z.string(),
|
||||
sessionId: z.string(),
|
||||
sadExpiresAt: z.coerce.date(),
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* Event: TSP returned signatures and they were embedded into the recipient's PDF bytes.
|
||||
*/
|
||||
export const ZDocumentAuditLogEventDocumentRecipientCscSignedSchema = z.object({
|
||||
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_CSC_SIGNED),
|
||||
data: ZBaseRecipientDataSchema.extend({
|
||||
providerId: z.string(),
|
||||
credentialId: z.string(),
|
||||
sessionId: z.string(),
|
||||
numItemsSigned: z.number(),
|
||||
signatureAlgorithm: z.string(),
|
||||
digestAlgorithm: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const ZDocumentAuditLogBaseSchema = z.object({
|
||||
id: z.string(),
|
||||
createdAt: z.date(),
|
||||
@@ -770,6 +842,11 @@ export const ZDocumentAuditLogSchema = ZDocumentAuditLogBaseSchema.and(
|
||||
ZDocumentAuditLogEventRecipientUpdatedSchema,
|
||||
ZDocumentAuditLogEventRecipientRemovedSchema,
|
||||
ZDocumentAuditLogEventRecipientExpiredSchema,
|
||||
ZDocumentAuditLogEventDocumentRecipientCscAuthenticatedSchema,
|
||||
ZDocumentAuditLogEventDocumentRecipientCscAuthenticationFailedSchema,
|
||||
ZDocumentAuditLogEventDocumentRecipientCscSignRequestedSchema,
|
||||
ZDocumentAuditLogEventDocumentRecipientCscAuthorizedSchema,
|
||||
ZDocumentAuditLogEventDocumentRecipientCscSignedSchema,
|
||||
]),
|
||||
);
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@ export const ZLicenseClaimSchema = z.object({
|
||||
hipaa: z.boolean().optional(),
|
||||
authenticationPortal: z.boolean().optional(),
|
||||
billing: z.boolean().optional(),
|
||||
instanceCscSigning: z.boolean().optional(),
|
||||
cscQesSigning: z.boolean().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -43,6 +45,13 @@ export type TLicenseClaim = z.infer<typeof ZLicenseClaimSchema>;
|
||||
export type TLicenseRequest = z.infer<typeof ZLicenseRequestSchema>;
|
||||
export type TLicenseResponse = z.infer<typeof ZLicenseResponseSchema>;
|
||||
|
||||
/**
|
||||
* String-literal union of every flag the licence server can grant. Adding a
|
||||
* field to `ZLicenseClaimSchema` automatically extends this type so that
|
||||
* `assertLicensedFor(flag)` and similar helpers stay in sync.
|
||||
*/
|
||||
export type LicenseFlag = keyof TLicenseClaim;
|
||||
|
||||
/**
|
||||
* Schema for the cached license data stored in the file.
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* The cryptographic signature tier an envelope is signed at.
|
||||
*
|
||||
* - `SES` — Simple Electronic Signature; the default Documenso flow signed
|
||||
* with the instance-held certificate.
|
||||
* - `AES` — Advanced Electronic Signature; recipient-bound signing through a
|
||||
* Cloud Signature Consortium (CSC) Trust Service Provider.
|
||||
* - `QES` — Qualified Electronic Signature; the eIDAS-qualified variant of
|
||||
* AES, also recipient-bound through a CSC TSP.
|
||||
*
|
||||
* Stored as free-form TEXT on `Envelope.signatureLevel` so the legal-tier
|
||||
* taxonomy can expand without a DB enum migration. Validation lives here.
|
||||
*/
|
||||
export const ZSignatureLevelSchema = z.enum(['SES', 'AES', 'QES']);
|
||||
|
||||
export const SignatureLevel = ZSignatureLevelSchema.enum;
|
||||
|
||||
export type TSignatureLevel = z.infer<typeof ZSignatureLevelSchema>;
|
||||
|
||||
/**
|
||||
* Whether an envelope's signature level routes through a Cloud Signature
|
||||
* Consortium Trust Service Provider (`AES` or `QES`). The single branch point
|
||||
* for TSP-vs-SES runtime divergence — seal handler, download endpoint,
|
||||
* completion email, and send-time PDF prep all key off this.
|
||||
*
|
||||
* Accepts a raw `string` because the Prisma column is TEXT; unknown values
|
||||
* are conservatively treated as non-TSP so a malformed row can't accidentally
|
||||
* trigger TSP-only code paths.
|
||||
*/
|
||||
export const isTspEnvelope = (envelope: { signatureLevel: string }): boolean =>
|
||||
envelope.signatureLevel === SignatureLevel.AES || envelope.signatureLevel === SignatureLevel.QES;
|
||||
@@ -51,6 +51,8 @@ export const ZClaimFlagsSchema = z.object({
|
||||
|
||||
signingReminders: z.boolean().optional(),
|
||||
|
||||
cscQesSigning: z.boolean().optional(),
|
||||
|
||||
/**
|
||||
* Controls whether an organisation is prevented from sending emails.
|
||||
*
|
||||
@@ -128,6 +130,11 @@ export const SUBSCRIPTION_CLAIM_FEATURE_FLAGS: Record<
|
||||
key: 'signingReminders',
|
||||
label: 'Signing reminders',
|
||||
},
|
||||
cscQesSigning: {
|
||||
key: 'cscQesSigning',
|
||||
label: 'QES signing',
|
||||
isEnterprise: true,
|
||||
},
|
||||
disableEmails: {
|
||||
key: 'disableEmails',
|
||||
label: 'Disable emails',
|
||||
|
||||
@@ -597,6 +597,31 @@ export const formatDocumentAuditLogAction = (i18n: I18n, auditLog: TDocumentAudi
|
||||
user: message,
|
||||
};
|
||||
})
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_CSC_AUTHENTICATED }, () => ({
|
||||
anonymous: msg`Recipient authenticated with the signing provider`,
|
||||
you: msg`You authenticated with the signing provider`,
|
||||
user: msg`${user} authenticated with the signing provider`,
|
||||
}))
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_CSC_AUTHENTICATION_FAILED }, () => ({
|
||||
anonymous: msg`Recipient's signing provider authentication failed`,
|
||||
you: msg`Your signing provider authentication failed`,
|
||||
user: msg`${user}'s signing provider authentication failed`,
|
||||
}))
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_CSC_SIGN_REQUESTED }, () => ({
|
||||
anonymous: msg`Recipient requested a remote signature`,
|
||||
you: msg`You requested a remote signature`,
|
||||
user: msg`${user} requested a remote signature`,
|
||||
}))
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_CSC_AUTHORIZED }, () => ({
|
||||
anonymous: msg`Recipient authorised the remote signature`,
|
||||
you: msg`You authorised the remote signature`,
|
||||
user: msg`${user} authorised the remote signature`,
|
||||
}))
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_CSC_SIGNED }, () => ({
|
||||
anonymous: msg`Recipient's remote signature was applied`,
|
||||
you: msg`Your remote signature was applied`,
|
||||
user: msg`${user}'s remote signature was applied`,
|
||||
}))
|
||||
.exhaustive();
|
||||
|
||||
let selectedDescription = description.anonymous;
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import type { DocumentMeta, Envelope, OrganisationGlobalSettings, Recipient, Team, User } from '@prisma/client';
|
||||
import { DocumentDistributionMethod, DocumentSigningOrder, DocumentStatus } from '@prisma/client';
|
||||
import { DocumentDistributionMethod, DocumentStatus } from '@prisma/client';
|
||||
|
||||
import { DEFAULT_DOCUMENT_TIME_ZONE } from '../constants/time-zones';
|
||||
import { resolveSigningOrder } from '../server-only/signature-level/resolve-signing-order';
|
||||
import type { TDocumentLite, TDocumentMany } from '../types/document';
|
||||
import { DEFAULT_DOCUMENT_EMAIL_SETTINGS } from '../types/document-email';
|
||||
import { SignatureLevel } from '../types/signature-level';
|
||||
import { mapSecondaryIdToDocumentId } from './envelope';
|
||||
import { mapRecipientToLegacyRecipient } from './recipients';
|
||||
|
||||
@@ -23,11 +25,17 @@ export const isDocumentCompleted = (document: Pick<Envelope, 'status'> | Documen
|
||||
*
|
||||
* @param settings - The merged organisation/team settings.
|
||||
* @param overrideMeta - The meta to override the settings with.
|
||||
* @param signatureLevel - The envelope's signature level. Optional; defaults
|
||||
* to `SES` for backward compatibility, which preserves the legacy `PARALLEL`
|
||||
* signing-order default. New callers should pass the resolved level so the
|
||||
* TSP envelopes get the `SEQUENTIAL` default + assertion against explicit
|
||||
* `PARALLEL`.
|
||||
* @returns The derived document meta.
|
||||
*/
|
||||
export const extractDerivedDocumentMeta = (
|
||||
settings: Omit<OrganisationGlobalSettings, 'id'>,
|
||||
overrideMeta: Partial<DocumentMeta> | undefined | null,
|
||||
signatureLevel: string = SignatureLevel.SES,
|
||||
) => {
|
||||
const meta = overrideMeta ?? {};
|
||||
|
||||
@@ -41,7 +49,7 @@ export const extractDerivedDocumentMeta = (
|
||||
subject: meta.subject || null,
|
||||
redirectUrl: meta.redirectUrl || null,
|
||||
|
||||
signingOrder: meta.signingOrder || DocumentSigningOrder.PARALLEL,
|
||||
signingOrder: resolveSigningOrder({ signatureLevel, requested: meta.signingOrder }),
|
||||
allowDictateNextSigner: meta.allowDictateNextSigner ?? false,
|
||||
distributionMethod: meta.distributionMethod || DocumentDistributionMethod.EMAIL, // Todo: Make this a setting.
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
/// <reference types="@documenso/tsconfig/process-env.d.ts" />
|
||||
|
||||
import { AppError, AppErrorCode } from '../errors/app-error';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__ENV__?: Record<string, string | undefined>;
|
||||
@@ -20,6 +22,30 @@ export const env = <K extends EnvKey>(variable: K): EnvValue<K> => {
|
||||
return (typeof process !== 'undefined' ? process?.env?.[variable] : undefined) as EnvValue<K>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Read an env var and assert it is set and non-empty. Throws `error` when
|
||||
* provided, otherwise an `AppError(MISSING_ENV_VAR)` naming the missing
|
||||
* variable.
|
||||
*
|
||||
* Empty-string is treated as unset — a shell-supplied `FOO=` is functionally
|
||||
* equivalent to omission.
|
||||
*/
|
||||
export const requireEnv = <K extends EnvKey>(variable: K, error?: Error): NonNullable<EnvValue<K>> => {
|
||||
const value = env(variable);
|
||||
|
||||
if (!value) {
|
||||
throw (
|
||||
error ??
|
||||
new AppError(AppErrorCode.MISSING_ENV_VAR, {
|
||||
message: `Required environment variable "${String(variable)}" is unset.`,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
return value as NonNullable<EnvValue<K>>;
|
||||
};
|
||||
|
||||
export const createPublicEnv = () => ({
|
||||
...Object.fromEntries(Object.entries(process.env).filter(([key]) => key.startsWith('NEXT_PUBLIC_'))),
|
||||
// Derived from the private URL so the public flag cannot drift from the
|
||||
@@ -27,4 +53,7 @@ export const createPublicEnv = () => ({
|
||||
// env var with the same name.
|
||||
// The `? 'true' : 'false'` might seem dumb but it's because we're expecting env var strings.
|
||||
NEXT_PUBLIC_DOCUMENT_CONVERSION_ENABLED: process.env.NEXT_PRIVATE_DOCUMENT_CONVERSION_URL ? 'true' : 'false',
|
||||
// Derived from the private transport so the client can detect CSC mode for
|
||||
// authoring UI gating without exposing the raw transport value.
|
||||
NEXT_PUBLIC_SIGNING_TRANSPORT_IS_CSC: process.env.NEXT_PRIVATE_SIGNING_TRANSPORT === 'csc' ? 'true' : 'false',
|
||||
});
|
||||
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
-- AlterTable
|
||||
-- Add the column with a temporary DEFAULT so Postgres backfills existing rows
|
||||
-- to 'SES' (the only level the instance supported before this migration). The
|
||||
-- DEFAULT is then dropped so future INSERTs must specify signatureLevel
|
||||
-- explicitly via resolveSignatureLevel — the column carries no DB-level default
|
||||
-- by design (see packages/lib/server-only/signature-level/resolve-signature-level.ts).
|
||||
ALTER TABLE "Envelope" ADD COLUMN "signatureLevel" TEXT NOT NULL DEFAULT 'SES';
|
||||
ALTER TABLE "Envelope" ALTER COLUMN "signatureLevel" DROP DEFAULT;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "CscCredential" (
|
||||
"id" TEXT NOT NULL,
|
||||
"providerId" TEXT NOT NULL,
|
||||
"credentialId" TEXT NOT NULL,
|
||||
"certCache" BYTEA,
|
||||
"signatureAlgorithm" TEXT NOT NULL,
|
||||
"keyType" TEXT NOT NULL,
|
||||
"digestAlgorithm" TEXT NOT NULL,
|
||||
"keyLenBits" INTEGER,
|
||||
"signAlgoParams" TEXT,
|
||||
"serviceTokenCiphertext" BYTEA,
|
||||
"serviceTokenExpiresAt" TIMESTAMP(3),
|
||||
"recipientId" INTEGER NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "CscCredential_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "CscSession" (
|
||||
"id" TEXT NOT NULL,
|
||||
"envelopeId" TEXT NOT NULL,
|
||||
"signingTime" TIMESTAMP(3) NOT NULL,
|
||||
"itemsJson" JSONB NOT NULL,
|
||||
"encryptedSad" BYTEA,
|
||||
"sadExpiresAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"recipientId" INTEGER NOT NULL,
|
||||
|
||||
CONSTRAINT "CscSession_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "CscCredential_recipientId_key" ON "CscCredential"("recipientId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "CscSession_recipientId_key" ON "CscSession"("recipientId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "CscCredential" ADD CONSTRAINT "CscCredential_recipientId_fkey" FOREIGN KEY ("recipientId") REFERENCES "Recipient"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "CscSession" ADD CONSTRAINT "CscSession_recipientId_fkey" FOREIGN KEY ("recipientId") REFERENCES "Recipient"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -429,7 +429,7 @@ enum EnvelopeType {
|
||||
TEMPLATE
|
||||
}
|
||||
|
||||
/// @zod.import(["import { ZDocumentAuthOptionsSchema } from '@documenso/lib/types/document-auth';", "import { ZDocumentFormValuesSchema } from '@documenso/lib/types/document-form-values';"])
|
||||
/// @zod.import(["import { ZDocumentAuthOptionsSchema } from '@documenso/lib/types/document-auth';", "import { ZDocumentFormValuesSchema } from '@documenso/lib/types/document-form-values';", "import { ZSignatureLevelSchema } from '@documenso/lib/types/signature-level';"])
|
||||
model Envelope {
|
||||
id String @id
|
||||
secondaryId String @unique
|
||||
@@ -447,6 +447,8 @@ model Envelope {
|
||||
source DocumentSource
|
||||
qrToken String? /// @zod.string.describe("The token for viewing the document using the QR code on the certificate.")
|
||||
|
||||
signatureLevel String /// [SignatureLevel] @zod.custom.use(ZSignatureLevelSchema)
|
||||
|
||||
internalVersion Int
|
||||
useLegacyFieldInsertion Boolean @default(false)
|
||||
|
||||
@@ -650,6 +652,9 @@ model Recipient {
|
||||
fields Field[]
|
||||
signatures Signature[]
|
||||
|
||||
cscCredential CscCredential?
|
||||
cscSession CscSession?
|
||||
|
||||
@@index([token])
|
||||
@@index([email])
|
||||
@@index([envelopeId])
|
||||
@@ -717,6 +722,72 @@ model Signature {
|
||||
@@index([recipientId])
|
||||
}
|
||||
|
||||
/// Per-recipient cached credential metadata for the configured Cloud Signature
|
||||
/// Consortium (CSC) provider. Holds the TSP-validated certificate chain and the
|
||||
/// symmetric-encrypted service-scope access token. One row per recipient on a
|
||||
/// CSC envelope; absent for SES recipients.
|
||||
model CscCredential {
|
||||
id String @id @default(cuid())
|
||||
providerId String
|
||||
credentialId String
|
||||
|
||||
/// TSP-validated certificate chain (length-prefixed). Nullable until the
|
||||
/// service-scope OAuth callback successfully retrieves and validates it.
|
||||
certCache Bytes?
|
||||
|
||||
/// Algorithm metadata derived from the CSC `credentials/info` response.
|
||||
/// Held as flat scalars so each is independently queryable.
|
||||
signatureAlgorithm String
|
||||
keyType String
|
||||
digestAlgorithm String
|
||||
keyLenBits Int?
|
||||
signAlgoParams String?
|
||||
|
||||
/// Service-scope access token, symmetric-encrypted at the application layer
|
||||
/// before persistence. Nullable until the service-scope OAuth callback runs.
|
||||
serviceTokenCiphertext Bytes?
|
||||
serviceTokenExpiresAt DateTime?
|
||||
|
||||
recipientId Int @unique
|
||||
recipient Recipient @relation(fields: [recipientId], references: [id], onDelete: Cascade)
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
/// Per-recipient transient sign-time state held across the credential-scope
|
||||
/// OAuth round-trip. The `recipientId @unique` constraint caps each recipient
|
||||
/// to at most one in-flight session — re-clicking Sign UPSERTs the row.
|
||||
/// Cleaned up transitively when the Recipient is deleted.
|
||||
/// @zod.import(["import { ZCscSessionItemsSchema } from '@documenso/lib/types/csc-session';"])
|
||||
model CscSession {
|
||||
id String @id @default(cuid())
|
||||
|
||||
/// Denormalised envelope id for ergonomic lookups. No FK relation — cleanup
|
||||
/// flows through the Recipient cascade.
|
||||
envelopeId String
|
||||
|
||||
/// Pinned at prep time; stable across overlay re-renders so the captured
|
||||
/// signedAttrs digest matches the bytes the TSP signs.
|
||||
signingTime DateTime
|
||||
|
||||
/// Contract between prep and sign: an array of
|
||||
/// `{ envelopeItemId, documentDataId, hashB64, ordinal }` per envelope item.
|
||||
/// `documentDataId` pins the immutable bytes that produced the captured
|
||||
/// digest so sign-time can re-derive the same hash against the same content.
|
||||
itemsJson Json /// [CscSessionItems] @zod.custom.use(ZCscSessionItemsSchema)
|
||||
|
||||
/// Populated by the credential-scope OAuth callback. Both nullable at row
|
||||
/// creation; set together when the SAD is exchanged.
|
||||
encryptedSad Bytes?
|
||||
sadExpiresAt DateTime?
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
recipientId Int @unique
|
||||
recipient Recipient @relation(fields: [recipientId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model DocumentShareLink {
|
||||
id Int @id @default(autoincrement())
|
||||
email String
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
FIELD_SIGNATURE_META_DEFAULT_VALUES,
|
||||
FIELD_TEXT_META_DEFAULT_VALUES,
|
||||
} from '@documenso/lib/types/field-meta';
|
||||
import { SignatureLevel } from '@documenso/lib/types/signature-level';
|
||||
import { prefixedId } from '@documenso/lib/universal/id';
|
||||
import type { Team, User } from '@prisma/client';
|
||||
import { nanoid } from 'nanoid';
|
||||
@@ -93,6 +94,7 @@ export const seedBlankDocument = async (owner: User, teamId: number, options: Cr
|
||||
id: prefixedId('envelope'),
|
||||
secondaryId: documentId.formattedDocumentId,
|
||||
internalVersion,
|
||||
signatureLevel: SignatureLevel.SES,
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
documentMetaId: documentMeta.id,
|
||||
source: DocumentSource.DOCUMENT,
|
||||
@@ -313,6 +315,7 @@ export const seedDraftDocument = async (
|
||||
id: prefixedId('envelope'),
|
||||
secondaryId: documentId.formattedDocumentId,
|
||||
internalVersion,
|
||||
signatureLevel: SignatureLevel.SES,
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
documentMetaId: documentMeta.id,
|
||||
source: DocumentSource.DOCUMENT,
|
||||
@@ -407,6 +410,7 @@ export const seedPendingDocument = async (
|
||||
id: prefixedId('envelope'),
|
||||
secondaryId: documentId.formattedDocumentId,
|
||||
internalVersion,
|
||||
signatureLevel: SignatureLevel.SES,
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
documentMetaId: documentMeta.id,
|
||||
source: DocumentSource.DOCUMENT,
|
||||
@@ -660,6 +664,7 @@ export const seedCompletedDocument = async (
|
||||
id: prefixedId('envelope'),
|
||||
secondaryId: documentId.formattedDocumentId,
|
||||
internalVersion,
|
||||
signatureLevel: SignatureLevel.SES,
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
documentMetaId: documentMeta.id,
|
||||
source: DocumentSource.DOCUMENT,
|
||||
|
||||
@@ -6,6 +6,7 @@ import { FIELD_META_TEST_FIELDS } from '@documenso/app-tests/constants/field-met
|
||||
import { OVERFLOW_TEST_FIELDS } from '@documenso/app-tests/constants/field-overflow-pdf';
|
||||
import { isBase64Image } from '@documenso/lib/constants/signatures';
|
||||
import { incrementDocumentId, incrementTemplateId } from '@documenso/lib/server-only/envelope/increment-id';
|
||||
import { SignatureLevel } from '@documenso/lib/types/signature-level';
|
||||
import { nanoid, prefixedId } from '@documenso/lib/universal/id';
|
||||
import { DIRECT_TEMPLATE_RECIPIENT_EMAIL, DIRECT_TEMPLATE_RECIPIENT_NAME } from '../../lib/constants/direct-templates';
|
||||
import { prisma } from '..';
|
||||
@@ -76,6 +77,7 @@ export const seedDatabase = async () => {
|
||||
id: prefixedId('envelope'),
|
||||
secondaryId: documentId.formattedDocumentId,
|
||||
internalVersion: 1,
|
||||
signatureLevel: SignatureLevel.SES,
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
documentMetaId: documentMeta.id,
|
||||
source: DocumentSource.DOCUMENT,
|
||||
@@ -115,6 +117,7 @@ export const seedDatabase = async () => {
|
||||
id: prefixedId('envelope'),
|
||||
secondaryId: documentId.formattedDocumentId,
|
||||
internalVersion: 1,
|
||||
signatureLevel: SignatureLevel.SES,
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
source: DocumentSource.DOCUMENT,
|
||||
title: `Document ${i}`,
|
||||
@@ -312,6 +315,7 @@ export const seedAlignmentTestDocument = async ({
|
||||
id: prefixedId('envelope'),
|
||||
secondaryId,
|
||||
internalVersion: 2,
|
||||
signatureLevel: SignatureLevel.SES,
|
||||
type,
|
||||
documentMetaId: documentMeta.id,
|
||||
source: DocumentSource.DOCUMENT,
|
||||
@@ -474,6 +478,7 @@ export const seedOverflowTestDocument = async ({
|
||||
id: prefixedId('envelope'),
|
||||
secondaryId,
|
||||
internalVersion: 2,
|
||||
signatureLevel: SignatureLevel.SES,
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
documentMetaId: documentMeta.id,
|
||||
source: DocumentSource.DOCUMENT,
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
DIRECT_TEMPLATE_RECIPIENT_NAME,
|
||||
} from '@documenso/lib/constants/direct-templates';
|
||||
import { incrementTemplateId } from '@documenso/lib/server-only/envelope/increment-id';
|
||||
import { SignatureLevel } from '@documenso/lib/types/signature-level';
|
||||
import { prefixedId } from '@documenso/lib/universal/id';
|
||||
|
||||
import { prisma } from '..';
|
||||
@@ -57,6 +58,7 @@ export const seedBlankTemplate = async (owner: User, teamId: number, options: Cr
|
||||
id: prefixedId('envelope'),
|
||||
secondaryId: templateId.formattedTemplateId,
|
||||
internalVersion: 1,
|
||||
signatureLevel: SignatureLevel.SES,
|
||||
type: EnvelopeType.TEMPLATE,
|
||||
title: `[TEST] Template ${key}`,
|
||||
teamId,
|
||||
@@ -105,6 +107,7 @@ export const seedTemplate = async (options: SeedTemplateOptions) => {
|
||||
id: prefixedId('envelope'),
|
||||
secondaryId: templateId.formattedTemplateId,
|
||||
internalVersion: options.internalVersion ?? 1,
|
||||
signatureLevel: SignatureLevel.SES,
|
||||
type: EnvelopeType.TEMPLATE,
|
||||
title,
|
||||
envelopeItems: {
|
||||
@@ -164,6 +167,7 @@ export const seedDirectTemplate = async (options: SeedTemplateOptions) => {
|
||||
id: prefixedId('envelope'),
|
||||
secondaryId: templateId.formattedTemplateId,
|
||||
internalVersion: options.internalVersion ?? 1,
|
||||
signatureLevel: SignatureLevel.SES,
|
||||
type: EnvelopeType.TEMPLATE,
|
||||
title,
|
||||
envelopeItems: {
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { executeTspSign } from '@documenso/ee/server-only/signing/csc/execute-tsp-sign';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
|
||||
import { procedure } from '../trpc';
|
||||
import { ZCscSignEnvelopeRequestSchema, ZCscSignEnvelopeResponseSchema } from './csc-sign-envelope.types';
|
||||
|
||||
/**
|
||||
* Internal mutation that drives the CSC TSP sign-time pipeline.
|
||||
*
|
||||
* `executeTspSign` does the heavy lifting (capture → batched signHash →
|
||||
* embed → tx). This route wraps it in a 15s `Promise.race` so an unresponsive
|
||||
* TSP surfaces as `CSC_TSP_TIMEOUT` instead of hanging the request. The
|
||||
* idle-timer is a soft cap on TSP round-trip latency; the underlying tx
|
||||
* keeps running on the server until it completes or errors.
|
||||
*/
|
||||
|
||||
const SIGN_TIMEOUT_MS = 15_000;
|
||||
|
||||
export const cscSignEnvelopeRoute = procedure
|
||||
.input(ZCscSignEnvelopeRequestSchema)
|
||||
.output(ZCscSignEnvelopeResponseSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const result = await Promise.race([
|
||||
executeTspSign({
|
||||
sessionId: input.sessionId,
|
||||
recipientToken: input.recipientToken,
|
||||
requestMetadata: ctx.metadata.requestMetadata,
|
||||
}),
|
||||
new Promise<never>((_, reject) =>
|
||||
setTimeout(
|
||||
() =>
|
||||
reject(
|
||||
new AppError(AppErrorCode.CSC_TSP_TIMEOUT, {
|
||||
message: 'CSC TSP did not respond within 15s.',
|
||||
}),
|
||||
),
|
||||
SIGN_TIMEOUT_MS,
|
||||
),
|
||||
),
|
||||
]);
|
||||
|
||||
return result;
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const ZCscSignEnvelopeRequestSchema = z.object({
|
||||
recipientToken: z.string().min(1),
|
||||
sessionId: z.string().min(1),
|
||||
});
|
||||
|
||||
export const ZCscSignEnvelopeResponseSchema = z.object({
|
||||
outcome: z.enum(['signed', 'already_signed']),
|
||||
});
|
||||
|
||||
export type TCscSignEnvelopeRequest = z.infer<typeof ZCscSignEnvelopeRequestSchema>;
|
||||
export type TCscSignEnvelopeResponse = z.infer<typeof ZCscSignEnvelopeResponseSchema>;
|
||||
@@ -2,6 +2,7 @@ import { router } from '../trpc';
|
||||
import { createOrganisationEmailRoute } from './create-organisation-email';
|
||||
import { createOrganisationEmailDomainRoute } from './create-organisation-email-domain';
|
||||
import { createSubscriptionRoute } from './create-subscription';
|
||||
import { cscSignEnvelopeRoute } from './csc-sign-envelope';
|
||||
import { declineLinkOrganisationAccountRoute } from './decline-link-organisation-account';
|
||||
import { deleteOrganisationEmailRoute } from './delete-organisation-email';
|
||||
import { deleteOrganisationEmailDomainRoute } from './delete-organisation-email-domain';
|
||||
@@ -55,4 +56,7 @@ export const enterpriseRouter = router({
|
||||
get: getInvoicesRoute,
|
||||
},
|
||||
},
|
||||
csc: {
|
||||
signEnvelope: cscSignEnvelopeRoute,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { assertEnvelopeMutable } from '@documenso/lib/server-only/envelope/assert-envelope-mutable';
|
||||
import { getEnvelopeWhereInput } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
|
||||
import { UNSAFE_replaceEnvelopeItemPdf } from '@documenso/lib/server-only/envelope-item/replace-envelope-item-pdf';
|
||||
import { getEnvelopeItemPermissions } from '@documenso/lib/utils/envelope';
|
||||
@@ -59,6 +60,8 @@ export const replaceEnvelopeItemPdfRoute = authenticatedProcedure
|
||||
});
|
||||
}
|
||||
|
||||
assertEnvelopeMutable(envelope);
|
||||
|
||||
if (envelope.internalVersion !== 2) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'PDF replacement is only supported for version 2 envelopes',
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { prepareCscRecipientSigning } from '@documenso/ee/server-only/signing/csc/prepare-recipient-signing';
|
||||
import { completeDocumentWithToken } from '@documenso/lib/server-only/document/complete-document-with-token';
|
||||
import { rejectDocumentWithToken } from '@documenso/lib/server-only/document/reject-document-with-token';
|
||||
import { createEnvelopeRecipients } from '@documenso/lib/server-only/recipient/create-envelope-recipients';
|
||||
@@ -6,6 +7,9 @@ import { getRecipientById } from '@documenso/lib/server-only/recipient/get-recip
|
||||
import { setDocumentRecipients } from '@documenso/lib/server-only/recipient/set-document-recipients';
|
||||
import { setTemplateRecipients } from '@documenso/lib/server-only/recipient/set-template-recipients';
|
||||
import { updateEnvelopeRecipients } from '@documenso/lib/server-only/recipient/update-envelope-recipients';
|
||||
import { isTspEnvelope } from '@documenso/lib/types/signature-level';
|
||||
import { unsafeBuildEnvelopeIdQuery } from '@documenso/lib/utils/envelope';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { EnvelopeType } from '@prisma/client';
|
||||
|
||||
import { ZGenericSuccessResponse, ZSuccessResponseSchema } from '../schema';
|
||||
@@ -13,6 +17,7 @@ import { authenticatedProcedure, procedure, router } from '../trpc';
|
||||
import { findRecipientSuggestionsRoute } from './find-recipient-suggestions';
|
||||
import {
|
||||
ZCompleteDocumentWithTokenMutationSchema,
|
||||
ZCompleteDocumentWithTokenResponseSchema,
|
||||
ZCreateDocumentRecipientRequestSchema,
|
||||
ZCreateDocumentRecipientResponseSchema,
|
||||
ZCreateDocumentRecipientsRequestSchema,
|
||||
@@ -559,6 +564,7 @@ export const recipientRouter = router({
|
||||
*/
|
||||
completeDocumentWithToken: procedure
|
||||
.input(ZCompleteDocumentWithTokenMutationSchema)
|
||||
.output(ZCompleteDocumentWithTokenResponseSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { token, documentId, accessAuthOptions, nextSigner, recipientOverride } = input;
|
||||
|
||||
@@ -568,6 +574,25 @@ export const recipientRouter = router({
|
||||
},
|
||||
});
|
||||
|
||||
// Branch on TSP envelopes before any SES side effects: TSP recipients
|
||||
// can't complete via this route — they go through the CSC sync sign
|
||||
// flow (`enterprise.csc.signEnvelope`). This route returns the redirect URL
|
||||
// for the credential-scope OAuth round-trip.
|
||||
const envelope = await prisma.envelope.findFirstOrThrow({
|
||||
where: {
|
||||
...unsafeBuildEnvelopeIdQuery({ type: 'documentId', id: documentId }, EnvelopeType.DOCUMENT),
|
||||
recipients: { some: { token } },
|
||||
},
|
||||
select: { signatureLevel: true, internalVersion: true },
|
||||
});
|
||||
|
||||
if (isTspEnvelope(envelope)) {
|
||||
return await prepareCscRecipientSigning({
|
||||
recipientToken: token,
|
||||
requestMetadata: ctx.metadata.requestMetadata,
|
||||
});
|
||||
}
|
||||
|
||||
await completeDocumentWithToken({
|
||||
token,
|
||||
id: {
|
||||
@@ -580,6 +605,8 @@ export const recipientRouter = router({
|
||||
userId: ctx.user?.id,
|
||||
requestMetadata: ctx.metadata.requestMetadata,
|
||||
});
|
||||
|
||||
return { status: 'SIGNED' as const };
|
||||
}),
|
||||
|
||||
/**
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user