Shared Sign Cert Validation (#5996)

## PR: Certificate Pre-Validation for Document Signing

### Problem

When a participant uploaded a certificate to sign a document, there was
no validation at submission time. If the certificate had the wrong
password, was expired, or was incompatible with the signing algorithm,
the error only surfaced during **finalization** — potentially days
later, after all other participants had signed. At that point the
session is stuck with no way to recover.

Additionally, `buildKeystore` in the finalization service only
recognised `"P12"` as a cert type, causing a `400 Invalid certificate
type: PKCS12` error when the **owner** signed using the standard
`PKCS12` identifier.

---

### What this PR does

#### Backend — Certificate pre-validation service

Adds `CertificateSubmissionValidator`, which validates a keystore before
it is stored by:
1. Loading the keystore with the provided password (catches wrong
password / corrupt file)
2. Checking the certificate's validity dates (catches expired and
not-yet-valid certs)
3. Test-signing a blank PDF using the same `PdfSigningService` code path
as finalization (catches algorithm incompatibilities)

This runs on both the participant submission endpoint
(`WorkflowParticipantController`) and the owner signing endpoint
(`SigningSessionController`), so both flows are protected.

#### Backend — Bug fix

`SigningFinalizationService.buildKeystore` now accepts `"PKCS12"` and
`"PFX"` as aliases for `"P12"`, consistent with how the validator
already handles them. This fixes a `400` error when the owner signed
using the `PKCS12` cert type.

#### Frontend — Real-time validation feedback

`ParticipantView` gains a debounced validation call (600ms) triggered
whenever the cert file or password changes. The UI shows:
- A spinner while validating
- Green "Certificate valid until [date] · [subject name]" on success
- Red error message on failure (wrong password, expired, not yet valid)
- The submit button is disabled while validation is in flight

#### Tests — Three layers

| Layer | File | Coverage |
|---|---|---|
| Service unit | `CertificateSubmissionValidatorTest` | 11 tests — valid
P12/JKS, wrong password, corrupt bytes, expired, not-yet-valid, signing
failure, cert type aliases |
| Controller unit | `WorkflowParticipantValidateCertificateTest` | 4
tests — valid cert, invalid cert, missing file, invalid token |
| Controller integration | `CertificateValidationIntegrationTest` | 6
tests — real `.p12`/`.jks` files through the full controller → validator
stack |
| Frontend E2E | `CertificateValidationE2E.spec.ts` | 7 Playwright tests
— all feedback states, button behaviour, SERVER type bypass |

#### CI

- **PR**: Playwright runs on chromium when frontend files change (~2-3
min)
- **Nightly / on-demand**: All three browsers (chromium, firefox,
webkit) at 2 AM UTC, also manually triggerable via `workflow_dispatch`
This commit is contained in:
ConnorYoh
2026-03-27 14:01:10 +00:00
committed by GitHub
parent e10c5f6283
commit dd44de349c
29 changed files with 1777 additions and 9 deletions
@@ -1,4 +1,5 @@
import React, { useState } from 'react';
import React, { useState, useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import {
Stack,
Card,
@@ -23,6 +24,7 @@ interface ParticipantViewProps {
}
const ParticipantView: React.FC<ParticipantViewProps> = ({ token }) => {
const { t } = useTranslation();
const { session, participant, loading, error, submitSignature, decline, downloadDocument } =
useParticipantSession(token);
@@ -37,6 +39,57 @@ const ParticipantView: React.FC<ParticipantViewProps> = ({ token }) => {
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
const [notification, setNotification] = useState<{ type: 'success' | 'error'; message: string } | null>(null);
type CertValidationState =
| { status: 'idle' }
| { status: 'validating' }
| { status: 'valid'; notAfter: string | null; subjectName: string | null }
| { status: 'error'; message: string };
const [certValidation, setCertValidation] = useState<CertValidationState>({ status: 'idle' });
const validationTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (certType === 'SERVER' || !certFile) {
setCertValidation({ status: 'idle' });
return;
}
if (validationTimerRef.current) clearTimeout(validationTimerRef.current);
setCertValidation({ status: 'validating' });
validationTimerRef.current = setTimeout(async () => {
try {
const formData = new FormData();
formData.append('participantToken', token);
formData.append('certType', certType);
formData.append('password', password);
if (certType === 'JKS') {
formData.append('jksFile', certFile);
} else {
formData.append('p12File', certFile);
}
const res = await fetch('/api/v1/workflow/participant/validate-certificate', {
method: 'POST',
body: formData,
});
const data = await res.json();
if (data.valid) {
setCertValidation({ status: 'valid', notAfter: data.notAfter, subjectName: data.subjectName });
} else {
setCertValidation({ status: 'error', message: data.error ?? t('certSign.collab.participant.certInvalidFallback', 'Invalid certificate') });
}
} catch {
setCertValidation({ status: 'error', message: t('certSign.collab.participant.certNetworkError', 'Could not validate certificate') });
}
}, 600);
return () => {
if (validationTimerRef.current) clearTimeout(validationTimerRef.current);
};
}, [certFile, password, certType, token]);
const handleSubmitSignature = async () => {
if (!certFile && certType !== 'SERVER') {
setNotification({ type: 'error', message: 'Please select a certificate file' });
@@ -190,6 +243,7 @@ const ParticipantView: React.FC<ParticipantViewProps> = ({ token }) => {
{ value: 'SERVER', label: 'Server Certificate (if available)' },
]}
size="sm"
data-testid="cert-type-select"
/>
{certType !== 'SERVER' && (
@@ -201,6 +255,7 @@ const ParticipantView: React.FC<ParticipantViewProps> = ({ token }) => {
onChange={setCertFile}
accept=".p12,.pfx,.jks"
size="sm"
data-testid="cert-file-input"
/>
<TextInput
@@ -209,7 +264,31 @@ const ParticipantView: React.FC<ParticipantViewProps> = ({ token }) => {
value={password}
onChange={(e) => setPassword(e.currentTarget.value)}
size="sm"
data-testid="cert-password-input"
/>
{/* Certificate validation feedback */}
{certValidation.status === 'validating' && (
<Text size="sm" c="dimmed" data-testid="cert-validation-feedback">
{t('certSign.collab.participant.certValidating', 'Validating certificate...')}
</Text>
)}
{certValidation.status === 'valid' && (
<Text size="sm" c="green" data-testid="cert-validation-feedback">
{t('certSign.collab.participant.certValid', '✓ Certificate valid')}
{certValidation.notAfter
? t('certSign.collab.participant.certValidUntil', ' until {{date}}', {
date: new Date(certValidation.notAfter).toLocaleDateString(),
})
: ''}
{certValidation.subjectName ? ` · ${certValidation.subjectName}` : ''}
</Text>
)}
{certValidation.status === 'error' && (
<Text size="sm" c="red" data-testid="cert-validation-feedback">
{t('certSign.collab.participant.certInvalid', '✗ {{error}}', { error: certValidation.message })}
</Text>
)}
</>
)}
@@ -243,7 +322,9 @@ const ParticipantView: React.FC<ParticipantViewProps> = ({ token }) => {
leftSection={<CheckCircleIcon fontSize="small" />}
onClick={handleSubmitSignature}
loading={isSubmitting}
disabled={isSubmitting || certValidation.status === 'validating'}
color="green"
data-testid="submit-signature-button"
>
Submit Signature
</Button>
@@ -253,6 +334,7 @@ const ParticipantView: React.FC<ParticipantViewProps> = ({ token }) => {
onClick={handleDecline}
color="red"
variant="light"
data-testid="decline-button"
>
Decline
</Button>