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}