feat: add CSC AES/QES signing (v1 instance-wide config) (#2874)

Adds Cloud Signature Consortium (CSC) integration for AES/QES signing
against a configured TSP. v1 ships as instance-wide configuration via
environment variables, with per-envelope signature level selection,
license gating, and an OAuth-driven signing flow (capture + FIFO
signers, SAD session, blocking/in-progress recipient pages).

Includes signature level compatibility checks (role, signing order,
dictate next signer), envelope mutability assertions, Prisma migration
for signature level and CSC tables, and docs for the new signing
certificate options.
This commit is contained in:
Lucas Smith
2026-06-16 23:37:34 +10:00
committed by GitHub
parent 9b59f1a273
commit d5ce222482
103 changed files with 6524 additions and 77 deletions
@@ -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}.`,
});
}
}
}