From a8345a4032189c1f17669535462ea51b3e1695ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andrzej=20Wo=CC=81jcik?= Date: Sun, 19 Nov 2023 16:56:08 +0100 Subject: [PATCH] Improved implementation of CAPTCHA for in-band registration #1510 --- .../tigase/xmpp/impl/CaptchaProvider.java | 117 ++++++++++++++++-- .../tigase/xmpp/impl/JabberIqRegister.java | 13 +- 2 files changed, 119 insertions(+), 11 deletions(-) diff --git a/src/main/java/tigase/xmpp/impl/CaptchaProvider.java b/src/main/java/tigase/xmpp/impl/CaptchaProvider.java index bf440e8c7..69ab36aa8 100644 --- a/src/main/java/tigase/xmpp/impl/CaptchaProvider.java +++ b/src/main/java/tigase/xmpp/impl/CaptchaProvider.java @@ -18,9 +18,19 @@ package tigase.xmpp.impl; import tigase.kernel.beans.Bean; +import tigase.util.Base64; import tigase.xmpp.XMPPResourceConnection; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; import java.security.SecureRandom; +import java.time.Duration; +import java.util.Arrays; +import java.util.Optional; import java.util.Random; @Bean(name = "CaptchaProvider", parent = JabberIqRegister.class, active = true) @@ -28,12 +38,33 @@ public class CaptchaProvider { private Random random = new SecureRandom(); - public CaptchaItem generateCaptcha() { - return new SimpleTextCaptcha(1 + random.nextInt(31), 1 + random.nextInt(31)); + public CaptchaItem generateCaptcha(XMPPResourceConnection connection) { + return new SimpleTextCaptcha(random, connection); + } + + public CaptchaItem getCaptchaByID(String id) { + if (id == null) { + return null; + } + + try { + String[] parts = id.split("\\."); + String type = parts[0]; + if (!"simple-text".equals(type)) { + return null; + } + + return new SimpleTextCaptcha(parts); + } catch (Throwable ex) { + // could not parse captcha data + return null; + } } public interface CaptchaItem { + String getID(); + String getCaptchaRequest(XMPPResourceConnection session); int getErrorCounter(); @@ -47,14 +78,70 @@ public interface CaptchaItem { private class SimpleTextCaptcha implements CaptchaItem { + private static final Duration TIMEOUT = Duration.ofMinutes(5); + private static final int NONCE_LENGTH = 16; + private final int a; private final int b; + private final int result; + private final byte[] nonce; + private final long time; + private final byte[] hmac; private int errorCounter; + private final Duration timeout = TIMEOUT; + + public static byte[] calculateHMac(byte[] data, String key) throws NoSuchAlgorithmException, InvalidKeyException { + SecretKeySpec secretKeySpec = new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), "HmacSHA256"); + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(secretKeySpec); + return mac.doFinal(data); + } + + public static String getSecret(XMPPResourceConnection connection) { + return Optional.ofNullable(connection.getDomain().getS2sSecret()) + .orElse(connection.getDomainAsJID().toString()); + } + + SimpleTextCaptcha(Random random, XMPPResourceConnection connection) { + this.a = 1 + random.nextInt(31); + this.b = 1 + random.nextInt(31); + this.result = a + b; + this.nonce = new byte[NONCE_LENGTH]; + random.nextBytes(this.nonce); + this.time = System.currentTimeMillis(); + + try { + this.hmac = calculateHMac((getPrefix() + "." + result).getBytes(StandardCharsets.UTF_8), + getSecret(connection)); + } catch (Throwable ex) { + throw new RuntimeException("Could not generate HMAC", ex); + } + } + + SimpleTextCaptcha(String[] parts) { + this.nonce = new byte[NONCE_LENGTH]; + ByteBuffer tmp = ByteBuffer.wrap(Base64.decode(parts[1])); + tmp.get(nonce); + this.a = tmp.getInt(); + this.b = tmp.getInt(); + this.time = tmp.getLong(); + this.result = a + b; + this.hmac = Base64.decode(parts[2]); + } - SimpleTextCaptcha(int a, int b) { - this.a = a; - this.b = b; + @Override + public String getID() { + return getPrefix() + "." + Base64.encode(hmac); + } + + protected String getPrefix() { + ByteBuffer tmp = ByteBuffer.allocate(nonce.length + 4 + 4 + 8); + tmp.put(nonce); + tmp.putInt(a); + tmp.putInt(b); + tmp.putLong(time); + return "simple-text" + "." + Base64.encode(tmp.array()); } @Override @@ -77,9 +164,25 @@ public boolean isResponseValid(XMPPResourceConnection session, String response) if (response == null) { return false; } - final int v = a + b; - return response.trim().equals(String.valueOf(v)); + try { + String responseResult = response.trim(); + // check if response matches + if (!String.valueOf(this.result).equals(responseResult)) { + return false; + } + // check if it is not expired + if (time > System.currentTimeMillis() || (System.currentTimeMillis() - time) > timeout.toMillis()) { + return false; + } + // then check if anyone tampered with token + byte[] calculated = calculateHMac((getPrefix() + "." + responseResult).getBytes(StandardCharsets.UTF_8), + getSecret(session)); + return Arrays.equals(hmac, calculated); + } catch (Throwable ex) { + return false; + } } + } } diff --git a/src/main/java/tigase/xmpp/impl/JabberIqRegister.java b/src/main/java/tigase/xmpp/impl/JabberIqRegister.java index da705e7ef..fc7652d8e 100644 --- a/src/main/java/tigase/xmpp/impl/JabberIqRegister.java +++ b/src/main/java/tigase/xmpp/impl/JabberIqRegister.java @@ -398,7 +398,7 @@ public Element[] supDiscoFeatures(XMPPResourceConnection session) { if (log.isLoggable(Level.FINEST) && (session != null)) { log.finest("VHostItem: " + session.getDomain()); } - if ((session != null) && session.getDomain().isRegisterEnabled()) { + if ((session != null) && ((!session.isTlsRequired()) || session.isEncrypted()) && session.getDomain().isRegisterEnabled()) { return DISCO_FEATURES; } else { return null; @@ -691,7 +691,9 @@ private void validateSignedForm(Packet packet, XMPPResourceConnection session, F private void validatCapchaForm(XMPPResourceConnection session, Form form) throws XMPPProcessorException { CaptchaItem captcha = (CaptchaItem) session.getSessionData("jabber:iq:register:captcha"); - + if (captcha == null) { + captcha = captchaProvider.getCaptchaByID(form.getAsString("captcha-id")); + } if (captcha == null) { log.finest("CAPTCHA is required"); throw new XMPPProcessorException(Authorization.BAD_REQUEST, @@ -718,9 +720,12 @@ private Element prepareCaptchaRegistrationForm(final XMPPResourceConnection sess query.addChild(new Element("instructions", (emailRequired ? INSTRUCTION_EMAIL_REQUIRED_DEF : INSTRUCTION_DEF))); Form form = prepareGenericRegistrationForm(); - CaptchaItem captcha = captchaProvider.generateCaptcha(); + CaptchaItem captcha = captchaProvider.generateCaptcha(session); session.putSessionData("jabber:iq:register:captcha", captcha); - Field field = Field.fieldTextSingle("captcha", "", captcha.getCaptchaRequest(session)); + Field field = Field.fieldHidden("captcha-id", captcha.getID()); + field.setRequired(true); + form.addField(field); + field = Field.fieldTextSingle("captcha", "", captcha.getCaptchaRequest(session)); field.setRequired(true); form.addField(field);