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