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