检测在签名之间完成的已签名 PDF 的更改

Detect Changes on Signed PDF that was done between signatures

我正在开发一个应验证 pdf 文件签名的应用程序。在应用每个签名之前,应用程序应检测对文件内容所做更新的完整历史记录。 例如:

  1. 签名者 1 签署了普通 pdf 文件
  2. 签名者 2 在签名文件中添加了评论,然后签名

应用程序如何检测到签名者 2 在他的签名前添加了评论。

我尝试过使用 itext 和 pdfbox

正如在 中所解释的那样,iText 和 PDFBox 都没有带来高级 API 告诉您在 [=104] 方面增量更新发生了什么变化=] 对象(评论,文本内容,...)。

您可以使用它们将 PDF 的不同修订版渲染为位图并比较这些图像。

或者你可以用它们告诉你低级 COS 对象(字典、数组、数字、字符串...)方面的变化。

但是分析这些图像或低级别对象的变化并根据 UI 对象确定它们的含义,例如一条评论,而且只添加了一条评论,非常重要。

你问了

Can you explain more, how can I detect changes in low level COS objects.

要比较什么以及要考虑什么变化

首先,您必须清楚可以比较哪些文档状态以检测更改。

PDF 格式允许在所谓的增量更新中将更改附加到 PDF。这允许更改签名文档而不用密码破坏这些签名,因为原始签名字节保持原样:

虽然中间可以有更多的增量更新,但这些更新没有签名;例如“版本 2 的更改”可能包括多个增量更新。

人们可能会考虑比较由任意增量更新创建的修订。但是,这里的问题是,您无法识别在没有签名的情况下应用增量更新的人。

因此,通常只比较已签名的修订版并让每个签名者对自上次已签名修订版以来的所有更改负责通常更有意义。这里唯一的例外是整个文件,作为 PDF 的当前版本,即使没有覆盖所有文件的签名也特别有趣。

接下来你必须决定你认为改变的是什么。特别是:

  • 增量更新中的每个对象覆盖都是更改吗?甚至那些用相同副本覆盖原始对象的对象?

  • 让直接宾语变间接(反之亦然)但保持所有内容和引用不变的更改怎么样?

  • 添加未在标准结构中的任何位置引用的新对象怎么办?

  • 如何添加未从交叉引用流或表中引用的对象?

  • 添加完全不遵循 PDF 语法的数据怎么办?

如果您确实也对此类更改感兴趣,现成的现成 PDF 库通常不会为您提供确定它们的方法;您很可能至少必须更改他们的代码以遍历交叉引用链 tables/streams,甚至直接分析更新中的文件字节。

不过,如果您对此类更改不感兴趣,通常无需更改或替换库例程。

由于在符合规范的 PDF 处理器处理 PDF 时,列举的和类似的更改没有任何区别,因此通常可以忽略此类更改。

如果这也是您的立场,以下示例工具可能会给您一个起点。

基于 iText 7 的示例工具

由于上述限制,您可以使用 iText 7 比较 PDF 的签名修订,而无需更改库,方法是将要比较的修订加载到单独的 PdfDocument 实例中,并递归比较从预告片开始的 PDF 对象.

我曾经把它实现为个人使用的一个小辅助工具(所以它还没有完全完成,更多的工作正在进行中)。首先是允许比较两个任意文档的基础 class:

public class PdfCompare {
    public static void main(String[] args) throws IOException {
        System.out.printf("Comparing:\n* %s\n* %s\n", args[0], args[1]);
        try (   PdfDocument pdfDocument1 = new PdfDocument(new PdfReader(args[0]));
                PdfDocument pdfDocument2 = new PdfDocument(new PdfReader(args[1]))  ) {
            PdfCompare pdfCompare = new PdfCompare(pdfDocument1, pdfDocument2);
            pdfCompare.compare();

            List<Difference> differences = pdfCompare.getDifferences();
            if (differences == null || differences.isEmpty()) {
                System.out.println("No differences found.");
            } else {
                System.out.printf("%d differences found:\n", differences.size());
                for (Difference difference : pdfCompare.getDifferences()) {
                    for (String element : difference.getPath()) {
                        System.out.print(element);
                    }
                    System.out.printf(" - %s\n", difference.getDescription());
                }
            }
        }
    }

    public interface Difference {
        List<String> getPath();
        String getDescription();
    }

    public PdfCompare(PdfDocument pdfDocument1, PdfDocument pdfDocument2) {
        trailer1 = pdfDocument1.getTrailer();
        trailer2 = pdfDocument2.getTrailer();
    }

    public void compare() {
        LOGGER.info("Starting comparison");
        try {
            compared.clear();
            differences.clear();
            LOGGER.info("START COMPARE");
            compare(trailer1, trailer2, Collections.singletonList("trailer"));
            LOGGER.info("START SHORTEN PATHS");
            shortenPaths();
        } finally {
            LOGGER.info("Finished comparison and shortening");
        }
    }

    public List<Difference> getDifferences() {
        return differences;
    }

    class DifferenceImplSimple implements Difference {
        DifferenceImplSimple(PdfObject object1, PdfObject object2, List<String> path, String description) {
            this.pair = Pair.of(object1, object2);
            this.path = path;
            this.description = description;
        }

        @Override
        public List<String> getPath() {
            List<String> byPair = getShortestPath(pair);
            return byPair != null ? byPair : shorten(path);
        }
        @Override public String getDescription()    { return description;           }

        final Pair<PdfObject, PdfObject> pair;
        final List<String> path;
        final String description;
    }

    void compare(PdfObject object1, PdfObject object2, List<String> path) {
        LOGGER.debug("Comparing objects at {}.", path);
        if (object1 == null && object2 == null)
        {
            LOGGER.debug("Both objects are null at {}.", path);
            return;
        }
        if (object1 == null) {
            differences.add(new DifferenceImplSimple(object1, object2, path, "Missing in document 1"));
            LOGGER.info("Object in document 1 is missing at {}.", path);
            return;
        }
        if (object2 == null) {
            differences.add(new DifferenceImplSimple(object1, object2, path, "Missing in document 2"));
            LOGGER.info("Object in document 2 is missing at {}.", path);
            return;
        }

        if (object1.getType() != object2.getType()) {
            differences.add(new DifferenceImplSimple(object1, object2, path,
                    String.format("Type difference, %s in document 1 and %s in document 2",
                            getTypeName(object1.getType()), getTypeName(object2.getType()))));
            LOGGER.info("Objects have different types at {}, {} and {}.", path, getTypeName(object1.getType()), getTypeName(object2.getType()));
            return;
        }

        switch (object1.getType()) {
        case PdfObject.ARRAY:
            compareContents((PdfArray) object1, (PdfArray) object2, path);
            break;
        case PdfObject.DICTIONARY:
            compareContents((PdfDictionary) object1, (PdfDictionary) object2, path);
            break;
        case PdfObject.STREAM:
            compareContents((PdfStream)object1, (PdfStream)object2, path);
            break;
        case PdfObject.BOOLEAN:
        case PdfObject.INDIRECT_REFERENCE:
        case PdfObject.LITERAL:
        case PdfObject.NAME:
        case PdfObject.NULL:
        case PdfObject.NUMBER:
        case PdfObject.STRING:
            compareContentsSimple(object1, object2, path);
            break;
        default:
            differences.add(new DifferenceImplSimple(object1, object2, path, "Unknown object type " + object1.getType() + "; cannot compare"));
            LOGGER.warn("Unknown object type at {}, {}.", path, object1.getType());
            break;
        }
    }

    void compareContents(PdfArray array1, PdfArray array2, List<String> path) {
        int count1 = array1.size();
        int count2 = array2.size();
        if (count1 < count2) {
            differences.add(new DifferenceImplSimple(array1, array2, path, "Document 1 misses " + (count2-count1) + " array entries"));
            LOGGER.info("Array in document 1 is missing {} entries at {} for {}.", (count2-count1), path);
        }
        if (count1 > count2) {
            differences.add(new DifferenceImplSimple(array1, array2, path, "Document 2 misses " + (count1-count2) + " array entries"));
            LOGGER.info("Array in document 2 is missing {} entries at {} for {}.", (count1-count2), path);
        }

        if (alreadyCompared(array1, array2, path)) {
            return;
        }

        int count = Math.min(count1, count2);
        for (int i = 0; i < count; i++) {
            compare(array1.get(i), array2.get(i), join(path, String.format("[%d]", i)));
        }
    }

    void compareContents(PdfDictionary dictionary1, PdfDictionary dictionary2, List<String> path) {
        List<PdfName> missing1 = new ArrayList<PdfName>(dictionary2.keySet());
        missing1.removeAll(dictionary1.keySet());
        if (!missing1.isEmpty()) {
            differences.add(new DifferenceImplSimple(dictionary1, dictionary2, path, "Document 1 misses dictionary entries for " + missing1));
            LOGGER.info("Dictionary in document 1 is missing entries at {} for {}.", path, missing1);
        }

        List<PdfName> missing2 = new ArrayList<PdfName>(dictionary1.keySet());
        missing2.removeAll(dictionary2.keySet());
        if (!missing2.isEmpty()) {
            differences.add(new DifferenceImplSimple(dictionary1, dictionary2, path, "Document 2 misses dictionary entries for " + missing2));
            LOGGER.info("Dictionary in document 2 is missing entries at {} for {}.", path, missing2);
        }

        if (alreadyCompared(dictionary1, dictionary2, path)) {
            return;
        }

        List<PdfName> common = new ArrayList<PdfName>(dictionary1.keySet());
        common.retainAll(dictionary2.keySet());
        for (PdfName name : common) {
            compare(dictionary1.get(name), dictionary2.get(name), join(path, name.toString()));
        }
    }

    void compareContents(PdfStream stream1, PdfStream stream2, List<String> path) {
        compareContents((PdfDictionary)stream1, (PdfDictionary)stream2, path);

        byte[] bytes1 = stream1.getBytes();
        byte[] bytes2 = stream2.getBytes();
        if (!Arrays.equals(bytes1, bytes2)) {
            differences.add(new DifferenceImplSimple(stream1, stream2, path, "Stream contents differ"));
            LOGGER.info("Stream contents differ at {}.", path);
        }
    }

    void compareContentsSimple(PdfObject object1, PdfObject object2, List<String> path) {
        // vvv--- work-around for DEVSIX-4931, likely to be fixed in 7.1.15
        if (object1 instanceof PdfNumber)
            ((PdfNumber)object1).getValue();
        if (object2 instanceof PdfNumber)
            ((PdfNumber)object2).getValue();
        // ^^^--- work-around for DEVSIX-4931, likely to be fixed in 7.1.15
        if (!object1.equals(object2)) {
            if (object1 instanceof PdfString) {
                String string1 = object1.toString();
                if (string1.length() > 40)
                    string1 = string1.substring(0, 40) + '\u22EF';
                string1 = sanitize(string1);
                String string2 = object2.toString();
                if (string2.length() > 40)
                    string2 = string2.substring(0, 40) + '\u22EF';
                string2 = sanitize(string2);
                differences.add(new DifferenceImplSimple(object1, object2, path, String.format("String values differ, '%s' and '%s'", string1, string2)));
                LOGGER.info("String values differ at {}, '{}' and '{}'.", path, string1, string2);
            } else {
                differences.add(new DifferenceImplSimple(object1, object2, path, String.format("Object values differ, '%s' and '%s'", object1, object2)));
                LOGGER.info("Object values differ at {}, '{}' and '{}'.", path, object1, object2);
            }
        }
    }

    String sanitize(CharSequence string) {
        char[] sanitized = new char[string.length()];
        for (int i = 0; i < sanitized.length; i++) {
            char c = string.charAt(i);
            if (c >= 0 && c < ' ')
                c = '\uFFFD';
            sanitized[i] = c;
        }
        return new String(sanitized);
    }

    String getTypeName(byte type) {
        switch (type) {
        case PdfObject.ARRAY:               return "ARRAY";
        case PdfObject.BOOLEAN:             return "BOOLEAN";
        case PdfObject.DICTIONARY:          return "DICTIONARY";
        case PdfObject.LITERAL:             return "LITERAL";
        case PdfObject.INDIRECT_REFERENCE:  return "REFERENCE";
        case PdfObject.NAME:                return "NAME";
        case PdfObject.NULL:                return "NULL";
        case PdfObject.NUMBER:              return "NUMBER";
        case PdfObject.STREAM:              return "STREAM";
        case PdfObject.STRING:              return "STRING";
        default:
            return "UNKNOWN";
        }
    }

    List<String> join(List<String> path, String element) {
        String[] array = path.toArray(new String[path.size() + 1]);
        array[array.length-1] = element;
        return Arrays.asList(array);
    }

    boolean alreadyCompared(PdfObject object1, PdfObject object2, List<String> path) {
        Pair<PdfObject, PdfObject> pair = Pair.of(object1, object2);
        if (compared.containsKey(pair)) {
            //LOGGER.debug("Objects already compared at {}, previously at {}.", path, compared.get(pair));
            Set<List<String>> paths = compared.get(pair);
            boolean alreadyPresent = false;
//            List<List<String>> toRemove = new ArrayList<>();
//            for (List<String> formerPath : paths) {
//                for (int i = 0; ; i++) {
//                    if (i == path.size()) {
//                        toRemove.add(formerPath);
//                        System.out.print('.');
//                        break;
//                    }
//                    if (i == formerPath.size()) {
//                        alreadyPresent = true;
//                        System.out.print(':');
//                        break;
//                    }
//                    if (!path.get(i).equals(formerPath.get(i)))
//                        break;
//                }
//            }
//            paths.removeAll(toRemove);
            if (!alreadyPresent)
                paths.add(path);
            return true;
        }
        compared.put(pair, new HashSet<>(Collections.singleton(path)));
        return false;
    }

    List<String> getShortestPath(Pair<PdfObject, PdfObject> pair) {
        Set<List<String>> paths = compared.get(pair);
        //return (paths == null) ? null : Collections.min(paths, pathComparator);
        return (paths == null || paths.isEmpty()) ? null : shortened.get(paths.stream().findFirst().get());
    }

    void shortenPaths() {
        List<Map<List<String>, SortedSet<List<String>>>> data = new ArrayList<>();
        for (Set<List<String>> set : compared.values()) {
            SortedSet<List<String>> sortedSet = new TreeSet<List<String>>(pathComparator);
            sortedSet.addAll(set);
            for (List<String> path : sortedSet) {
                while (path.size() >= data.size()) {
                    data.add(new HashMap<>());
                }
                SortedSet<List<String>> former = data.get(path.size()).put(path, sortedSet);
                if (former != null) {
                    LOGGER.error("Path not well-defined for {}", path);
                }
            }
        }
        for (int pathSize = 3; pathSize < data.size(); pathSize++) {
            for (Map.Entry<List<String>, SortedSet<List<String>>> pathEntry : data.get(pathSize).entrySet()) {
                List<String> path = pathEntry.getKey();
                SortedSet<List<String>> equivalents = pathEntry.getValue();
                for (int subpathSize = 2; subpathSize < pathSize; subpathSize++) {
                    List<String> subpath = path.subList(0, subpathSize);
                    List<String> remainder = path.subList(subpathSize, pathSize); 
                    SortedSet<List<String>> subequivalents = data.get(subpathSize).get(subpath);
                    if (subequivalents != null && subequivalents.size() > 1) {
                        List<String> subequivalent = subequivalents.first();
                        if (subequivalent.size() < subpathSize) {
                            List<String> replacement = join(subequivalent, remainder);
                            if (equivalents.add(replacement)) {
                                data.get(replacement.size()).put(replacement, equivalents);
                            }
                        }
                    }
                }
            }
        }

        shortened.clear();
        for (Map<List<String>, SortedSet<List<String>>> singleLengthData : data) {
            for (Map.Entry<List<String>, SortedSet<List<String>>> entry : singleLengthData.entrySet()) {
                List<String> path = entry.getKey();
                List<String> shortenedPath = entry.getValue().first();
                shortened.put(path, shortenedPath);
            }
        }
    }

    List<String> join(List<String> path, List<String> elements) {
        String[] array = path.toArray(new String[path.size() + elements.size()]);
        for (int i = 0; i < elements.size(); i++) {
            array[path.size() + i] = elements.get(i);
        }
        return Arrays.asList(array);
    }

    List<String> shorten(List<String> path) {
        List<String> shortPath = path;
        for (int subpathSize = path.size(); subpathSize > 2; subpathSize--) {
            List<String> subpath = path.subList(0, subpathSize);
            List<String> shortSubpath = shortened.get(subpath);
            if (shortSubpath != null && shortSubpath.size() < subpathSize) {
                List<String> remainder = path.subList(subpathSize, path.size());
                List<String> replacement = join(shortSubpath, remainder);
                if (replacement.size() < shortPath.size())
                    shortPath = replacement;
            }
        }
        return shortPath;
    }

    final static Logger LOGGER = LoggerFactory.getLogger(PdfCompare.class);
    final PdfDictionary trailer1;
    final PdfDictionary trailer2;
    final Map<Pair<PdfObject, PdfObject>, Set<List<String>>> compared = new HashMap<>();
    final List<Difference> differences = new ArrayList<>();
    final Map<List<String>, List<String>> shortened = new HashMap<>();
    final static Comparator<List<String>> pathComparator = new Comparator<List<String>>() {
        @Override
        public int compare(List<String> o1, List<String> o2) {
            int compare = Integer.compare(o1.size(), o2.size());
            if (compare != 0)
                return compare;
            for (int i = 0; i < o1.size(); i++) {
                compare = o1.get(i).compareTo(o2.get(i));
                if (compare != 0)
                    return compare;
            }
            return 0;
        }
    };
}

(PdfCompare.java)

使用此代码进行修订比较的工具是其子class:

public class PdfRevisionCompare extends PdfCompare {
    public static void main(String[] args) throws IOException {
        for (String arg : args) {
            System.out.printf("\nComparing revisions of: %s\n***********************\n", args[0]);
            try (PdfDocument pdfDocument = new PdfDocument(new PdfReader(arg))) {
                SignatureUtil signatureUtil = new SignatureUtil(pdfDocument);
                List<String> signatureNames = signatureUtil.getSignatureNames();
                if (signatureNames.isEmpty()) {
                    System.out.println("No signed revisions detected. (no AcroForm)");
                    continue;
                }
                String previousRevision = signatureNames.get(0);
                PdfDocument previousDocument = new PdfDocument(new PdfReader(signatureUtil.extractRevision(previousRevision)));
                System.out.printf("* Initial signed revision: %s\n", previousRevision);
                for (int i = 1; i < signatureNames.size(); i++) {
                    String currentRevision = signatureNames.get(i);
                    PdfDocument currentDocument = new PdfDocument(new PdfReader(signatureUtil.extractRevision(currentRevision)));
                    showDifferences(previousDocument, currentDocument);
                    System.out.printf("* Next signed revision (%d): %s\n", i+1, currentRevision);
                    previousDocument.close();
                    previousDocument = currentDocument;
                    previousRevision = currentRevision;
                }
                if (signatureUtil.signatureCoversWholeDocument(previousRevision)) {
                    System.out.println("No unsigned updates.");
                } else {
                    showDifferences(previousDocument, pdfDocument);
                    System.out.println("* Final unsigned revision");
                }
                previousDocument.close();
            }
        }
    }

    static void showDifferences(PdfDocument previousDocument, PdfDocument currentDocument) {
        PdfRevisionCompare pdfRevisionCompare = new PdfRevisionCompare(previousDocument, currentDocument);
        pdfRevisionCompare.compare();
        List<Difference> differences = pdfRevisionCompare.getDifferences();
        if (differences == null || differences.isEmpty()) {
            System.out.println("No differences found.");
        } else {
            System.out.printf("%d differences found:\n", differences.size());
            for (Difference difference : differences) {
                for (String element : difference.getPath()) {
                    System.out.print(element);
                }
                System.out.printf(" - %s\n", difference.getDescription());
            }
        }
    }

    public PdfRevisionCompare(PdfDocument pdfDocument1, PdfDocument pdfDocument2) {
        super(pdfDocument1, pdfDocument2);
    }
}

(PdfRevisionCompare.java)