This commit is contained in:
David Nguyen
2026-08-26 12:10:29 +10:00
parent 9dc83bdb06
commit 6db71b13d4
70 changed files with 5964 additions and 892 deletions
@@ -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);
}
};
@@ -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 };
});
};