Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGES.txt
Original file line number Diff line number Diff line change
@@ -1,3 +1,11 @@
Release 4.2.1 - ???

OTHER CHANGES

* PEM-armored CMS/PKCS7 (-----BEGIN PKCS7----- or -----BEGIN CMS-----) is parsed like
DER: Pkcs7Parser and the opt-in pkcs-7-detector report the smime-type and extract
signed content; -----BEGIN CMS----- is now detected (TIKA-5002).

Release 4.2.0 - 10/25/2026

BREAKING CHANGES AND DEPRECATIONS
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -921,8 +921,9 @@
<mime-type type="application/pkcs7-signature">
<glob pattern="*.p7s"/>
<magic priority="50">
<!-- PEM encoded -->
<!-- PEM encoded; Pkcs7Parser decodes the armor and refines as for DER -->
<match value="-----BEGIN PKCS7" type="string" offset="0"/>
<match value="-----BEGIN CMS-----" type="string" offset="0"/>
<!-- DER encoded: SEQUENCE then a CMS ContentInfo whose contentType is in the pkcs7 arc
1.2.840.113549.1.7.x (signedData .2, envelopedData .3, digestedData .5, encryptedData .6,
... last OID byte masked) followed by the [0] content tag (a0). offset 2:6 spans the DER
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,15 @@
*/
package org.apache.tika.parser.crypto;

import java.io.BufferedInputStream;
import java.io.FilterInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.Base64;
import java.util.Map;

import org.bouncycastle.asn1.ASN1Encodable;
Expand Down Expand Up @@ -48,6 +53,10 @@ public final class CmsClassifier {
// cap the first primitive read when peeking the content-type OID (an OID is a few bytes)
private static final int OID_PEEK_LIMIT = 1 << 16;

private static final byte[][] PEM_PREFIXES = {
"-----BEGIN PKCS7".getBytes(StandardCharsets.US_ASCII),
"-----BEGIN CMS".getBytes(StandardCharsets.US_ASCII)};

static final MediaType PKCS7_MIME = MediaType.application("pkcs7-mime");
static final MediaType PKCS7_SIGNATURE = MediaType.application("pkcs7-signature");
static final MediaType SIGNED = smime("signed-data");
Expand All @@ -70,6 +79,12 @@ private static MediaType smime(String smimeType) {
* {@code null}), so a real I/O error is not silently masked as a non-crypto stream.
*/
public static MediaType classify(TikaInputStream tis) throws IOException {
try (TikaInputStream der = decodePem(tis)) {
return classifyDer(der != null ? der : tis);
}
}

private static MediaType classifyDer(TikaInputStream tis) throws IOException {
ASN1ObjectIdentifier oid = contentType(tis);
if (oid == null) {
return null;
Expand Down Expand Up @@ -113,6 +128,43 @@ static MediaType nonSignedType(ASN1ObjectIdentifier oid) {
return null;
}

/**
* A PEM-armored ({@code -----BEGIN PKCS7} / {@code -----BEGIN CMS}) stream decoded to DER, or
* {@code null} if the stream is not PEM CMS or its body does not decode. The caller closes the
* result; {@code tis} itself is spooled, not consumed.
*
* @throws IOException on a genuine spool failure of {@code tis}.
*/
static TikaInputStream decodePem(TikaInputStream tis) throws IOException {
if (tis == null || !startsWithPemPrefix(tis)) {
return null;
}
Path path = tis.getPath();
if (path == null) {
return null;
}
TikaInputStream der = TikaInputStream.get(
Base64.getMimeDecoder().wrap(new PemBody(new BufferedInputStream(Files.newInputStream(path)))));
try {
der.getPath(); // decode eagerly so malformed base64 reads as "not CMS", not an I/O error
return der;
} catch (IOException | IllegalArgumentException e) {
der.close();
return null;
}
}

private static boolean startsWithPemPrefix(TikaInputStream tis) throws IOException {
byte[] head = new byte[PEM_PREFIXES[0].length];
int n = tis.peek(head);
for (byte[] prefix : PEM_PREFIXES) {
if (n >= prefix.length && Arrays.equals(head, 0, prefix.length, prefix, 0, prefix.length)) {
return true;
}
}
return false;
}

/** Cheap DER-{@code SEQUENCE} gate: peek one byte (mark/reset, no spool). */
private static boolean looksLikeDerSequence(TikaInputStream tis) throws IOException {
byte[] head = new byte[1];
Expand Down Expand Up @@ -172,4 +224,70 @@ static MediaType refinedSignedType(CMSSignedDataParser parser, CMSTypedStream co
boolean hasCerts = !parser.getCertificates().getMatches(null).isEmpty();
return (hasCerts && !hasSigners) ? CERTS_ONLY : PKCS7_SIGNATURE;
}

/** The base64 body of a PEM block: skips the BEGIN line, ends at the {@code -----END} line. */
private static final class PemBody extends FilterInputStream {

private boolean inBody;
private boolean done;

PemBody(InputStream in) {
super(in);
}

@Override
public int read() throws IOException {
if (done) {
return -1;
}
int c;
while (!inBody) {
c = in.read();
if (c == -1) {
done = true;
return -1;
}
inBody = c == '\n';
}
c = in.read();
// '-' is outside the base64 alphabet, so it can only start the END line
if (c == -1 || c == '-') {
done = true;
return -1;
}
return c;
}

@Override
public int read(byte[] b, int off, int len) throws IOException {
int i = 0;
for (; i < len; i++) {
int c = read();
if (c == -1) {
break;
}
b[off + i] = (byte) c;
}
return i == 0 && len > 0 ? -1 : i;
}

@Override
public long skip(long n) throws IOException {
long i = 0;
while (i < n && read() != -1) {
i++;
}
return i;
}

@Override
public int available() {
return 0;
}

@Override
public boolean markSupported() {
return false;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -74,29 +74,34 @@ public Set<MediaType> getSupportedTypes(ParseContext context) {
public void parse(TikaInputStream tis, ContentHandler handler, Metadata metadata,
ParseContext context) throws IOException, SAXException, TikaException {
tis.setCloseShield();
try {
// route on the cheap ContentInfo OID; signedData is then parsed exactly once (in
// parseSignedData), which both labels the subtype and extracts the payload
ASN1ObjectIdentifier oid = CmsClassifier.contentType(tis);
if (PKCSObjectIdentifiers.signedData.equals(oid)) {
parseSignedData(tis, handler, metadata, context);
} else if (PKCSObjectIdentifiers.id_ct_compressedData.equals(oid)) {
metadata.set(HttpHeaders.CONTENT_TYPE, CmsClassifier.COMPRESSED.toString());
extractCompressedContent(tis, handler, metadata, context);
} else {
// enveloped / digested / encrypted (labelled; payload absent, encrypted, or
// unsupported) or unknown / non-CMS -> empty doc, no throw
MediaType type = CmsClassifier.nonSignedType(oid);
if (type != null) {
metadata.set(HttpHeaders.CONTENT_TYPE, type.toString());
}
emptyDocument(handler, metadata);
}
try (TikaInputStream der = CmsClassifier.decodePem(tis)) {
parseDer(der != null ? der : tis, handler, metadata, context);
} finally {
tis.removeCloseShield();
}
}

private void parseDer(TikaInputStream tis, ContentHandler handler, Metadata metadata,
ParseContext context) throws IOException, SAXException, TikaException {
// route on the cheap ContentInfo OID; signedData is then parsed exactly once (in
// parseSignedData), which both labels the subtype and extracts the payload
ASN1ObjectIdentifier oid = CmsClassifier.contentType(tis);
if (PKCSObjectIdentifiers.signedData.equals(oid)) {
parseSignedData(tis, handler, metadata, context);
} else if (PKCSObjectIdentifiers.id_ct_compressedData.equals(oid)) {
metadata.set(HttpHeaders.CONTENT_TYPE, CmsClassifier.COMPRESSED.toString());
extractCompressedContent(tis, handler, metadata, context);
} else {
// enveloped / digested / encrypted (labelled; payload absent, encrypted, or
// unsupported) or unknown / non-CMS -> empty doc, no throw
MediaType type = CmsClassifier.nonSignedType(oid);
if (type != null) {
metadata.set(HttpHeaders.CONTENT_TYPE, type.toString());
}
emptyDocument(handler, metadata);
}
}

/** Parse a signedData message once: set the refined subtype and extract the embedded payload if present. */
private void parseSignedData(TikaInputStream tis, ContentHandler handler, Metadata metadata,
ParseContext context)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
*/
package org.apache.tika.detect.crypto;

import static org.apache.tika.parser.crypto.PemFixtures.pem;
import static org.apache.tika.parser.crypto.PemFixtures.resource;
import static org.junit.jupiter.api.Assertions.assertEquals;

import org.junit.jupiter.api.Test;
Expand All @@ -31,12 +33,25 @@ public class Pkcs7DetectorTest {
private final Pkcs7Detector detector = new Pkcs7Detector();

private MediaType detect(String resource) throws Exception {
try (TikaInputStream tis = TikaInputStream.get(
Pkcs7DetectorTest.class.getResourceAsStream("/test-documents/" + resource))) {
return detect(resource(resource));
}

private MediaType detect(byte[] bytes) throws Exception {
try (TikaInputStream tis = TikaInputStream.get(bytes)) {
return detector.detect(tis, new Metadata(), new ParseContext());
}
}

@Test
public void testPemSubtypeAtDetectTime() throws Exception {
assertEquals(MediaType.parse("application/pkcs7-mime; smime-type=certs-only"),
detect(pem(resource("testPKCS7_certs_only_def.p7c"), "PKCS7", "\n")));
assertEquals(MediaType.parse("application/pkcs7-mime; smime-type=signed-data"),
detect(pem(resource("testPKCS7_signed_data_def.p7m"), "CMS", "\r\n")));
assertEquals(MediaType.parse("application/pkcs7-mime; smime-type=signed-data"),
detect("testPKCS7_signed_data_def.pem"));
}

@Test
public void testSubtypeAtDetectTime() throws Exception {
assertEquals(MediaType.parse("application/pkcs7-mime; smime-type=signed-data"),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.tika.parser.crypto;

import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.Base64;

/** Builds PEM-armored test inputs from the DER fixtures. */
public final class PemFixtures {

private PemFixtures() {
}

public static byte[] resource(String name) throws IOException {
try (InputStream is = PemFixtures.class.getResourceAsStream("/test-documents/" + name)) {
return is.readAllBytes();
}
}

/** {@code der} armored as {@code -----BEGIN <label>-----} with 64-column lines ending in {@code eol}. */
public static byte[] pem(byte[] der, String label, String eol) {
String body = Base64.getMimeEncoder(64, eol.getBytes(StandardCharsets.US_ASCII))
.encodeToString(der);
return ("-----BEGIN " + label + "-----" + eol + body + eol + "-----END " + label + "-----" + eol)
.getBytes(StandardCharsets.US_ASCII);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -52,5 +52,8 @@ public void testContentOnlyDetection() throws Exception {
assertEquals("application/pkcs7-signature", detect("testPKCS7_enveloped_def.p7m"));
// TIKA-2856: a short-form-length digestedData is now caught coarsely (routes to Pkcs7Parser)
assertEquals("application/pkcs7-signature", detect("testPKCS7_digested.p7"));
// TIKA-5002: `openssl cms -outform PEM` armor routes to Pkcs7Parser
assertEquals("application/pkcs7-signature", tika.detect(PemFixtures.pem(
PemFixtures.resource("testPKCS7_certs_only_def.p7c"), "CMS", "\n")));
}
}
Loading
Loading