diff --git a/pom.xml b/pom.xml
index 4e7b37f4..d5dfb3b7 100644
--- a/pom.xml
+++ b/pom.xml
@@ -97,6 +97,11 @@
${itext.version}
test
+
+ com.google.code.gson
+ gson
+ 2.9.0
+
diff --git a/src/main/java/com/itextpdf/research/autoupdate/AutoUpdater.java b/src/main/java/com/itextpdf/research/autoupdate/AutoUpdater.java
new file mode 100644
index 00000000..80ccb1a0
--- /dev/null
+++ b/src/main/java/com/itextpdf/research/autoupdate/AutoUpdater.java
@@ -0,0 +1,205 @@
+package com.itextpdf.research.autoupdate;
+
+import com.itextpdf.kernel.pdf.PdfArray;
+import com.itextpdf.kernel.pdf.PdfDictionary;
+import com.itextpdf.kernel.pdf.PdfName;
+import com.itextpdf.kernel.pdf.PdfString;
+import com.itextpdf.rups.model.LoggerHelper;
+import com.itextpdf.rups.model.PdfFile;
+
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.net.HttpURLConnection;
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardOpenOption;
+import java.security.GeneralSecurityException;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.security.SignatureException;
+import java.util.Base64;
+import java.util.Base64.Encoder;
+
+public class AutoUpdater {
+ private final PdfFile input;
+ private final OutputStream output;
+
+ private final PdfDictionary autoUpdateDict;
+
+ private byte[] fileContent;
+
+ public AutoUpdater(PdfFile input, OutputStream output) {
+ this.input = input;
+ this.output = output;
+ this.autoUpdateDict = input.getPdfDocument().getCatalog().getPdfObject()
+ .getAsDictionary(new PdfName("AutoUpdate"));
+ }
+
+ public boolean hasAutoUpdate() {
+ return autoUpdateDict != null;
+ }
+
+ private byte[] getFileContent() {
+ if (fileContent == null) {
+ fileContent = input.getBytes();
+ }
+ return fileContent;
+ }
+
+ private String getResourceIdentifier() {
+ PdfName addressMode = autoUpdateDict.getAsName(new PdfName("AddressMode"));
+ Encoder enc = Base64.getUrlEncoder();
+ if ("ContentDigest".equals(addressMode.getValue())) {
+ byte[] hash;
+ try {
+ MessageDigest md = MessageDigest.getInstance("SHA384");
+ md.update(getFileContent());
+ hash = md.digest();
+ } catch (NoSuchAlgorithmException e) {
+ throw new IllegalStateException(e);
+ }
+ return new String(enc.encode(hash), StandardCharsets.UTF_8);
+ } else if ("DocumentID".equals(addressMode.getValue())) {
+ PdfArray arr = input.getPdfDocument().getTrailer().getAsArray(PdfName.ID);
+ byte[] id1 = arr.getAsString(0).getValueBytes();
+ byte[] id2 = arr.getAsString(1).getValueBytes();
+
+ return String.format(
+ "%s/%s",
+ new String(enc.encode(id1), StandardCharsets.UTF_8),
+ new String(enc.encode(id2), StandardCharsets.UTF_8)
+ );
+ }
+ throw new IllegalStateException();
+ }
+
+ private URL getUpdateURL() throws MalformedURLException {
+ PdfString repo = autoUpdateDict.getAsString(new PdfName("Repository"));
+ PdfName addressMode = autoUpdateDict.getAsName(new PdfName("AddressMode"));
+ StringBuilder urlStr = new StringBuilder(repo.toUnicodeString());
+ if ("ContentDigest".equals(addressMode.getValue())) {
+ urlStr.append("/hash/");
+ } else if ("DocumentID".equals(addressMode.getValue())) {
+ urlStr.append("/docId/");
+ }
+ String resourceId = getResourceIdentifier();
+ urlStr.append(resourceId);
+ URL result = new URL(urlStr.toString());
+ LoggerHelper.info("Fetching update with ID " + resourceId + "; URL is " + result, AutoUpdater.class);
+ return result;
+ }
+
+ private static boolean nameValueIs(PdfDictionary dict, String key, String expectedValue) {
+ PdfName valueFound = dict.getAsName(new PdfName(key));
+ return valueFound != null && expectedValue.equals(valueFound.getValue());
+ }
+
+ public void downloadAndApplyUpdate() throws IOException, UpdateVerificationException {
+ // this method only implements a small part of the draft spec, but was written to
+ // somewhat realistically represent how one would ingest an update "for real", e.g.
+ // with potentially large update payloads, only exposing update content to the
+ // caller after verification passes, etc.
+
+ // TODO refactor
+
+ if (!nameValueIs(autoUpdateDict, "UpdateType", "Incremental")) {
+ throw new IOException("Only Incremental is supported in this PoC");
+ }
+
+ PdfDictionary integrity = autoUpdateDict.getAsDictionary(new PdfName("Integrity"));
+ PasetoV4PublicVerifier verifier;
+ if (integrity != null) {
+ boolean isPasetoV4Pub =
+ nameValueIs(integrity, "CertDataType", "PASETOV4Public");
+ PdfString pskStr = integrity.getAsString(new PdfName("PreSharedKey"));
+ if (!isPasetoV4Pub || pskStr == null) {
+ throw new UpdateVerificationException("Only PASETOV4Public with pre-shared keys is supported");
+ }
+ try {
+ verifier = new PasetoV4PublicVerifier(pskStr.getValueBytes());
+ } catch (GeneralSecurityException e) {
+ LoggerHelper.error(e.getMessage(), e, AutoUpdater.class);
+ throw new UpdateVerificationException("Key deser error", e);
+ }
+ } else {
+ verifier = null;
+ }
+
+ // stream the update content to disk (out of sight) while also feeding it to the verifier
+ Path tempFile = Files.createTempFile("pdfupdate", ".bin");
+ long contentLength = -1;
+ try (OutputStream tempOut = Files.newOutputStream(tempFile)) {
+ byte[] buf = new byte[2048];
+ HttpURLConnection conn = (HttpURLConnection) getUpdateURL().openConnection();
+
+ if (conn.getResponseCode() != 200) {
+ throw new IOException("Fetch failed; error " + conn.getResponseCode());
+ }
+ if (verifier != null) {
+ try {
+ contentLength = conn.getContentLengthLong();
+ if (contentLength == -1) {
+ throw new IOException("Content-Length must be present for this PoC");
+ }
+ verifier.init(conn.getHeaderField("X-PDF-Update-Token").getBytes(StandardCharsets.UTF_8), contentLength);
+ } catch (GeneralSecurityException e) {
+ LoggerHelper.error(e.getMessage(), e, AutoUpdater.class);
+ throw new UpdateVerificationException("Cryptographic failure", e);
+ }
+ }
+ InputStream is = conn.getInputStream();
+ int bytesRead;
+ while ((bytesRead = is.read(buf)) > 0) {
+ tempOut.write(buf, 0, bytesRead);
+ if (verifier != null) {
+ try {
+ verifier.updateImplicit(buf, 0, bytesRead);
+ } catch (GeneralSecurityException e) {
+ LoggerHelper.error(e.getMessage(), e, AutoUpdater.class);
+ throw new UpdateVerificationException("Cryptographic failure", e);
+ }
+ }
+ }
+ }
+
+ if (verifier != null) {
+ byte[] payload;
+ try {
+ payload = verifier.verifyAndGetPayload();
+ } catch (SignatureException e) {
+ LoggerHelper.error(e.getMessage(), e, AutoUpdater.class);
+ throw new UpdateVerificationException("Cryptographic failure", e);
+ }
+ // verify the token contents
+ // TODO do this with a schema and null-safe queries
+ JsonObject el = JsonParser
+ .parseString(new String(payload, StandardCharsets.UTF_8))
+ .getAsJsonObject();
+ // TODO check updateType, protocol version
+ if (!getResourceIdentifier().equals(el.getAsJsonPrimitive("resourceId").getAsString())) {
+ throw new UpdateVerificationException("Resource ID mismatch");
+ }
+ if (el.getAsJsonPrimitive("updateLength").getAsNumber().longValue()
+ != contentLength) {
+ throw new UpdateVerificationException("Length mismatch");
+ }
+
+ }
+
+ // all clear -> proceed to output
+ output.write(getFileContent());
+ try (InputStream tempIn = Files.newInputStream(tempFile, StandardOpenOption.DELETE_ON_CLOSE)) {
+ byte[] buf = new byte[2048];
+ int bytesRead;
+ while ((bytesRead = tempIn.read(buf)) > 0) {
+ output.write(buf, 0, bytesRead);
+ }
+ }
+ }
+}
diff --git a/src/main/java/com/itextpdf/research/autoupdate/PasetoV4PublicVerifier.java b/src/main/java/com/itextpdf/research/autoupdate/PasetoV4PublicVerifier.java
new file mode 100644
index 00000000..2c686d79
--- /dev/null
+++ b/src/main/java/com/itextpdf/research/autoupdate/PasetoV4PublicVerifier.java
@@ -0,0 +1,113 @@
+package com.itextpdf.research.autoupdate;
+
+import java.nio.charset.StandardCharsets;
+import java.security.GeneralSecurityException;
+import java.security.KeyFactory;
+import java.security.NoSuchAlgorithmException;
+import java.security.PublicKey;
+import java.security.Signature;
+import java.security.SignatureException;
+import java.security.spec.InvalidKeySpecException;
+import java.security.spec.X509EncodedKeySpec;
+import java.util.Base64;
+
+public class PasetoV4PublicVerifier {
+
+ public static final int SIGNATURE_LENGTH = 64;
+ private final PublicKey publicKey;
+
+ private long implicitLength;
+ private long implicitLengthSoFar = 0;
+ private Signature sig;
+
+ byte[] decodedPayload;
+
+ public PasetoV4PublicVerifier(PublicKey publicKey) {
+ this.publicKey = publicKey;
+ }
+
+ public PasetoV4PublicVerifier(byte[] pubKeyBytes)
+ throws NoSuchAlgorithmException, InvalidKeySpecException {
+ this(KeyFactory.getInstance("Ed25519")
+ .generatePublic(new X509EncodedKeySpec(pubKeyBytes)));
+ }
+
+ public void init(byte[] token, long implicitLength)
+ throws UpdateVerificationException, GeneralSecurityException {
+ if (sig != null) {
+ throw new IllegalStateException();
+ }
+ String tokenStr = new String(token, StandardCharsets.US_ASCII);
+ String[] parts = tokenStr.split("\\.", 4);
+ if (parts.length != 3) {
+ throw new UpdateVerificationException("Expected 3-part no-footer token");
+ }
+
+ if (!"v4".equals(parts[0]) || !"public".equals(parts[1])) {
+ throw new UpdateVerificationException("Expected v4.public token");
+ }
+
+ this.decodedPayload = Base64.getUrlDecoder().decode(parts[2]);
+ int messageLength = decodedPayload.length - SIGNATURE_LENGTH;
+
+ this.sig = Signature.getInstance("Ed25519");
+ this.sig.initVerify(this.publicKey);
+ // we need to feed the verifier 4 items of PAE data
+ this.sig.update(le64(4));
+
+ //header
+ byte[] h = "v4.public.".getBytes(StandardCharsets.US_ASCII);
+ this.sig.update(le64(h.length));
+ this.sig.update(h);
+
+ // message
+ this.sig.update(le64(messageLength));
+ this.sig.update(decodedPayload, 0, messageLength);
+
+ // footer (empty)
+ this.sig.update(le64(0));
+
+ // implicit data
+ sig.update(le64(implicitLength));
+ // the rest is by streaming
+ this.implicitLength = implicitLength;
+ }
+
+ public void updateImplicit(byte[] data, int off, int len) throws SignatureException {
+ if (this.implicitLengthSoFar + len > this.implicitLength) {
+ throw new IllegalStateException("Too much input");
+ }
+ sig.update(data, off, len);
+ this.implicitLengthSoFar += len;
+ }
+
+ public byte[] verifyAndGetPayload() throws SignatureException, UpdateVerificationException {
+ int messageLength = this.decodedPayload.length - SIGNATURE_LENGTH;
+ if (this.implicitLengthSoFar != this.implicitLength) {
+ String msg = String.format(
+ "Expected %d bytes of input, but got %d.",
+ this.implicitLength,
+ this.implicitLengthSoFar);
+ throw new UpdateVerificationException(msg);
+ }
+ if (!sig.verify(this.decodedPayload, messageLength, SIGNATURE_LENGTH)) {
+ throw new UpdateVerificationException("Invalid signature");
+ }
+ byte[] message = new byte[messageLength];
+ System.arraycopy(this.decodedPayload, 0, message, 0, messageLength);
+ return message;
+ }
+
+ private static byte[] le64(long l) {
+ return new byte[] {
+ (byte) l,
+ (byte) (l >>> 8),
+ (byte) (l >>> 16),
+ (byte) (l >>> 24),
+ (byte) (l >>> 32),
+ (byte) (l >>> 40),
+ (byte) (l >>> 48),
+ (byte) ((l >>> 56) & 0x7f) // clear msb
+ };
+ }
+}
diff --git a/src/main/java/com/itextpdf/research/autoupdate/UpdateVerificationException.java b/src/main/java/com/itextpdf/research/autoupdate/UpdateVerificationException.java
new file mode 100644
index 00000000..14e5e61c
--- /dev/null
+++ b/src/main/java/com/itextpdf/research/autoupdate/UpdateVerificationException.java
@@ -0,0 +1,11 @@
+package com.itextpdf.research.autoupdate;
+
+public class UpdateVerificationException extends Exception {
+ public UpdateVerificationException(String message) {
+ super(message);
+ }
+
+ public UpdateVerificationException(String message, Throwable cause) {
+ super(message, cause);
+ }
+}
diff --git a/src/main/java/com/itextpdf/rups/model/PdfFile.java b/src/main/java/com/itextpdf/rups/model/PdfFile.java
index c4cc6b8a..7d3cac6a 100644
--- a/src/main/java/com/itextpdf/rups/model/PdfFile.java
+++ b/src/main/java/com/itextpdf/rups/model/PdfFile.java
@@ -226,6 +226,12 @@ public File getDirectory() {
return directory;
}
+ public byte[] getBytes() {
+ byte[] b = new byte[rawContent.length];
+ System.arraycopy(rawContent, 0, b, 0, b.length);
+ return b;
+ }
+
public String getRawContent() {
try {
return new String(rawContent, "Cp1252");
diff --git a/src/main/java/com/itextpdf/rups/view/RupsMenuBar.java b/src/main/java/com/itextpdf/rups/view/RupsMenuBar.java
index fe87b95f..c65faa4e 100644
--- a/src/main/java/com/itextpdf/rups/view/RupsMenuBar.java
+++ b/src/main/java/com/itextpdf/rups/view/RupsMenuBar.java
@@ -43,6 +43,7 @@ This file is part of the iText (R) project.
package com.itextpdf.rups.view;
import com.itextpdf.kernel.actions.data.ITextCoreProductData;
+import com.itextpdf.research.autoupdate.AutoUpdater;
import com.itextpdf.rups.controller.RupsController;
import com.itextpdf.rups.event.RupsEvent;
import com.itextpdf.rups.io.FileCloseAction;
@@ -51,9 +52,14 @@ This file is part of the iText (R) project.
import com.itextpdf.rups.io.FileSaveAction;
import com.itextpdf.rups.io.OpenInViewerAction;
import com.itextpdf.rups.io.filters.PdfFilter;
+import com.itextpdf.rups.model.LoggerHelper;
import java.awt.event.ActionListener;
import java.awt.event.InputEvent;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
import java.util.HashMap;
import java.util.Observable;
import java.util.Observer;
@@ -61,6 +67,7 @@ This file is part of the iText (R) project.
import javax.swing.JMenu;
import javax.swing.JMenuBar;
import javax.swing.JMenuItem;
+import javax.swing.JOptionPane;
import javax.swing.KeyStroke;
public class RupsMenuBar extends JMenuBar implements Observer {
@@ -129,6 +136,26 @@ public RupsMenuBar(RupsController controller) {
preferencesWindow.show(controller.getMasterComponent());
}
);
+ addItem(edit, "AutoUpdate", e -> {
+ try {
+ Path out = Files.createTempFile("pdfupdate-out", ".pdf");
+ try (OutputStream os = Files.newOutputStream(out)) {
+ AutoUpdater au = new AutoUpdater(controller.getCurrentFile(), os);
+ if (au.hasAutoUpdate()) {
+ au.downloadAndApplyUpdate();
+ LoggerHelper.info("Update written to " + out, RupsMenuBar.class);
+ controller.openNewFile(out.toFile());
+ } else {
+ JOptionPane.showMessageDialog(getParent(),
+ "This is not an updatable document",
+ "AutoUpdate error", JOptionPane.ERROR_MESSAGE);
+ Files.delete(out);
+ }
+ }
+ } catch (Exception ex) {
+ JOptionPane.showMessageDialog(getParent(), ex.getMessage(), "AutoUpdate error", JOptionPane.ERROR_MESSAGE);
+ }
+ });
add(edit);
add(Box.createGlue());