mirror of
https://github.com/documenso/documenso.git
synced 2026-08-27 00:32:29 +10:00
fix: wip
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
import { RecipientRole } from '@prisma/client';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { SignatureLevel } from '../../types/signature-level';
|
||||
import { assertCompatibleRecipientGrouping } from './assert-compatible-recipient-grouping';
|
||||
|
||||
const signer = (signingOrder: number | null) => ({ role: RecipientRole.SIGNER, signingOrder });
|
||||
const cc = (signingOrder: number | null) => ({ role: RecipientRole.CC, signingOrder });
|
||||
|
||||
const expectRejected = (
|
||||
signatureLevel: string,
|
||||
recipients: Array<{ role: RecipientRole; signingOrder: number | null }>,
|
||||
) => {
|
||||
expect(() => assertCompatibleRecipientGrouping({ signatureLevel, recipients })).toThrow(
|
||||
/signing group|same signing step/i,
|
||||
);
|
||||
};
|
||||
|
||||
const expectAccepted = (
|
||||
signatureLevel: string,
|
||||
recipients: Array<{ role: RecipientRole; signingOrder: number | null }>,
|
||||
) => {
|
||||
expect(() => assertCompatibleRecipientGrouping({ signatureLevel, recipients })).not.toThrow();
|
||||
};
|
||||
|
||||
describe('assertCompatibleRecipientGrouping', () => {
|
||||
describe('AES/QES envelopes', () => {
|
||||
for (const signatureLevel of [SignatureLevel.AES, SignatureLevel.QES]) {
|
||||
it(`rejects two signers sharing a signing order (${signatureLevel})`, () => {
|
||||
expectRejected(signatureLevel, [signer(1), signer(2), signer(2)]);
|
||||
});
|
||||
|
||||
it(`accepts distinct signing orders (${signatureLevel})`, () => {
|
||||
expectAccepted(signatureLevel, [signer(1), signer(2), signer(3)]);
|
||||
});
|
||||
}
|
||||
|
||||
// Every null order collapses into the same tail step, so two of them sign
|
||||
// in parallel exactly as a duplicate order would.
|
||||
it('rejects two signers without a signing order', () => {
|
||||
expectRejected(SignatureLevel.AES, [signer(null), signer(null)]);
|
||||
});
|
||||
|
||||
it('rejects a signer without an order alongside an ordered signer', () => {
|
||||
// The unordered recipient shares the tail step with the other null.
|
||||
expectRejected(SignatureLevel.AES, [signer(1), signer(null), signer(null)]);
|
||||
});
|
||||
|
||||
it('accepts a single signer without a signing order', () => {
|
||||
expectAccepted(SignatureLevel.AES, [signer(null)]);
|
||||
});
|
||||
|
||||
it('accepts one ordered signer and one unordered signer', () => {
|
||||
expectAccepted(SignatureLevel.AES, [signer(1), signer(null)]);
|
||||
});
|
||||
|
||||
// CC recipients never sign, so their order carries no meaning.
|
||||
it('ignores CC recipients sharing an order with a signer', () => {
|
||||
expectAccepted(SignatureLevel.AES, [signer(1), cc(1)]);
|
||||
});
|
||||
|
||||
it('ignores several CC recipients sharing an order with each other', () => {
|
||||
expectAccepted(SignatureLevel.AES, [signer(1), cc(2), cc(2), cc(null), cc(null)]);
|
||||
});
|
||||
|
||||
it('accepts an empty recipient list', () => {
|
||||
expectAccepted(SignatureLevel.AES, []);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SES envelopes', () => {
|
||||
it('permits signing groups', () => {
|
||||
expectAccepted(SignatureLevel.SES, [signer(1), signer(2), signer(2)]);
|
||||
});
|
||||
|
||||
it('permits multiple unordered signers', () => {
|
||||
expectAccepted(SignatureLevel.SES, [signer(null), signer(null)]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { Recipient } from '@prisma/client';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { isTspEnvelope } from '../../types/signature-level';
|
||||
import { effectiveOrder } from '../../utils/recipient-groups';
|
||||
import { isCcRecipient } from '../../utils/recipients';
|
||||
|
||||
type AssertCompatibleRecipientGroupingOptions = {
|
||||
signatureLevel: string;
|
||||
recipients: Array<Pick<Recipient, 'role'> & { signingOrder?: number | null }>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Reject recipient signing groups on AES/QES envelopes.
|
||||
*
|
||||
* A "group" is two or more signing recipients sharing a signing step, which
|
||||
* they may then complete in any order — including at the same time. That is
|
||||
* parallel signing scoped to one step, so it breaks the same per-recipient
|
||||
* `/ByteRange` invariant that {@link assertCompatibleSigningOrder} exists to
|
||||
* protect: each TSP signature must cover the exact bytes it was applied to,
|
||||
* and concurrent incremental updates over one base state cannot all verify.
|
||||
*
|
||||
* Note this is not caught by the `signingOrder = PARALLEL` guard: groups are
|
||||
* expressed as duplicate `Recipient.signingOrder` values on a document whose
|
||||
* `documentMeta.signingOrder` is SEQUENTIAL.
|
||||
*
|
||||
* Recipients sharing a step are detected by {@link effectiveOrder}, so an
|
||||
* absent signing order counts too — every unordered recipient lands in the
|
||||
* same tail step and would sign in parallel.
|
||||
*
|
||||
* CC recipients are ignored: they never sign, and their signing order carries
|
||||
* no meaning anywhere else.
|
||||
*
|
||||
* SES envelopes pass through unchanged — signing groups are an SES feature.
|
||||
*
|
||||
* Schema-layer guard. {@link sendDocument} re-checks at distribution time as a
|
||||
* defence-in-depth backstop.
|
||||
*/
|
||||
export const assertCompatibleRecipientGrouping = ({
|
||||
signatureLevel,
|
||||
recipients,
|
||||
}: AssertCompatibleRecipientGroupingOptions): void => {
|
||||
if (!isTspEnvelope({ signatureLevel })) {
|
||||
return;
|
||||
}
|
||||
|
||||
const seenOrders = new Set<number>();
|
||||
|
||||
for (const recipient of recipients) {
|
||||
if (isCcRecipient(recipient)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const order = effectiveOrder(recipient);
|
||||
|
||||
if (seenOrders.has(order)) {
|
||||
throw new AppError(AppErrorCode.INVALID_BODY, {
|
||||
message: `Envelopes signed at '${signatureLevel}' cannot place two recipients in the same signing step — a signing group is parallel signing within one step, which breaks the per-recipient /ByteRange invariant TSP signatures rely on. Give every signing recipient a distinct signingOrder.`,
|
||||
});
|
||||
}
|
||||
|
||||
seenOrders.add(order);
|
||||
}
|
||||
};
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
import { RecipientRole } from '@prisma/client';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { assertCompatibleRecipientGrouping } from './assert-compatible-recipient-grouping';
|
||||
import { assignDefaultRecipientSigningOrders } from './assign-default-recipient-signing-orders';
|
||||
|
||||
const signer = (signingOrder?: number | null) => ({
|
||||
role: RecipientRole.SIGNER,
|
||||
signingOrder,
|
||||
});
|
||||
|
||||
const defaultRecipient = (email: string, role: RecipientRole = RecipientRole.SIGNER) => ({
|
||||
email,
|
||||
name: email,
|
||||
role,
|
||||
});
|
||||
|
||||
describe('assignDefaultRecipientSigningOrders', () => {
|
||||
it('leaves defaults unordered on SES envelopes', () => {
|
||||
const result = assignDefaultRecipientSigningOrders({
|
||||
signatureLevel: 'SES',
|
||||
payloadRecipients: [signer(1), signer(2)],
|
||||
defaultRecipients: [defaultRecipient('a@example.com'), defaultRecipient('b@example.com')],
|
||||
});
|
||||
|
||||
expect(result.map((recipient) => recipient.signingOrder)).toEqual([undefined, undefined]);
|
||||
});
|
||||
|
||||
it.each(['AES', 'QES'])('assigns distinct orders after the payload max on %s envelopes', (signatureLevel) => {
|
||||
const result = assignDefaultRecipientSigningOrders({
|
||||
signatureLevel,
|
||||
payloadRecipients: [signer(1), signer(4)],
|
||||
defaultRecipients: [defaultRecipient('a@example.com'), defaultRecipient('b@example.com')],
|
||||
});
|
||||
|
||||
expect(result.map((recipient) => recipient.signingOrder)).toEqual([5, 6]);
|
||||
});
|
||||
|
||||
it('numbers defaults from 1 when the payload has no numeric orders', () => {
|
||||
const result = assignDefaultRecipientSigningOrders({
|
||||
signatureLevel: 'QES',
|
||||
payloadRecipients: [],
|
||||
defaultRecipients: [defaultRecipient('a@example.com'), defaultRecipient('b@example.com')],
|
||||
});
|
||||
|
||||
expect(result.map((recipient) => recipient.signingOrder)).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it('skips CC defaults while numbering the rest', () => {
|
||||
const result = assignDefaultRecipientSigningOrders({
|
||||
signatureLevel: 'AES',
|
||||
payloadRecipients: [signer(2)],
|
||||
defaultRecipients: [
|
||||
defaultRecipient('a@example.com'),
|
||||
defaultRecipient('cc@example.com', RecipientRole.CC),
|
||||
defaultRecipient('b@example.com'),
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.map((recipient) => recipient.signingOrder)).toEqual([3, undefined, 4]);
|
||||
});
|
||||
|
||||
it('produces a combined set that satisfies the TSP grouping assertion', () => {
|
||||
const payloadRecipients = [signer(1), signer(2)];
|
||||
|
||||
const defaults = assignDefaultRecipientSigningOrders({
|
||||
signatureLevel: 'QES',
|
||||
payloadRecipients,
|
||||
defaultRecipients: [defaultRecipient('a@example.com'), defaultRecipient('b@example.com')],
|
||||
});
|
||||
|
||||
expect(() =>
|
||||
assertCompatibleRecipientGrouping({
|
||||
signatureLevel: 'QES',
|
||||
recipients: [...payloadRecipients, ...defaults],
|
||||
}),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('remains assertion-compatible when the payload holds a single unordered recipient', () => {
|
||||
// The write-path assert permits one unordered recipient (no shared step);
|
||||
// numbered defaults must not collide with it.
|
||||
const payloadRecipients = [signer(null)];
|
||||
|
||||
const defaults = assignDefaultRecipientSigningOrders({
|
||||
signatureLevel: 'AES',
|
||||
payloadRecipients,
|
||||
defaultRecipients: [defaultRecipient('a@example.com')],
|
||||
});
|
||||
|
||||
expect(defaults.map((recipient) => recipient.signingOrder)).toEqual([1]);
|
||||
|
||||
expect(() =>
|
||||
assertCompatibleRecipientGrouping({
|
||||
signatureLevel: 'AES',
|
||||
recipients: [...payloadRecipients, ...defaults],
|
||||
}),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { Recipient } from '@prisma/client';
|
||||
|
||||
import { isTspEnvelope } from '../../types/signature-level';
|
||||
import { isCcRecipient } from '../../utils/recipients';
|
||||
|
||||
type AssignDefaultRecipientSigningOrdersOptions<T> = {
|
||||
signatureLevel: string;
|
||||
/**
|
||||
* The recipients supplied by the caller, already validated by
|
||||
* {@link assertCompatibleRecipientGrouping}.
|
||||
*/
|
||||
payloadRecipients: Array<Pick<Recipient, 'role'> & { signingOrder?: number | null }>;
|
||||
/**
|
||||
* The team default recipients to append.
|
||||
*/
|
||||
defaultRecipients: T[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Assigns distinct signing orders to team default recipients appended to a
|
||||
* TSP (AES/QES) envelope.
|
||||
*
|
||||
* Team defaults carry no signing order, so on a TSP envelope two or more of
|
||||
* them would share the unordered tail step — a signing group, which TSP
|
||||
* signatures cannot hold (see {@link assertCompatibleRecipientGrouping}).
|
||||
* Numbering them after the payload's highest order keeps the combined set
|
||||
* valid at write time instead of relying on the send-time flatten backstop.
|
||||
*
|
||||
* CC defaults are left unordered: they never sign and are ignored by the
|
||||
* grouping assertion.
|
||||
*
|
||||
* SES envelopes pass through unchanged — shared steps are an SES feature.
|
||||
*/
|
||||
export const assignDefaultRecipientSigningOrders = <T extends Pick<Recipient, 'role'>>({
|
||||
signatureLevel,
|
||||
payloadRecipients,
|
||||
defaultRecipients,
|
||||
}: AssignDefaultRecipientSigningOrdersOptions<T>): Array<T & { signingOrder?: number }> => {
|
||||
if (!isTspEnvelope({ signatureLevel })) {
|
||||
return defaultRecipients;
|
||||
}
|
||||
|
||||
let nextOrder =
|
||||
payloadRecipients.reduce((highest, recipient) => Math.max(highest, recipient.signingOrder ?? 0), 0) + 1;
|
||||
|
||||
return defaultRecipients.map((recipient) => {
|
||||
if (isCcRecipient(recipient)) {
|
||||
return recipient;
|
||||
}
|
||||
|
||||
const signingOrder = nextOrder;
|
||||
|
||||
nextOrder += 1;
|
||||
|
||||
return { ...recipient, signingOrder };
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user