001/*
002 * (C) Copyright 2013-2018 Nuxeo (http://nuxeo.com/) and others.
003 *
004 * Licensed under the Apache License, Version 2.0 (the "License");
005 * you may not use this file except in compliance with the License.
006 * You may obtain a copy of the License at
007 *
008 *     http://www.apache.org/licenses/LICENSE-2.0
009 *
010 * Unless required by applicable law or agreed to in writing, software
011 * distributed under the License is distributed on an "AS IS" BASIS,
012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 * See the License for the specific language governing permissions and
014 * limitations under the License.
015 *
016 * Contributors:
017 *     Wojciech Sulejman
018 *     Florent Guillaume
019 *     Vladimir Pasquier <vpasquier@nuxeo.com>
020 */
021package org.nuxeo.ecm.platform.signature.core.sign;
022
023import static org.nuxeo.ecm.platform.signature.api.sign.SignatureService.StatusWithBlob.SIGNED_CURRENT;
024import static org.nuxeo.ecm.platform.signature.api.sign.SignatureService.StatusWithBlob.SIGNED_OTHER;
025import static org.nuxeo.ecm.platform.signature.api.sign.SignatureService.StatusWithBlob.UNSIGNABLE;
026import static org.nuxeo.ecm.platform.signature.api.sign.SignatureService.StatusWithBlob.UNSIGNED;
027
028import java.io.File;
029import java.io.FileOutputStream;
030import java.io.IOException;
031import java.io.Serializable;
032import java.security.KeyPair;
033import java.security.KeyStore;
034import java.security.cert.Certificate;
035import java.security.cert.X509Certificate;
036import java.util.ArrayList;
037import java.util.Collections;
038import java.util.HashMap;
039import java.util.List;
040import java.util.Map;
041
042import org.apache.commons.io.FilenameUtils;
043import org.apache.commons.lang3.StringUtils;
044import org.apache.commons.logging.Log;
045import org.apache.commons.logging.LogFactory;
046import org.nuxeo.ecm.core.api.Blob;
047import org.nuxeo.ecm.core.api.Blobs;
048import org.nuxeo.ecm.core.api.DocumentModel;
049import org.nuxeo.ecm.core.api.ListDiff;
050import org.nuxeo.ecm.core.api.blobholder.BlobHolder;
051import org.nuxeo.ecm.core.api.blobholder.DocumentBlobHolder;
052import org.nuxeo.ecm.core.api.blobholder.SimpleBlobHolder;
053import org.nuxeo.ecm.core.convert.api.ConversionException;
054import org.nuxeo.ecm.core.convert.api.ConversionService;
055import org.nuxeo.ecm.platform.signature.api.exception.AlreadySignedException;
056import org.nuxeo.ecm.platform.signature.api.exception.CertException;
057import org.nuxeo.ecm.platform.signature.api.exception.SignException;
058import org.nuxeo.ecm.platform.signature.api.pki.CertService;
059import org.nuxeo.ecm.platform.signature.api.sign.SignatureAppearanceFactory;
060import org.nuxeo.ecm.platform.signature.api.sign.SignatureLayout;
061import org.nuxeo.ecm.platform.signature.api.sign.SignatureService;
062import org.nuxeo.ecm.platform.signature.api.user.AliasType;
063import org.nuxeo.ecm.platform.signature.api.user.AliasWrapper;
064import org.nuxeo.ecm.platform.signature.api.user.CUserService;
065import org.nuxeo.runtime.api.Framework;
066import org.nuxeo.runtime.model.ComponentInstance;
067import org.nuxeo.runtime.model.DefaultComponent;
068
069import com.lowagie.text.DocumentException;
070import com.lowagie.text.Rectangle;
071import com.lowagie.text.pdf.AcroFields;
072import com.lowagie.text.pdf.PdfPKCS7;
073import com.lowagie.text.pdf.PdfReader;
074import com.lowagie.text.pdf.PdfSignatureAppearance;
075import com.lowagie.text.pdf.PdfStamper;
076
077/**
078 * Base implementation for the signature service (also a Nuxeo component).
079 * <p>
080 * The main document is signed. If it's not already a PDF, then a PDF conversion is done.
081 * <p>
082 * Once signed, it can replace the main document or be stored as the first attachment. If replacing the main document,
083 * an archive of the original can be kept.
084 */
085public class SignatureServiceImpl extends DefaultComponent implements SignatureService {
086
087    private static final Log log = LogFactory.getLog(SignatureServiceImpl.class);
088
089    protected static final int SIGNATURE_FIELD_HEIGHT = 50;
090
091    protected static final int SIGNATURE_FIELD_WIDTH = 150;
092
093    protected static final int SIGNATURE_MARGIN = 10;
094
095    protected static final int PAGE_TO_SIGN = 1;
096
097    protected static final String XP_SIGNATURE = "signature";
098
099    protected static final String ALREADY_SIGNED_BY = "This document has already been signed by ";
100
101    protected static final String MIME_TYPE_PDF = "application/pdf";
102
103    /** From JODBasedConverter */
104    protected static final String PDFA1_PARAM = "PDF/A-1";
105
106    protected static final String FILE_CONTENT = "file:content";
107
108    protected static final String FILES_FILES = "files:files";
109
110    protected static final String FILES_FILE = "file";
111
112    protected static final String USER_EMAIL = "user:email";
113
114    protected final Map<String, SignatureDescriptor> signatureRegistryMap;
115
116    public SignatureServiceImpl() {
117        signatureRegistryMap = new HashMap<>();
118    }
119
120    @Override
121    public void registerContribution(Object contribution, String extensionPoint, ComponentInstance contributor) {
122        if (XP_SIGNATURE.equals(extensionPoint)) {
123            SignatureDescriptor signatureDescriptor = (SignatureDescriptor) contribution;
124            if (!signatureDescriptor.getRemoveExtension()) {
125                signatureRegistryMap.put(signatureDescriptor.getId(), signatureDescriptor);
126            } else {
127                signatureRegistryMap.remove(signatureDescriptor.getId());
128            }
129        }
130    }
131
132    @Override
133    public void unregisterContribution(Object contribution, String extensionPoint, ComponentInstance contributor) {
134        if (XP_SIGNATURE.equals(extensionPoint)) {
135            SignatureDescriptor signatureDescriptor = (SignatureDescriptor) contribution;
136            if (!signatureDescriptor.getRemoveExtension()) {
137                signatureRegistryMap.remove(signatureDescriptor.getId());
138            }
139        }
140    }
141
142    //
143    // ----- SignatureService -----
144    //
145
146    @Override
147    public StatusWithBlob getSigningStatus(DocumentModel doc, DocumentModel user) {
148        if (doc == null) {
149            return new StatusWithBlob(UNSIGNABLE, null, null, null);
150        }
151        StatusWithBlob blobAndStatus = getSignedPdfBlobAndStatus(doc, user);
152        if (blobAndStatus != null) {
153            return blobAndStatus;
154        }
155        BlobHolder mbh = doc.getAdapter(BlobHolder.class);
156        Blob blob;
157        if (mbh == null || (blob = mbh.getBlob()) == null) {
158            return new StatusWithBlob(UNSIGNABLE, null, null, null);
159        }
160        return new StatusWithBlob(UNSIGNED, blob, mbh, FILE_CONTENT);
161    }
162
163    protected int getSigningStatus(Blob pdfBlob, DocumentModel user) {
164        if (pdfBlob == null) {
165            return UNSIGNED;
166        }
167        List<X509Certificate> certificates = getCertificates(pdfBlob);
168        if (certificates.isEmpty()) {
169            return UNSIGNED;
170        }
171        if (user == null) {
172            return SIGNED_OTHER;
173        }
174        String email = (String) user.getPropertyValue(USER_EMAIL);
175        if (StringUtils.isEmpty(email)) {
176            return SIGNED_OTHER;
177        }
178        CertService certService = Framework.getService(CertService.class);
179        for (X509Certificate certificate : certificates) {
180            String certEmail;
181            try {
182                certEmail = certService.getCertificateEmail(certificate);
183            } catch (CertException e) {
184                continue;
185            }
186            if (email.equals(certEmail)) {
187                return SIGNED_CURRENT;
188            }
189        }
190        return SIGNED_OTHER;
191    }
192
193    /**
194     * Finds the first signed PDF blob.
195     */
196    protected StatusWithBlob getSignedPdfBlobAndStatus(DocumentModel doc, DocumentModel user) {
197        BlobHolder mbh = doc.getAdapter(BlobHolder.class);
198        if (mbh != null) {
199            Blob blob = mbh.getBlob();
200            if (blob != null && MIME_TYPE_PDF.equals(blob.getMimeType())) {
201                int status = getSigningStatus(blob, user);
202                if (status != UNSIGNED) {
203                    // TODO for File document it works, but for general
204                    // blob holders the path may be incorrect
205                    return new StatusWithBlob(status, blob, mbh, FILE_CONTENT);
206                }
207            }
208        }
209        @SuppressWarnings("unchecked")
210        List<Map<String, Serializable>> files = (List<Map<String, Serializable>>) doc.getPropertyValue(FILES_FILES);
211        int i = -1;
212        for (Map<String, Serializable> map : files) {
213            i++;
214            Blob blob = (Blob) map.get(FILES_FILE);
215            if (blob != null && MIME_TYPE_PDF.equals(blob.getMimeType())) {
216                int status = getSigningStatus(blob, user);
217                if (status != UNSIGNED) {
218                    String pathbase = FILES_FILES + "/" + i + "/";
219                    String path = pathbase + FILES_FILE;
220                    BlobHolder bh = new DocumentBlobHolder(doc, path);
221                    return new StatusWithBlob(status, blob, bh, path);
222                }
223            }
224        }
225        return null;
226    }
227
228    @Override
229    public Blob signDocument(DocumentModel doc, DocumentModel user, String keyPassword, String reason, boolean pdfa,
230            SigningDisposition disposition, String archiveFilename) {
231
232        StatusWithBlob blobAndStatus = getSignedPdfBlobAndStatus(doc, user);
233        if (blobAndStatus != null) {
234            // re-sign it
235            Blob signedBlob = signPDF(blobAndStatus.blob, doc, user, keyPassword, reason);
236            signedBlob.setFilename(blobAndStatus.blob.getFilename());
237            // replace the previous blob with a new one
238            blobAndStatus.blobHolder.setBlob(signedBlob);
239            return signedBlob;
240        }
241
242        Blob originalBlob;
243        BlobHolder mbh = doc.getAdapter(BlobHolder.class);
244        if (mbh == null || (originalBlob = mbh.getBlob()) == null) {
245            return null;
246        }
247
248        Blob pdfBlob;
249        if (MIME_TYPE_PDF.equals(originalBlob.getMimeType())) {
250            pdfBlob = originalBlob;
251        } else {
252            // convert to PDF or PDF/A first
253            ConversionService conversionService = Framework.getService(ConversionService.class);
254            Map<String, Serializable> parameters = new HashMap<>();
255            if (pdfa) {
256                parameters.put(PDFA1_PARAM, Boolean.TRUE);
257            }
258            try {
259                BlobHolder holder = conversionService.convert("any2pdf", new SimpleBlobHolder(originalBlob),
260                        parameters);
261                pdfBlob = holder.getBlob();
262            } catch (ConversionException conversionException) {
263                throw new SignException(conversionException);
264            }
265        }
266
267        Blob signedBlob = signPDF(pdfBlob, doc, user, keyPassword, reason);
268        signedBlob.setFilename(FilenameUtils.getBaseName(originalBlob.getFilename()) + ".pdf");
269
270        Map<String, Serializable> map;
271        ListDiff listDiff;
272        switch (disposition) {
273        case REPLACE:
274            // replace main blob
275            mbh.setBlob(signedBlob);
276            break;
277        case ARCHIVE:
278            // archive as attachment
279            originalBlob.setFilename(archiveFilename);
280            map = new HashMap<>();
281            map.put(FILES_FILE, (Serializable) originalBlob);
282            listDiff = new ListDiff();
283            listDiff.add(map);
284            doc.setPropertyValue(FILES_FILES, listDiff);
285            // and replace main blob
286            mbh.setBlob(signedBlob);
287            break;
288        case ATTACH:
289            // set as first attachment
290            map = new HashMap<>();
291            map.put(FILES_FILE, (Serializable) signedBlob);
292            listDiff = new ListDiff();
293            listDiff.insert(0, map);
294            doc.setPropertyValue(FILES_FILES, listDiff);
295            break;
296        }
297
298        return signedBlob;
299    }
300
301    @Override
302    public Blob signPDF(Blob pdfBlob, DocumentModel doc, DocumentModel user, String keyPassword, String reason) {
303        CertService certService = Framework.getService(CertService.class);
304        CUserService cUserService = Framework.getService(CUserService.class);
305        try {
306            File outputFile = Framework.createTempFile("signed-", ".pdf");
307            Blob blob = Blobs.createBlob(outputFile, MIME_TYPE_PDF);
308            Framework.trackFile(outputFile, blob);
309
310            PdfReader pdfReader = new PdfReader(pdfBlob.getStream());
311            List<X509Certificate> pdfCertificates = getCertificates(pdfReader);
312
313            // allows for multiple signatures
314            PdfStamper pdfStamper = PdfStamper.createSignature(pdfReader, new FileOutputStream(outputFile), '\0', null,
315                    true);
316
317            String userID = (String) user.getPropertyValue("user:username");
318            AliasWrapper alias = new AliasWrapper(userID);
319            KeyStore keystore = cUserService.getUserKeystore(userID, keyPassword);
320            Certificate certificate = certService.getCertificate(keystore, alias.getId(AliasType.CERT));
321            KeyPair keyPair = certService.getKeyPair(keystore, alias.getId(AliasType.KEY), alias.getId(AliasType.CERT),
322                    keyPassword);
323
324            if (certificatePresentInPDF(certificate, pdfCertificates)) {
325                X509Certificate userX509Certificate = (X509Certificate) certificate;
326                String message = ALREADY_SIGNED_BY + userX509Certificate.getSubjectDN();
327                log.debug(message);
328                throw new AlreadySignedException(message);
329            }
330
331            PdfSignatureAppearance pdfSignatureAppearance = pdfStamper.getSignatureAppearance();
332            pdfSignatureAppearance.setCrypto(keyPair.getPrivate(), (X509Certificate) certificate, null, PdfSignatureAppearance.SELF_SIGNED);
333            if (StringUtils.isBlank(reason)) {
334                reason = getSigningReason();
335            }
336            pdfSignatureAppearance.setVisibleSignature(getNextCertificatePosition(pdfReader, pdfCertificates), 1, null);
337            getSignatureAppearanceFactory().format(pdfSignatureAppearance, doc, userID, reason);
338
339            pdfStamper.close(); // closes the file
340
341            log.debug("File " + outputFile.getAbsolutePath() + " created and signed with " + reason);
342
343            return blob;
344        } catch (IOException | DocumentException | ReflectiveOperationException e) {
345            throw new SignException(e);
346        } catch (IllegalArgumentException e) {
347            if (String.valueOf(e.getMessage()).contains("PdfReader not opened with owner password")) {
348                // iText PDF reading
349                throw new SignException("PDF is password-protected");
350            }
351            throw new SignException(e);
352        }
353    }
354
355    /**
356     * @since 5.8
357     * @return the signature layout. Default one if no contribution.
358     */
359    @Override
360    public SignatureLayout getSignatureLayout() {
361        for (SignatureDescriptor signatureDescriptor : signatureRegistryMap.values()) {
362            SignatureLayout signatureLayout = signatureDescriptor.getSignatureLayout();
363            if (signatureLayout != null) {
364                return signatureLayout;
365            }
366        }
367        return new SignatureDescriptor.SignatureLayout();
368    }
369
370    protected SignatureAppearanceFactory getSignatureAppearanceFactory() throws ReflectiveOperationException {
371        if (!signatureRegistryMap.isEmpty()) {
372            SignatureDescriptor signatureDescriptor = signatureRegistryMap.values().iterator().next();
373            return signatureDescriptor.getAppearanceFatory();
374        }
375        return new DefaultSignatureAppearanceFactory();
376    }
377
378    protected String getSigningReason() throws SignException {
379        for (SignatureDescriptor sd : signatureRegistryMap.values()) {
380            String reason = sd.getReason();
381            if (!StringUtils.isBlank(reason)) {
382                return reason;
383            }
384        }
385        throw new SignException("No default signing reason provided in configuration");
386    }
387
388    protected boolean certificatePresentInPDF(Certificate userCert, List<X509Certificate> pdfCertificates)
389            throws SignException {
390        X509Certificate xUserCert = (X509Certificate) userCert;
391        for (X509Certificate xcert : pdfCertificates) {
392            // matching certificate found
393            if (xcert.getSubjectX500Principal().equals(xUserCert.getSubjectX500Principal())) {
394                return true;
395            }
396        }
397        return false;
398    }
399
400    /**
401     * @since 5.8 Provides the position rectangle for the next certificate. An assumption is made that all previous
402     *        certificates in a given PDF were placed using the same technique and settings. New certificates are added
403     *        depending of signature layout contributed.
404     */
405    protected Rectangle getNextCertificatePosition(PdfReader pdfReader, List<X509Certificate> pdfCertificates)
406            throws SignException {
407        int numberOfSignatures = pdfCertificates.size();
408
409        Rectangle pageSize = pdfReader.getPageSize(PAGE_TO_SIGN);
410
411        // PDF size
412        float width = pageSize.getWidth();
413        float height = pageSize.getHeight();
414
415        // Signature size
416        float rectangleWidth = width / getSignatureLayout().getColumns();
417        float rectangeHeight = height / getSignatureLayout().getLines();
418
419        // Signature location
420        int column = numberOfSignatures % getSignatureLayout().getColumns() + getSignatureLayout().getStartColumn();
421        int line = numberOfSignatures / getSignatureLayout().getColumns() + getSignatureLayout().getStartLine();
422        if (column > getSignatureLayout().getColumns()) {
423            column = column % getSignatureLayout().getColumns();
424            line++;
425        }
426
427        // Skip rectangle display If number of signatures exceed free locations
428        // on pdf layout
429        if (line > getSignatureLayout().getLines()) {
430            return new Rectangle(0, 0, 0, 0);
431        }
432
433        // make smaller by page margin
434        float topRightX = rectangleWidth * column;
435        float bottomLeftY = height - rectangeHeight * line;
436        float bottomLeftX = topRightX - SIGNATURE_FIELD_WIDTH;
437        float topRightY = bottomLeftY + SIGNATURE_FIELD_HEIGHT;
438
439        // verify current position coordinates in case they were
440        // misconfigured
441        validatePageBounds(pdfReader, 1, bottomLeftX, true);
442        validatePageBounds(pdfReader, 1, bottomLeftY, false);
443        validatePageBounds(pdfReader, 1, topRightX, true);
444        validatePageBounds(pdfReader, 1, topRightY, false);
445
446        Rectangle positionRectangle = new Rectangle(bottomLeftX, bottomLeftY, topRightX, topRightY);
447
448        return positionRectangle;
449    }
450
451    /**
452     * Verifies that a provided value fits within the page bounds. If it does not, a sign exception is thrown. This is
453     * to verify externally configurable signature positioning.
454     *
455     * @param isHorizontal - if false, the current value is checked agains the vertical page dimension
456     */
457    protected void validatePageBounds(PdfReader pdfReader, int pageNo, float valueToCheck, boolean isHorizontal)
458            throws SignException {
459        if (valueToCheck < 0) {
460            String message = "The new signature position " + valueToCheck
461                    + " exceeds the page bounds. The position must be a positive number.";
462            log.debug(message);
463            throw new SignException(message);
464        }
465
466        Rectangle pageRectangle = pdfReader.getPageSize(pageNo);
467        if (isHorizontal && valueToCheck > pageRectangle.getRight()) {
468            String message = "The new signature position " + valueToCheck
469                    + " exceeds the horizontal page bounds. The page dimensions are: (" + pageRectangle + ").";
470            log.debug(message);
471            throw new SignException(message);
472        }
473        if (!isHorizontal && valueToCheck > pageRectangle.getTop()) {
474            String message = "The new signature position " + valueToCheck
475                    + " exceeds the vertical page bounds. The page dimensions are: (" + pageRectangle + ").";
476            log.debug(message);
477            throw new SignException(message);
478        }
479    }
480
481    @Override
482    public List<X509Certificate> getCertificates(DocumentModel doc) {
483        StatusWithBlob signedBlob = getSignedPdfBlobAndStatus(doc, null);
484        if (signedBlob == null) {
485            return Collections.emptyList();
486        }
487        return getCertificates(signedBlob.blob);
488    }
489
490    protected List<X509Certificate> getCertificates(Blob pdfBlob) throws SignException {
491        try {
492            PdfReader pdfReader = new PdfReader(pdfBlob.getStream());
493            return getCertificates(pdfReader);
494        } catch (IOException e) {
495            String message = "";
496            if (e.getMessage().equals("PDF header signature not found.")) {
497                message = "PDF seems to be corrupted";
498            }
499            throw new SignException(message, e);
500        }
501    }
502
503    protected List<X509Certificate> getCertificates(PdfReader pdfReader) throws SignException {
504        List<X509Certificate> pdfCertificates = new ArrayList<>();
505        AcroFields acroFields = pdfReader.getAcroFields();
506        List<String> signatureNames = acroFields.getSignatureNames();
507        for (String signatureName : signatureNames) {
508            PdfPKCS7 pdfPKCS7 = acroFields.verifySignature(signatureName);
509            X509Certificate signingCertificate = pdfPKCS7.getSigningCertificate();
510            pdfCertificates.add(signingCertificate);
511        }
512        return pdfCertificates;
513    }
514
515}