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}