From 4269338d2e11028a880c99eb906c93a397fd0c1f Mon Sep 17 00:00:00 2001 From: Jakob Heher Date: Wed, 5 Oct 2022 11:39:07 +0200 Subject: FIDO2 support once again --- .../asit/pdfover/gui/bku/MobileBKUConnector.java | 120 ++++++++++++++++++--- .../pdfover/gui/bku/OLDmobile/ATrustHandler.java | 15 +-- .../asit/pdfover/gui/bku/mobile/ATrustParser.java | 90 +++++++++------- 3 files changed, 159 insertions(+), 66 deletions(-) (limited to 'pdf-over-gui/src/main/java/at/asit/pdfover/gui/bku') diff --git a/pdf-over-gui/src/main/java/at/asit/pdfover/gui/bku/MobileBKUConnector.java b/pdf-over-gui/src/main/java/at/asit/pdfover/gui/bku/MobileBKUConnector.java index 14971ce1..1c07376c 100644 --- a/pdf-over-gui/src/main/java/at/asit/pdfover/gui/bku/MobileBKUConnector.java +++ b/pdf-over-gui/src/main/java/at/asit/pdfover/gui/bku/MobileBKUConnector.java @@ -5,12 +5,16 @@ import java.net.URI; import java.net.URISyntaxException; import java.nio.charset.Charset; import java.util.ArrayList; +import java.util.Base64; import java.util.List; import java.util.Optional; import java.util.regex.Matcher; import java.util.regex.Pattern; +import javax.annotation.CheckForNull; import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import javax.swing.text.html.HTML.Tag; import org.apache.hc.client5.http.classic.methods.HttpGet; import org.apache.hc.client5.http.classic.methods.HttpPost; @@ -20,6 +24,7 @@ import org.apache.hc.client5.http.entity.mime.MultipartEntityBuilder; import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; import org.apache.hc.client5.http.impl.classic.CloseableHttpResponse; import org.apache.hc.client5.http.impl.classic.HttpClients; +import org.apache.hc.client5.http.impl.classic.RequestFailedException; import org.apache.hc.core5.http.ClassicHttpRequest; import org.apache.hc.core5.http.ContentType; import org.apache.hc.core5.http.Header; @@ -46,6 +51,10 @@ import at.asit.pdfover.signer.BkuSlConnector; import at.asit.pdfover.signer.SignatureException; import at.asit.pdfover.signer.UserCancelledException; import at.asit.pdfover.signer.pdfas.PdfAs4SLRequest; +import at.asit.webauthn.PublicKeyCredentialRequestOptions; +import at.asit.webauthn.WebAuthN; +import at.asit.webauthn.exceptions.WebAuthNOperationFailed; +import at.asit.webauthn.exceptions.WebAuthNUserCancelled; import static at.asit.pdfover.commons.Constants.ISNOTNULL; @@ -80,6 +89,9 @@ public class MobileBKUConnector implements BkuSlConnector { } } + /* some anti-infinite-loop safeguards so we don't murder the atrust servers by accident */ + private int loopHTTPRequestCounter = 0; + private Long lastHTTPRequestTime = null; /** * Sends the specified request, following redirects (including meta-tag redirects) recursively * @return The JSOUP document retrieved @@ -89,6 +101,16 @@ public class MobileBKUConnector implements BkuSlConnector { * @throws InterruptedException */ private @Nonnull ATrustParser.Result sendHTTPRequest(CloseableHttpClient httpClient, ClassicHttpRequest request) throws IOException, ProtocolException, URISyntaxException { + long now = System.nanoTime(); + if ((lastHTTPRequestTime != null) && ((now - lastHTTPRequestTime) < 2e+9)) { /* less than 2s since last request */ + ++loopHTTPRequestCounter; + if (loopHTTPRequestCounter > 250) + throw new IOException("Infinite loop protection triggered"); + } else { + loopHTTPRequestCounter = 0; + } + lastHTTPRequestTime = now; + log.debug("Sending request to '{}'...", request.getUri().toString()); try (final CloseableHttpResponse response = httpClient.execute(request)) { int httpStatus = response.getCode(); @@ -176,17 +198,30 @@ public class MobileBKUConnector implements BkuSlConnector { return post; } - private static @Nonnull ClassicHttpRequest buildFormSubmit(@Nonnull ATrustParser.HTMLResult html, @Nonnull String submitButtonId) { + private static @Nonnull ClassicHttpRequest buildFormSubmit(@Nonnull ATrustParser.HTMLResult html, @CheckForNull String submitButton) { HttpPost post = new HttpPost(html.formTarget); var builder = MultipartEntityBuilder.create(); for (var pair : html.iterateFormOptions()) builder.addTextBody(pair.getKey(), pair.getValue()); - if (html.submitButtons.containsKey(submitButtonId)) { - var submitInfo = html.submitButtons.get(submitButtonId); - builder.addTextBody(submitInfo.name, submitInfo.value); - } else { - log.warn("Attempted to use submit button #{} which does not exist. Omitting.", submitButtonId); + + if (submitButton != null) { + var submitButtonElm = html.htmlDocument.selectFirst(submitButton); + if (submitButtonElm != null) { + if ("input".equalsIgnoreCase(submitButtonElm.tagName())) { + if ("submit".equalsIgnoreCase(submitButtonElm.attr("type"))) { + String name = submitButtonElm.attr("name"); + if (!name.isEmpty()) + builder.addTextBody(name, submitButtonElm.attr("value")); + } else { + log.warn("Skipped specified submitButton {}, type is {} (not submit)", submitButton, submitButtonElm.attr("type")); + } + } else { + log.warn("Skipped specified submitButton {}, tag name is {} (not input)", submitButton, submitButtonElm.tagName()); + } + } else { + log.warn("Skipped specified submitButton {}, element not found", submitButton); + } } post.setEntity(builder.build()); @@ -198,26 +233,41 @@ public class MobileBKUConnector implements BkuSlConnector { * @return the next request to make, or null if the current response should be returned */ private @Nonnull ClassicHttpRequest presentResponseToUserAndReturnNextRequest(@Nonnull ATrustParser.HTMLResult html) throws UserCancelledException { + if (html.errorBlock != null) { + try { + this.credentials.password = null; + this.state.clearRememberedPassword(); + + if (html.errorBlock.isRecoverable) + this.state.showRecoverableError(html.errorBlock.errorText); + else + this.state.showUnrecoverableError(html.errorBlock.errorText); + return buildFormSubmit(html, "#Button_Back"); + } catch (UserCancelledException e) { + return buildFormSubmit(html, "#Button_Cancel"); + } + } if (html.usernamePasswordBlock != null) { try { while ((this.credentials.username == null) || (this.credentials.password == null)) { this.state.getCredentialsFromUserTo(this.credentials, null); // TODO error message } html.usernamePasswordBlock.setUsernamePassword(this.credentials.username, this.credentials.password); - return buildFormSubmit(html, "Button_Identification"); + return buildFormSubmit(html, "#Button_Identification"); } catch (UserCancelledException e) { - return buildFormSubmit(html, "Button_Cancel"); + return buildFormSubmit(html, "#Button_Cancel"); } } if (html.qrCodeBlock != null) { try (final CloseableHttpClient httpClient = HttpClients.custom().disableRedirectHandling().build()) { - final HttpGet request = new HttpGet(html.qrCodeBlock.qrCodeURI); + final HttpGet request = new HttpGet(html.qrCodeBlock.pollingURI); boolean[] done = new boolean[1]; done[0] = false; Thread longPollThread = new Thread(() -> { + long timeout = System.nanoTime() + (300l * 1000l * 1000l * 1000l); /* a-trust timeout is 5 minutes */ log.debug("longPollThread hello"); while (!done[0]) { - try (final CloseableHttpResponse response = httpClient.execute(new HttpGet(html.qrCodeBlock.pollingURI))) { + try (final CloseableHttpResponse response = httpClient.execute(request)) { JSONObject jsonResponse = new JSONObject(EntityUtils.toString(response.getEntity())); if (jsonResponse.getBoolean("Fin")) state.signalQRScanned(); @@ -233,8 +283,11 @@ public class MobileBKUConnector implements BkuSlConnector { break; } } catch (NoHttpResponseException e) { - continue; + if (timeout <= System.nanoTime()) + state.signalQRScanned(); /* reload to find the timeout error */ + continue; /* httpclient timeout */ } catch (IOException | ParseException | IllegalStateException e) { + if (done[0]) break; log.warn("QR code long polling exception", e); /* sleep so we don't hammer a-trust too hard in case this goes wrong */ try { Thread.sleep(5000); } catch (InterruptedException e2) {} @@ -244,19 +297,54 @@ public class MobileBKUConnector implements BkuSlConnector { }); try { longPollThread.start(); - MobileBKUState.QRResult result = this.state.showQRCode(html.qrCodeBlock.referenceValue, html.qrCodeBlock.qrCodeURI, null); + MobileBKUState.QRResult result = this.state.showQRCode(html.qrCodeBlock.referenceValue, html.qrCodeBlock.qrCodeURI, html.signatureDataLink, html.smsTanLink != null, html.fido2Link != null, null); switch (result) { - case UPDATE: return new HttpGet(html.htmlDocument.baseUri()); - case TO_FIDO2: throw new IllegalStateException(); - case TO_SMS: throw new IllegalStateException(); + case UPDATE: break; + case TO_FIDO2: if (html.fido2Link != null) return new HttpGet(html.fido2Link); break; + case TO_SMS: if (html.smsTanLink != null) return new HttpGet(html.smsTanLink); break; } + return new HttpGet(html.htmlDocument.baseUri()); } finally { done[0] = true; request.abort(); try { longPollThread.join(1000); } catch (InterruptedException e) {} } } catch (IOException e) { - log.warn("closing CloseableHttpClient threw exception", e); + log.warn("closing long-polling HttpClient threw exception", e); + } + } + if (html.fido2Block != null) { + // TODO composite for this + if (WebAuthN.isAvailable()) { + try { + var fido2Assertion = PublicKeyCredentialRequestOptions.FromJSONString(html.fido2Block.fidoOptions).get("https://service.a-trust.at"); + + Base64.Encoder base64 = Base64.getEncoder(); + + JSONObject aTrustAssertion = new JSONObject(); + aTrustAssertion.put("id", fido2Assertion.id); + aTrustAssertion.put("rawId", base64.encodeToString(fido2Assertion.rawId)); + aTrustAssertion.put("type", fido2Assertion.type); + aTrustAssertion.put("extensions", new JSONObject()); // TODO fix extensions in library + + JSONObject aTrustAssertionResponse = new JSONObject(); + aTrustAssertion.put("response", aTrustAssertionResponse); + aTrustAssertionResponse.put("authenticatorData", base64.encodeToString(fido2Assertion.response.authenticatorData)); + aTrustAssertionResponse.put("clientDataJson", base64.encodeToString(fido2Assertion.response.clientDataJSON)); + aTrustAssertionResponse.put("signature", base64.encodeToString(fido2Assertion.response.signature)); + if (fido2Assertion.response.userHandle != null) + aTrustAssertionResponse.put("userHandle", base64.encodeToString(fido2Assertion.response.userHandle)); + else + aTrustAssertionResponse.put("userHandle", JSONObject.NULL); + + html.fido2Block.setFIDOResult(aTrustAssertion.toString()); + return buildFormSubmit(html, "#FidoContinue"); + } catch (WebAuthNUserCancelled e) { + log.debug("WebAuthN authentication cancelled by user"); + throw new UserCancelledException(); + } catch (WebAuthNOperationFailed e) { + log.warn("WebAuthN authentication failed", e); + } } } throw new IllegalStateException("No top-level block is set? Something has gone terribly wrong."); diff --git a/pdf-over-gui/src/main/java/at/asit/pdfover/gui/bku/OLDmobile/ATrustHandler.java b/pdf-over-gui/src/main/java/at/asit/pdfover/gui/bku/OLDmobile/ATrustHandler.java index 2e69e779..ab85645b 100644 --- a/pdf-over-gui/src/main/java/at/asit/pdfover/gui/bku/OLDmobile/ATrustHandler.java +++ b/pdf-over-gui/src/main/java/at/asit/pdfover/gui/bku/OLDmobile/ATrustHandler.java @@ -57,6 +57,7 @@ import at.asit.pdfover.gui.controls.Dialog.BUTTONS; import at.asit.pdfover.gui.controls.Dialog.ICON; import at.asit.pdfover.gui.exceptions.ATrustConnectionException; import at.asit.pdfover.gui.utils.FileUploadSource; +import at.asit.pdfover.gui.utils.SWTUtils; import at.asit.pdfover.commons.Messages; import at.asit.pdfover.gui.workflow.states.LocalBKUState; import at.asit.pdfover.gui.workflow.states.MobileBKUState; @@ -399,25 +400,13 @@ public class ATrustHandler { status.errorMessage = null; - final Document responseDocument = Jsoup.parse(responseData); - if (responseData.contains("ExpiresInfo.aspx?sid=")) { // Certificate expiration interstitial - skip if (!expiryNoticeDisplayed) { Display.getDefault().syncExec(()-> { Dialog d = new Dialog(ATrustHandler.this.shell, Messages.getString("common.info"), Messages.getString("mobileBKU.certExpiresSoon"), BUTTONS.YES_NO, ICON.WARNING); if (d.open() == SWT.YES) { - log.debug("Trying to open " + ACTIVATION_URL); - if (Desktop.isDesktopSupported()) { - try { - Desktop.getDesktop().browse(new URI(ACTIVATION_URL)); - return; - } catch (Exception e) { - log.debug("Error opening URL", e); - } - } - log.info("SWT Desktop is not supported on this platform"); - Program.launch(ACTIVATION_URL); + SWTUtils.openURL(ACTIVATION_URL); } }); expiryNoticeDisplayed = true; diff --git a/pdf-over-gui/src/main/java/at/asit/pdfover/gui/bku/mobile/ATrustParser.java b/pdf-over-gui/src/main/java/at/asit/pdfover/gui/bku/mobile/ATrustParser.java index 890ffad1..16f571a3 100644 --- a/pdf-over-gui/src/main/java/at/asit/pdfover/gui/bku/mobile/ATrustParser.java +++ b/pdf-over-gui/src/main/java/at/asit/pdfover/gui/bku/mobile/ATrustParser.java @@ -24,8 +24,8 @@ public class ATrustParser { private static class ComponentParseFailed extends Exception {} private static class TopLevelFormBlock { - protected @Nonnull org.jsoup.nodes.Document htmlDocument; - protected @Nonnull Map formOptions; + protected final @Nonnull org.jsoup.nodes.Document htmlDocument; + protected final @Nonnull Map formOptions; protected TopLevelFormBlock(@Nonnull org.jsoup.nodes.Document d, @Nonnull Map fO) { this.htmlDocument = d; this.formOptions = fO; } protected void abortIfElementMissing(@Nonnull String selector) throws ComponentParseFailed { @@ -56,30 +56,49 @@ public class ATrustParser { } } + public static class ErrorBlock extends TopLevelFormBlock { + public final boolean isRecoverable; + public final @Nonnull String errorText; + + private ErrorBlock(@Nonnull org.jsoup.nodes.Document htmlDocument, @Nonnull URI formTarget, @Nonnull Map formOptions) throws ComponentParseFailed { + super(htmlDocument, formOptions); + if (!formTarget.getPath().contains("/error.aspx")) + throw new ComponentParseFailed(); + + this.isRecoverable = (htmlDocument.selectFirst("#Button_Back") != null); + + String errorText = getElementEnsureNotNull("#Label1").ownText(); + if (errorText.startsWith("Fehler:")) + errorText = errorText.substring(7); + this.errorText = ISNOTNULL(errorText.trim()); + } + } + public static class UsernamePasswordBlock extends TopLevelFormBlock { - private @Nonnull String usernameKey; - private @Nonnull String passwordKey; - public @CheckForNull String errorMessage; + private final @Nonnull String usernameKey; + private final @Nonnull String passwordKey; + public final @CheckForNull String errorMessage; public void setUsernamePassword(String username, String password) { formOptions.put(usernameKey, username); formOptions.put(passwordKey, password); } - private UsernamePasswordBlock(@Nonnull org.jsoup.nodes.Document htmlDocument, @Nonnull Map formOptions) throws ComponentParseFailed { + private UsernamePasswordBlock(@Nonnull org.jsoup.nodes.Document htmlDocument, @Nonnull URI formTarget, @Nonnull Map formOptions) throws ComponentParseFailed { super(htmlDocument, formOptions); abortIfElementMissing("#handynummer"); this.usernameKey = getAttributeEnsureNotNull("#handynummer", "name"); this.passwordKey = getAttributeEnsureNotNull("#signaturpasswort", "name"); + this.errorMessage = null; } } public static class QRCodeBlock extends TopLevelFormBlock { - public @Nonnull String referenceValue; - public @Nonnull URI qrCodeURI; - public @Nonnull URI pollingURI; - public @Nullable String errorMessage; + public final @Nonnull String referenceValue; + public final @Nonnull URI qrCodeURI; + public final @Nonnull URI pollingURI; + public final @Nullable String errorMessage; - private QRCodeBlock(@Nonnull org.jsoup.nodes.Document htmlDocument, @Nonnull Map formOptions) throws ComponentParseFailed { + private QRCodeBlock(@Nonnull org.jsoup.nodes.Document htmlDocument, @Nonnull URI formTarget, @Nonnull Map formOptions) throws ComponentParseFailed { super(htmlDocument, formOptions); abortIfElementMissing("#qrimage"); @@ -102,17 +121,18 @@ public class ATrustParser { log.warn("URI '{}' could not be parsed", pollingUriString); throw new ComponentParseFailed(); } + + this.errorMessage = null; } } public static class Fido2Block extends TopLevelFormBlock { - private @Nonnull String fidoOptions; - private @Nonnull String credentialResultKey; + public final @Nonnull String fidoOptions; + private final @Nonnull String credentialResultKey; - public @Nonnull String getFIDOOptions() { return fidoOptions; } public void setFIDOResult(String result) { formOptions.put(credentialResultKey, result); } - private Fido2Block(@Nonnull org.jsoup.nodes.Document htmlDocument, @Nonnull Map formOptions) throws ComponentParseFailed { + private Fido2Block(@Nonnull org.jsoup.nodes.Document htmlDocument, @Nonnull URI formTarget, @Nonnull Map formOptions) throws ComponentParseFailed { super(htmlDocument, formOptions); abortIfElementMissing("#fidoBlock"); this.fidoOptions = getAttributeEnsureNotNull("#credentialOptions", "value"); @@ -125,22 +145,15 @@ public class ATrustParser { public final @Nonnull URI formTarget; public final @Nonnull Map formOptions = new HashMap<>(); - public static class NameValuePair { - public final @Nonnull String name; - public final @Nonnull String value; - public NameValuePair(@Nonnull String n, @Nonnull String v) { name = n; value = v; } - } - /** - * map: id -> (name, value) - */ - public final @Nonnull Map submitButtons = new HashMap<>(); - public @Nonnull Iterable> iterateFormOptions() { return ISNOTNULL(formOptions.entrySet()); } - /* optional mode switch links (any number may or may not be null) */ + /* optional links (any number may or may not be null) */ + public final @CheckForNull URI signatureDataLink; + public final @CheckForNull URI smsTanLink; public final @CheckForNull URI fido2Link; /* top-level blocks (exactly one is not null) */ + public final @CheckForNull ErrorBlock errorBlock; public final @CheckForNull UsernamePasswordBlock usernamePasswordBlock; public final @CheckForNull QRCodeBlock qrCodeBlock; public final @CheckForNull Fido2Block fido2Block; @@ -148,6 +161,7 @@ public class ATrustParser { private void validate() { Set populated = new HashSet<>(); + if (errorBlock != null) populated.add("errorBlock"); if (usernamePasswordBlock != null) populated.add("usernamePasswordBlock"); if (qrCodeBlock != null) populated.add("qrCodeBlock"); if (fido2Block != null) populated.add("fido2Block"); @@ -178,7 +192,7 @@ public class ATrustParser { */ private @Nullable T TryParseMainBlock(Class clazz) { try { - return clazz.getDeclaredConstructor(org.jsoup.nodes.Document.class, Map.class).newInstance(this.htmlDocument, this.formOptions); + return clazz.getDeclaredConstructor(org.jsoup.nodes.Document.class, URI.class, Map.class).newInstance(this.htmlDocument, this.formTarget, this.formOptions); } catch (NoSuchMethodException | InstantiationException | IllegalAccessException | IllegalArgumentException | SecurityException e) { log.error("Internal parser error; check your method signatures?", e); return null; @@ -194,6 +208,7 @@ public class ATrustParser { } private HTMLResult(@Nonnull org.jsoup.nodes.Document htmlDocument) { + log.trace("Now parsing:\n{}", htmlDocument.toString()); this.htmlDocument = htmlDocument; var forms = htmlDocument.getElementsByTag("form"); @@ -214,20 +229,21 @@ public class ATrustParser { for (var input : mainForm.select("input")) { String name = input.attr("name"); - /* special handling for submit inputs, they only get sent if they are "clicked" */ - if ("submit".equals(input.attr("type"))) { - if (name.isEmpty()) - this.submitButtons.put(input.attr("id"), null); - else - this.submitButtons.put(input.attr("id"), new NameValuePair(name, ISNOTNULL(input.attr("value")))); - } else { - if (!name.isEmpty()) - this.formOptions.put(name, input.attr("value")); - } + if (name.isEmpty()) + continue; + + /* submit inputs omitted here, they only get sent if they are "clicked", cf. MobileBKUConnector::buildFormSubmit */ + if ("submit".equalsIgnoreCase(input.attr("type"))) + continue; + + this.formOptions.put(name, input.attr("value")); } + this.signatureDataLink = getHrefIfExists("#LinkList a[href*=\"ShowSigobj.aspx\"]"); /* grr, they didn't give it an ID */ + this.smsTanLink = getHrefIfExists("#SmsButton"); this.fido2Link = getHrefIfExists("#FidoButton"); + this.errorBlock = TryParseMainBlock(ErrorBlock.class); this.usernamePasswordBlock = TryParseMainBlock(UsernamePasswordBlock.class); this.qrCodeBlock = TryParseMainBlock(QRCodeBlock.class); this.fido2Block = TryParseMainBlock(Fido2Block.class); -- cgit v1.2.3