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());