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.awt.Color;
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.lang.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.SignatureService;
060import org.nuxeo.ecm.platform.signature.api.user.AliasType;
061import org.nuxeo.ecm.platform.signature.api.user.AliasWrapper;
062import org.nuxeo.ecm.platform.signature.api.user.CUserService;
063import org.nuxeo.runtime.api.Framework;
064import org.nuxeo.runtime.model.ComponentInstance;
065import org.nuxeo.runtime.model.DefaultComponent;
066
067import com.lowagie.text.DocumentException;
068import com.lowagie.text.Font;
069import com.lowagie.text.FontFactory;
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 * <p>
085 * <ul>
086 * <li>
087 */
088public class SignatureServiceImpl extends DefaultComponent implements SignatureService {
089
090    private static final Log log = LogFactory.getLog(SignatureServiceImpl.class);
091
092    protected static final int SIGNATURE_FIELD_HEIGHT = 50;
093
094    protected static final int SIGNATURE_FIELD_WIDTH = 150;
095
096    protected static final int SIGNATURE_MARGIN = 10;
097
098    protected static final int PAGE_TO_SIGN = 1;
099
100    protected static final String XP_SIGNATURE = "signature";
101
102    protected static final String ALREADY_SIGNED_BY = "This document has already been signed by ";
103
104    protected static final String MIME_TYPE_PDF = "application/pdf";
105
106    /** From JODBasedConverter */
107    protected static final String PDFA1_PARAM = "PDF/A-1";
108
109    protected static final String FILE_CONTENT = "file:content";
110
111    protected static final String FILES_FILES = "files:files";
112
113    protected static final String FILES_FILE = "file";
114
115    protected static final String FILES_FILENAME = "filename";
116
117    protected static final String USER_EMAIL = "user:email";
118
119    protected final Map<String, SignatureDescriptor> signatureRegistryMap;
120
121    public SignatureServiceImpl() {
122        signatureRegistryMap = new HashMap<String, SignatureDescriptor>();
123    }
124
125    @Override
126    public void registerContribution(Object contribution, String extensionPoint, ComponentInstance contributor) {
127        if (XP_SIGNATURE.equals(extensionPoint)) {
128            SignatureDescriptor signatureDescriptor = (SignatureDescriptor) contribution;
129            if (!signatureDescriptor.getRemoveExtension()) {
130                signatureRegistryMap.put(signatureDescriptor.getId(), signatureDescriptor);
131            }
132        }
133    }
134
135    @Override
136    public void unregisterContribution(Object contribution, String extensionPoint, ComponentInstance contributor) {
137        if (XP_SIGNATURE.equals(extensionPoint)) {
138            SignatureDescriptor signatureDescriptor = (SignatureDescriptor) contribution;
139            if (!signatureDescriptor.getRemoveExtension()) {
140                signatureRegistryMap.remove(signatureDescriptor.getId());
141            }
142        }
143    }
144
145    //
146    // ----- SignatureService -----
147    //
148
149    @Override
150    public StatusWithBlob getSigningStatus(DocumentModel doc, DocumentModel user) {
151        if (doc == null) {
152            return new StatusWithBlob(UNSIGNABLE, null, null, null);
153        }
154        StatusWithBlob blobAndStatus = getSignedPdfBlobAndStatus(doc, user);
155        if (blobAndStatus != null) {
156            return blobAndStatus;
157        }
158        BlobHolder mbh = doc.getAdapter(BlobHolder.class);
159        Blob blob;
160        if (mbh == null || (blob = mbh.getBlob()) == null) {
161            return new StatusWithBlob(UNSIGNABLE, null, null, null);
162        }
163        return new StatusWithBlob(UNSIGNED, blob, mbh, FILE_CONTENT);
164    }
165
166    protected int getSigningStatus(Blob pdfBlob, DocumentModel user) {
167        if (pdfBlob == null) {
168            return UNSIGNED;
169        }
170        List<X509Certificate> certificates = getCertificates(pdfBlob);
171        if (certificates.isEmpty()) {
172            return UNSIGNED;
173        }
174        if (user == null) {
175            return SIGNED_OTHER;
176        }
177        String email = (String) user.getPropertyValue(USER_EMAIL);
178        if (StringUtils.isEmpty(email)) {
179            return SIGNED_OTHER;
180        }
181        CertService certService = Framework.getLocalService(CertService.class);
182        for (X509Certificate certificate : certificates) {
183            String certEmail;
184            try {
185                certEmail = certService.getCertificateEmail(certificate);
186            } catch (CertException e) {
187                continue;
188            }
189            if (email.equals(certEmail)) {
190                return SIGNED_CURRENT;
191            }
192        }
193        return SIGNED_OTHER;
194    }
195
196    /**
197     * Finds the first signed PDF blob.
198     */
199    protected StatusWithBlob getSignedPdfBlobAndStatus(DocumentModel doc, DocumentModel user) {
200        BlobHolder mbh = doc.getAdapter(BlobHolder.class);
201        if (mbh != null) {
202            Blob blob = mbh.getBlob();
203            if (blob != null && MIME_TYPE_PDF.equals(blob.getMimeType())) {
204                int status = getSigningStatus(blob, user);
205                if (status != UNSIGNED) {
206                    // TODO for File document it works, but for general
207                    // blob holders the path may be incorrect
208                    return new StatusWithBlob(status, blob, mbh, FILE_CONTENT);
209                }
210            }
211        }
212        @SuppressWarnings("unchecked")
213        List<Map<String, Serializable>> files = (List<Map<String, Serializable>>) doc.getPropertyValue(FILES_FILES);
214        int i = -1;
215        for (Map<String, Serializable> map : files) {
216            i++;
217            Blob blob = (Blob) map.get(FILES_FILE);
218            if (blob != null && MIME_TYPE_PDF.equals(blob.getMimeType())) {
219                int status = getSigningStatus(blob, user);
220                if (status != UNSIGNED) {
221                    String pathbase = FILES_FILES + "/" + i + "/";
222                    String path = pathbase + FILES_FILE;
223                    BlobHolder bh = new DocumentBlobHolder(doc, path, pathbase + FILES_FILENAME);
224                    return new StatusWithBlob(status, blob, bh, path);
225                }
226            }
227        }
228        return null;
229    }
230
231    @Override
232    public Blob signDocument(DocumentModel doc, DocumentModel user, String keyPassword, String reason, boolean pdfa,
233            SigningDisposition disposition, String archiveFilename) {
234
235        StatusWithBlob blobAndStatus = getSignedPdfBlobAndStatus(doc, user);
236        if (blobAndStatus != null) {
237            // re-sign it
238            Blob signedBlob = signPDF(blobAndStatus.blob, user, keyPassword, reason);
239            signedBlob.setFilename(blobAndStatus.blob.getFilename());
240            // replace the previous blob with a new one
241            blobAndStatus.blobHolder.setBlob(signedBlob);
242            return signedBlob;
243        }
244
245        Blob originalBlob;
246        BlobHolder mbh = doc.getAdapter(BlobHolder.class);
247        if (mbh == null || (originalBlob = mbh.getBlob()) == null) {
248            return null;
249        }
250
251        Blob pdfBlob;
252        if (MIME_TYPE_PDF.equals(originalBlob.getMimeType())) {
253            pdfBlob = originalBlob;
254        } else {
255            // convert to PDF or PDF/A first
256            ConversionService conversionService = Framework.getLocalService(ConversionService.class);
257            Map<String, Serializable> parameters = new HashMap<String, Serializable>();
258            if (pdfa) {
259                parameters.put(PDFA1_PARAM, Boolean.TRUE);
260            }
261            try {
262                BlobHolder holder = conversionService.convert("any2pdf", new SimpleBlobHolder(originalBlob), parameters);
263                pdfBlob = holder.getBlob();
264            } catch (ConversionException conversionException) {
265                throw new SignException(conversionException);
266            }
267        }
268
269        Blob signedBlob = signPDF(pdfBlob, user, keyPassword, reason);
270        signedBlob.setFilename(FilenameUtils.getBaseName(originalBlob.getFilename()) + ".pdf");
271
272        Map<String, Serializable> map;
273        ListDiff listDiff;
274        switch (disposition) {
275        case REPLACE:
276            // replace main blob
277            mbh.setBlob(signedBlob);
278            break;
279        case ARCHIVE:
280            // archive as attachment
281            originalBlob.setFilename(archiveFilename);
282            map = new HashMap<String, Serializable>();
283            map.put(FILES_FILE, (Serializable) originalBlob);
284            map.put(FILES_FILENAME, originalBlob.getFilename());
285            listDiff = new ListDiff();
286            listDiff.add(map);
287            doc.setPropertyValue(FILES_FILES, listDiff);
288            // and replace main blob
289            mbh.setBlob(signedBlob);
290            break;
291        case ATTACH:
292            // set as first attachment
293            map = new HashMap<String, Serializable>();
294            map.put(FILES_FILE, (Serializable) signedBlob);
295            map.put(FILES_FILENAME, signedBlob.getFilename());
296            listDiff = new ListDiff();
297            listDiff.insert(0, map);
298            doc.setPropertyValue(FILES_FILES, listDiff);
299            break;
300        }
301
302        return signedBlob;
303    }
304
305    @Override
306    public Blob signPDF(Blob pdfBlob, DocumentModel user, String keyPassword, String reason) {
307        CertService certService = Framework.getLocalService(CertService.class);
308        CUserService cUserService = Framework.getLocalService(CUserService.class);
309        try {
310            File outputFile = File.createTempFile("signed-", ".pdf");
311            Blob blob = Blobs.createBlob(outputFile, MIME_TYPE_PDF);
312            Framework.trackFile(outputFile, blob);
313
314            PdfReader pdfReader = new PdfReader(pdfBlob.getStream());
315            List<X509Certificate> pdfCertificates = getCertificates(pdfReader);
316
317            // allows for multiple signatures
318            PdfStamper pdfStamper = PdfStamper.createSignature(pdfReader, new FileOutputStream(outputFile), '\0', null,
319                    true);
320
321            PdfSignatureAppearance pdfSignatureAppearance = pdfStamper.getSignatureAppearance();
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.setCrypto(keyPair.getPrivate(), certChain, null, PdfSignatureAppearance.SELF_SIGNED);
341            if (StringUtils.isBlank(reason)) {
342                reason = getSigningReason();
343            }
344            pdfSignatureAppearance.setReason(reason);
345            pdfSignatureAppearance.setAcro6Layers(true);
346            Font layer2Font = FontFactory.getFont(FontFactory.TIMES, getSignatureLayout().getTextSize(), Font.NORMAL,
347                    new Color(0x00, 0x00, 0x00));
348            pdfSignatureAppearance.setLayer2Font(layer2Font);
349            pdfSignatureAppearance.setRender(PdfSignatureAppearance.SignatureRenderDescription);
350
351            pdfSignatureAppearance.setVisibleSignature(getNextCertificatePosition(pdfReader, pdfCertificates), 1, null);
352
353            pdfStamper.close(); // closes the file
354
355            log.debug("File " + outputFile.getAbsolutePath() + " created and signed with " + reason);
356
357            return blob;
358        } catch (IOException e) {
359            throw new SignException(e);
360        } catch (DocumentException e) {
361            // iText PDF stamping
362            throw new SignException(e);
363        } catch (IllegalArgumentException e) {
364            if (String.valueOf(e.getMessage()).contains("PdfReader not opened with owner password")) {
365                // iText PDF reading
366                throw new SignException("PDF is password-protected");
367            }
368            throw new SignException(e);
369        }
370    }
371
372    /**
373     * @since 5.8
374     * @return the signature layout. Default one if no contribution.
375     */
376    protected SignatureDescriptor.SignatureLayout getSignatureLayout() {
377        for (SignatureDescriptor signatureDescriptor : signatureRegistryMap.values()) {
378            SignatureDescriptor.SignatureLayout signatureLayout = signatureDescriptor.getSignatureLayout();
379            if (signatureLayout != null) {
380                return signatureLayout;
381            }
382        }
383        return new SignatureDescriptor.SignatureLayout();
384    }
385
386    protected String getSigningReason() throws SignException {
387        for (SignatureDescriptor sd : signatureRegistryMap.values()) {
388            String reason = sd.getReason();
389            if (!StringUtils.isBlank(reason)) {
390                return reason;
391            }
392        }
393        throw new SignException("No default signing reason provided in configuration");
394    }
395
396    protected boolean certificatePresentInPDF(Certificate userCert, List<X509Certificate> pdfCertificates)
397            throws SignException {
398        X509Certificate xUserCert = (X509Certificate) userCert;
399        for (X509Certificate xcert : pdfCertificates) {
400            // matching certificate found
401            if (xcert.getSubjectX500Principal().equals(xUserCert.getSubjectX500Principal())) {
402                return true;
403            }
404        }
405        return false;
406    }
407
408    /**
409     * @since 5.8 Provides the position rectangle for the next certificate. An assumption is made that all previous
410     *        certificates in a given PDF were placed using the same technique and settings. New certificates are added
411     *        depending of signature layout contributed.
412     */
413    protected Rectangle getNextCertificatePosition(PdfReader pdfReader, List<X509Certificate> pdfCertificates)
414            throws SignException {
415        int numberOfSignatures = pdfCertificates.size();
416
417        Rectangle pageSize = pdfReader.getPageSize(PAGE_TO_SIGN);
418
419        // PDF size
420        float width = pageSize.getWidth();
421        float height = pageSize.getHeight();
422
423        // Signature size
424        float rectangleWidth = width / getSignatureLayout().getColumns();
425        float rectangeHeight = height / getSignatureLayout().getLines();
426
427        // Signature location
428        int column = numberOfSignatures % getSignatureLayout().getColumns() + getSignatureLayout().getStartColumn();
429        int line = numberOfSignatures / getSignatureLayout().getColumns() + getSignatureLayout().getStartLine();
430        if (column > getSignatureLayout().getColumns()) {
431            column = column % getSignatureLayout().getColumns();
432            line++;
433        }
434
435        // Skip rectangle display If number of signatures exceed free locations
436        // on pdf layout
437        if (line > getSignatureLayout().getLines()) {
438            return new Rectangle(0, 0, 0, 0);
439        }
440
441        // make smaller by page margin
442        float topRightX = rectangleWidth * column;
443        float bottomLeftY = height - rectangeHeight * line;
444        float bottomLeftX = topRightX - SIGNATURE_FIELD_WIDTH;
445        float topRightY = bottomLeftY + SIGNATURE_FIELD_HEIGHT;
446
447        // verify current position coordinates in case they were
448        // misconfigured
449        validatePageBounds(pdfReader, 1, bottomLeftX, true);
450        validatePageBounds(pdfReader, 1, bottomLeftY, false);
451        validatePageBounds(pdfReader, 1, topRightX, true);
452        validatePageBounds(pdfReader, 1, topRightY, false);
453
454        Rectangle positionRectangle = new Rectangle(bottomLeftX, bottomLeftY, topRightX, topRightY);
455
456        return positionRectangle;
457    }
458
459    /**
460     * Verifies that a provided value fits within the page bounds. If it does not, a sign exception is thrown. This is
461     * to verify externally configurable signature positioning.
462     *
463     * @param isHorizontal - if false, the current value is checked agains the vertical page dimension
464     */
465    protected void validatePageBounds(PdfReader pdfReader, int pageNo, float valueToCheck, boolean isHorizontal)
466            throws SignException {
467        if (valueToCheck < 0) {
468            String message = "The new signature position " + valueToCheck
469                    + " exceeds the page bounds. The position must be a positive number.";
470            log.debug(message);
471            throw new SignException(message);
472        }
473
474        Rectangle pageRectangle = pdfReader.getPageSize(pageNo);
475        if (isHorizontal && valueToCheck > pageRectangle.getRight()) {
476            String message = "The new signature position " + valueToCheck
477                    + " exceeds the horizontal page bounds. The page dimensions are: (" + pageRectangle + ").";
478            log.debug(message);
479            throw new SignException(message);
480        }
481        if (!isHorizontal && valueToCheck > pageRectangle.getTop()) {
482            String message = "The new signature position " + valueToCheck
483                    + " exceeds the vertical page bounds. The page dimensions are: (" + pageRectangle + ").";
484            log.debug(message);
485            throw new SignException(message);
486        }
487    }
488
489    @Override
490    public List<X509Certificate> getCertificates(DocumentModel doc) {
491        StatusWithBlob signedBlob = getSignedPdfBlobAndStatus(doc, null);
492        if (signedBlob == null) {
493            return Collections.emptyList();
494        }
495        return getCertificates(signedBlob.blob);
496    }
497
498    protected List<X509Certificate> getCertificates(Blob pdfBlob) throws SignException {
499        try {
500            PdfReader pdfReader = new PdfReader(pdfBlob.getStream());
501            return getCertificates(pdfReader);
502        } catch (IOException e) {
503            String message = "";
504            if (e.getMessage().equals("PDF header signature not found.")) {
505                message = "PDF seems to be corrupted";
506            }
507            throw new SignException(message, e);
508        }
509    }
510
511    protected List<X509Certificate> getCertificates(PdfReader pdfReader) throws SignException {
512        List<X509Certificate> pdfCertificates = new ArrayList<X509Certificate>();
513        AcroFields acroFields = pdfReader.getAcroFields();
514        @SuppressWarnings("unchecked")
515        List<String> signatureNames = acroFields.getSignatureNames();
516        for (String signatureName : signatureNames) {
517            PdfPKCS7 pdfPKCS7 = acroFields.verifySignature(signatureName);
518            X509Certificate signingCertificate = pdfPKCS7.getSigningCertificate();
519            pdfCertificates.add(signingCertificate);
520        }
521        return pdfCertificates;
522    }
523
524}