mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 19:33:11 +02:00
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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user