implement SCRAM-SHA512

This commit is contained in:
Daniel Gultsch 2020-12-31 09:32:05 +01:00
parent 2a57c92f63
commit 0e54d8a2cf
11 changed files with 352 additions and 296 deletions

View file

@ -7,22 +7,24 @@ import eu.siacs.conversations.xml.TagWriter;
public class Anonymous extends SaslMechanism { public class Anonymous extends SaslMechanism {
public Anonymous(TagWriter tagWriter, Account account, SecureRandom rng) { public static final String MECHANISM = "ANONYMOUS";
super(tagWriter, account, rng);
}
@Override public Anonymous(TagWriter tagWriter, Account account, SecureRandom rng) {
public int getPriority() { super(tagWriter, account, rng);
return 0; }
}
@Override @Override
public String getMechanism() { public int getPriority() {
return "ANONYMOUS"; return 0;
} }
@Override @Override
public String getClientFirstMessage() { public String getMechanism() {
return ""; return MECHANISM;
} }
@Override
public String getClientFirstMessage() {
return "";
}
} }

View file

@ -12,79 +12,82 @@ import eu.siacs.conversations.utils.CryptoHelper;
import eu.siacs.conversations.xml.TagWriter; import eu.siacs.conversations.xml.TagWriter;
public class DigestMd5 extends SaslMechanism { public class DigestMd5 extends SaslMechanism {
public DigestMd5(final TagWriter tagWriter, final Account account, final SecureRandom rng) {
super(tagWriter, account, rng);
}
@Override public static final String MECHANISM = "DIGEST-MD5";
public int getPriority() {
return 10;
}
@Override public DigestMd5(final TagWriter tagWriter, final Account account, final SecureRandom rng) {
public String getMechanism() { super(tagWriter, account, rng);
return "DIGEST-MD5"; }
}
private State state = State.INITIAL; @Override
public int getPriority() {
return 10;
}
@Override @Override
public String getResponse(final String challenge) throws AuthenticationException { public String getMechanism() {
switch (state) { return MECHANISM;
case INITIAL: }
state = State.RESPONSE_SENT;
final String encodedResponse;
try {
final Tokenizer tokenizer = new Tokenizer(Base64.decode(challenge, Base64.DEFAULT));
String nonce = "";
for (final String token : tokenizer) {
final String[] parts = token.split("=", 2);
if (parts[0].equals("nonce")) {
nonce = parts[1].replace("\"", "");
} else if (parts[0].equals("rspauth")) {
return "";
}
}
final String digestUri = "xmpp/" + account.getServer();
final String nonceCount = "00000001";
final String x = account.getUsername() + ":" + account.getServer() + ":"
+ account.getPassword();
final MessageDigest md = MessageDigest.getInstance("MD5");
final byte[] y = md.digest(x.getBytes(Charset.defaultCharset()));
final String cNonce = CryptoHelper.random(100,rng);
final byte[] a1 = CryptoHelper.concatenateByteArrays(y,
(":" + nonce + ":" + cNonce).getBytes(Charset.defaultCharset()));
final String a2 = "AUTHENTICATE:" + digestUri;
final String ha1 = CryptoHelper.bytesToHex(md.digest(a1));
final String ha2 = CryptoHelper.bytesToHex(md.digest(a2.getBytes(Charset
.defaultCharset())));
final String kd = ha1 + ":" + nonce + ":" + nonceCount + ":" + cNonce
+ ":auth:" + ha2;
final String response = CryptoHelper.bytesToHex(md.digest(kd.getBytes(Charset
.defaultCharset())));
final String saslString = "username=\"" + account.getUsername()
+ "\",realm=\"" + account.getServer() + "\",nonce=\""
+ nonce + "\",cnonce=\"" + cNonce + "\",nc=" + nonceCount
+ ",qop=auth,digest-uri=\"" + digestUri + "\",response="
+ response + ",charset=utf-8";
encodedResponse = Base64.encodeToString(
saslString.getBytes(Charset.defaultCharset()),
Base64.NO_WRAP);
} catch (final NoSuchAlgorithmException e) {
throw new AuthenticationException(e);
}
return encodedResponse; private State state = State.INITIAL;
case RESPONSE_SENT:
state = State.VALID_SERVER_RESPONSE; @Override
break; public String getResponse(final String challenge) throws AuthenticationException {
case VALID_SERVER_RESPONSE: switch (state) {
if (challenge==null) { case INITIAL:
return null; //everything is fine state = State.RESPONSE_SENT;
} final String encodedResponse;
default: try {
throw new InvalidStateException(state); final Tokenizer tokenizer = new Tokenizer(Base64.decode(challenge, Base64.DEFAULT));
} String nonce = "";
return null; for (final String token : tokenizer) {
} final String[] parts = token.split("=", 2);
if (parts[0].equals("nonce")) {
nonce = parts[1].replace("\"", "");
} else if (parts[0].equals("rspauth")) {
return "";
}
}
final String digestUri = "xmpp/" + account.getServer();
final String nonceCount = "00000001";
final String x = account.getUsername() + ":" + account.getServer() + ":"
+ account.getPassword();
final MessageDigest md = MessageDigest.getInstance("MD5");
final byte[] y = md.digest(x.getBytes(Charset.defaultCharset()));
final String cNonce = CryptoHelper.random(100, rng);
final byte[] a1 = CryptoHelper.concatenateByteArrays(y,
(":" + nonce + ":" + cNonce).getBytes(Charset.defaultCharset()));
final String a2 = "AUTHENTICATE:" + digestUri;
final String ha1 = CryptoHelper.bytesToHex(md.digest(a1));
final String ha2 = CryptoHelper.bytesToHex(md.digest(a2.getBytes(Charset
.defaultCharset())));
final String kd = ha1 + ":" + nonce + ":" + nonceCount + ":" + cNonce
+ ":auth:" + ha2;
final String response = CryptoHelper.bytesToHex(md.digest(kd.getBytes(Charset
.defaultCharset())));
final String saslString = "username=\"" + account.getUsername()
+ "\",realm=\"" + account.getServer() + "\",nonce=\""
+ nonce + "\",cnonce=\"" + cNonce + "\",nc=" + nonceCount
+ ",qop=auth,digest-uri=\"" + digestUri + "\",response="
+ response + ",charset=utf-8";
encodedResponse = Base64.encodeToString(
saslString.getBytes(Charset.defaultCharset()),
Base64.NO_WRAP);
} catch (final NoSuchAlgorithmException e) {
throw new AuthenticationException(e);
}
return encodedResponse;
case RESPONSE_SENT:
state = State.VALID_SERVER_RESPONSE;
break;
case VALID_SERVER_RESPONSE:
if (challenge == null) {
return null; //everything is fine
}
default:
throw new InvalidStateException(state);
}
return null;
}
} }

View file

@ -1,6 +1,7 @@
package eu.siacs.conversations.crypto.sasl; package eu.siacs.conversations.crypto.sasl;
import android.util.Base64; import android.util.Base64;
import java.security.SecureRandom; import java.security.SecureRandom;
import eu.siacs.conversations.entities.Account; import eu.siacs.conversations.entities.Account;
@ -8,22 +9,24 @@ import eu.siacs.conversations.xml.TagWriter;
public class External extends SaslMechanism { public class External extends SaslMechanism {
public External(TagWriter tagWriter, Account account, SecureRandom rng) { public static final String MECHANISM = "EXTERNAL";
super(tagWriter, account, rng);
}
@Override public External(TagWriter tagWriter, Account account, SecureRandom rng) {
public int getPriority() { super(tagWriter, account, rng);
return 25; }
}
@Override @Override
public String getMechanism() { public int getPriority() {
return "EXTERNAL"; return 25;
} }
@Override @Override
public String getClientFirstMessage() { public String getMechanism() {
return Base64.encodeToString(account.getJid().asBareJid().toEscapedString().getBytes(),Base64.NO_WRAP); return MECHANISM;
} }
@Override
public String getClientFirstMessage() {
return Base64.encodeToString(account.getJid().asBareJid().toEscapedString().getBytes(), Base64.NO_WRAP);
}
} }

View file

@ -8,27 +8,30 @@ import eu.siacs.conversations.entities.Account;
import eu.siacs.conversations.xml.TagWriter; import eu.siacs.conversations.xml.TagWriter;
public class Plain extends SaslMechanism { public class Plain extends SaslMechanism {
public Plain(final TagWriter tagWriter, final Account account) {
super(tagWriter, account, null);
}
@Override public static final String MECHANISM = "PLAIN";
public int getPriority() {
return 10;
}
@Override public Plain(final TagWriter tagWriter, final Account account) {
public String getMechanism() { super(tagWriter, account, null);
return "PLAIN"; }
}
@Override @Override
public String getClientFirstMessage() { public int getPriority() {
return getMessage(account.getUsername(), account.getPassword()); return 10;
} }
public static String getMessage(String username, String password) { @Override
final String message = '\u0000' + username + '\u0000' + password; public String getMechanism() {
return Base64.encodeToString(message.getBytes(Charset.defaultCharset()), Base64.NO_WRAP); return MECHANISM;
} }
@Override
public String getClientFirstMessage() {
return getMessage(account.getUsername(), account.getPassword());
}
public static String getMessage(String username, String password) {
final String message = '\u0000' + username + '\u0000' + password;
return Base64.encodeToString(message.getBytes(Charset.defaultCharset()), Base64.NO_WRAP);
}
} }

View file

@ -7,60 +7,63 @@ import eu.siacs.conversations.xml.TagWriter;
public abstract class SaslMechanism { public abstract class SaslMechanism {
final protected TagWriter tagWriter; final protected TagWriter tagWriter;
final protected Account account; final protected Account account;
final protected SecureRandom rng; final protected SecureRandom rng;
protected enum State { protected enum State {
INITIAL, INITIAL,
AUTH_TEXT_SENT, AUTH_TEXT_SENT,
RESPONSE_SENT, RESPONSE_SENT,
VALID_SERVER_RESPONSE, VALID_SERVER_RESPONSE,
} }
public static class AuthenticationException extends Exception { public static class AuthenticationException extends Exception {
public AuthenticationException(final String message) { public AuthenticationException(final String message) {
super(message); super(message);
} }
public AuthenticationException(final Exception inner) { public AuthenticationException(final Exception inner) {
super(inner); super(inner);
} }
public AuthenticationException(final String message, final Exception exception) { public AuthenticationException(final String message, final Exception exception) {
super(message,exception); super(message, exception);
} }
} }
public static class InvalidStateException extends AuthenticationException { public static class InvalidStateException extends AuthenticationException {
public InvalidStateException(final String message) { public InvalidStateException(final String message) {
super(message); super(message);
} }
public InvalidStateException(final State state) { public InvalidStateException(final State state) {
this("Invalid state: " + state.toString()); this("Invalid state: " + state.toString());
} }
} }
public SaslMechanism(final TagWriter tagWriter, final Account account, final SecureRandom rng) { public SaslMechanism(final TagWriter tagWriter, final Account account, final SecureRandom rng) {
this.tagWriter = tagWriter; this.tagWriter = tagWriter;
this.account = account; this.account = account;
this.rng = rng; this.rng = rng;
} }
/** /**
* The priority is used to pin the authentication mechanism. If authentication fails, it MAY be retried with another * The priority is used to pin the authentication mechanism. If authentication fails, it MAY be retried with another
* mechanism of the same priority, but MUST NOT be tried with a mechanism of lower priority (to prevent downgrade * mechanism of the same priority, but MUST NOT be tried with a mechanism of lower priority (to prevent downgrade
* attacks). * attacks).
* @return An arbitrary int representing the priority *
*/ * @return An arbitrary int representing the priority
public abstract int getPriority(); */
public abstract int getPriority();
public abstract String getMechanism(); public abstract String getMechanism();
public String getClientFirstMessage() {
return ""; public String getClientFirstMessage() {
} return "";
public String getResponse(final String challenge) throws AuthenticationException { }
return "";
} public String getResponse(final String challenge) throws AuthenticationException {
return "";
}
} }

View file

@ -1,7 +1,5 @@
package eu.siacs.conversations.crypto.sasl; package eu.siacs.conversations.crypto.sasl;
import android.annotation.TargetApi;
import android.os.Build;
import android.util.Base64; import android.util.Base64;
import com.google.common.base.Objects; import com.google.common.base.Objects;
@ -21,7 +19,6 @@ import eu.siacs.conversations.entities.Account;
import eu.siacs.conversations.utils.CryptoHelper; import eu.siacs.conversations.utils.CryptoHelper;
import eu.siacs.conversations.xml.TagWriter; import eu.siacs.conversations.xml.TagWriter;
@TargetApi(Build.VERSION_CODES.HONEYCOMB_MR1)
abstract class ScramMechanism extends SaslMechanism { abstract class ScramMechanism extends SaslMechanism {
// TODO: When channel binding (SCRAM-SHA1-PLUS) is supported in future, generalize this to indicate support and/or usage. // TODO: When channel binding (SCRAM-SHA1-PLUS) is supported in future, generalize this to indicate support and/or usage.
private final static String GS2_HEADER = "n,,"; private final static String GS2_HEADER = "n,,";

View file

@ -11,27 +11,29 @@ import eu.siacs.conversations.xml.TagWriter;
public class ScramSha1 extends ScramMechanism { public class ScramSha1 extends ScramMechanism {
@Override public static final String MECHANISM = "SCRAM-SHA-1";
protected HMac getHMAC() {
return new HMac(new SHA1Digest());
}
@Override @Override
protected Digest getDigest() { protected HMac getHMAC() {
return new SHA1Digest(); return new HMac(new SHA1Digest());
} }
public ScramSha1(final TagWriter tagWriter, final Account account, final SecureRandom rng) { @Override
super(tagWriter, account, rng); protected Digest getDigest() {
} return new SHA1Digest();
}
@Override public ScramSha1(final TagWriter tagWriter, final Account account, final SecureRandom rng) {
public int getPriority() { super(tagWriter, account, rng);
return 20; }
}
@Override @Override
public String getMechanism() { public int getPriority() {
return "SCRAM-SHA-1"; return 20;
} }
@Override
public String getMechanism() {
return MECHANISM;
}
} }

View file

@ -11,27 +11,29 @@ import eu.siacs.conversations.xml.TagWriter;
public class ScramSha256 extends ScramMechanism { public class ScramSha256 extends ScramMechanism {
@Override public static final String MECHANISM = "SCRAM-SHA-256";
protected HMac getHMAC() {
return new HMac(new SHA256Digest());
}
@Override @Override
protected Digest getDigest() { protected HMac getHMAC() {
return new SHA256Digest(); return new HMac(new SHA256Digest());
} }
public ScramSha256(final TagWriter tagWriter, final Account account, final SecureRandom rng) { @Override
super(tagWriter, account, rng); protected Digest getDigest() {
} return new SHA256Digest();
}
@Override public ScramSha256(final TagWriter tagWriter, final Account account, final SecureRandom rng) {
public int getPriority() { super(tagWriter, account, rng);
return 25; }
}
@Override @Override
public String getMechanism() { public int getPriority() {
return "SCRAM-SHA-256"; return 25;
} }
@Override
public String getMechanism() {
return MECHANISM;
}
} }

View file

@ -0,0 +1,39 @@
package eu.siacs.conversations.crypto.sasl;
import org.bouncycastle.crypto.Digest;
import org.bouncycastle.crypto.digests.SHA512Digest;
import org.bouncycastle.crypto.macs.HMac;
import java.security.SecureRandom;
import eu.siacs.conversations.entities.Account;
import eu.siacs.conversations.xml.TagWriter;
public class ScramSha512 extends ScramMechanism {
public static final String MECHANISM = "SCRAM-SHA-512";
@Override
protected HMac getHMAC() {
return new HMac(new SHA512Digest());
}
@Override
protected Digest getDigest() {
return new SHA512Digest();
}
public ScramSha512(final TagWriter tagWriter, final Account account, final SecureRandom rng) {
super(tagWriter, account, rng);
}
@Override
public int getPriority() {
return 30;
}
@Override
public String getMechanism() {
return MECHANISM;
}
}

View file

@ -10,69 +10,69 @@ import java.util.NoSuchElementException;
* A tokenizer for GS2 header strings * A tokenizer for GS2 header strings
*/ */
public final class Tokenizer implements Iterator<String>, Iterable<String> { public final class Tokenizer implements Iterator<String>, Iterable<String> {
private final List<String> parts; private final List<String> parts;
private int index; private int index;
public Tokenizer(final byte[] challenge) { public Tokenizer(final byte[] challenge) {
final String challengeString = new String(challenge); final String challengeString = new String(challenge);
parts = new ArrayList<>(Arrays.asList(challengeString.split(","))); parts = new ArrayList<>(Arrays.asList(challengeString.split(",")));
// Trim parts. // Trim parts.
for (int i = 0; i < parts.size(); i++) { for (int i = 0; i < parts.size(); i++) {
parts.set(i, parts.get(i).trim()); parts.set(i, parts.get(i).trim());
} }
index = 0; index = 0;
} }
/** /**
* Returns true if there is at least one more element, false otherwise. * Returns true if there is at least one more element, false otherwise.
* *
* @see #next * @see #next
*/ */
@Override @Override
public boolean hasNext() { public boolean hasNext() {
return parts.size() != index + 1; return parts.size() != index + 1;
} }
/** /**
* Returns the next object and advances the iterator. * Returns the next object and advances the iterator.
* *
* @return the next object. * @return the next object.
* @throws java.util.NoSuchElementException if there are no more elements. * @throws java.util.NoSuchElementException if there are no more elements.
* @see #hasNext * @see #hasNext
*/ */
@Override @Override
public String next() { public String next() {
if (hasNext()) { if (hasNext()) {
return parts.get(index++); return parts.get(index++);
} else { } else {
throw new NoSuchElementException("No such element. Size is: " + parts.size()); throw new NoSuchElementException("No such element. Size is: " + parts.size());
} }
} }
/** /**
* Removes the last object returned by {@code next} from the collection. * Removes the last object returned by {@code next} from the collection.
* This method can only be called once between each call to {@code next}. * This method can only be called once between each call to {@code next}.
* *
* @throws UnsupportedOperationException if removing is not supported by the collection being * @throws UnsupportedOperationException if removing is not supported by the collection being
* iterated. * iterated.
* @throws IllegalStateException if {@code next} has not been called, or {@code remove} has * @throws IllegalStateException if {@code next} has not been called, or {@code remove} has
* already been called after the last call to {@code next}. * already been called after the last call to {@code next}.
*/ */
@Override @Override
public void remove() { public void remove() {
if(index <= 0) { if (index <= 0) {
throw new IllegalStateException("You can't delete an element before first next() method call"); throw new IllegalStateException("You can't delete an element before first next() method call");
} }
parts.remove(--index); parts.remove(--index);
} }
/** /**
* Returns an {@link java.util.Iterator} for the elements in this object. * Returns an {@link java.util.Iterator} for the elements in this object.
* *
* @return An {@code Iterator} instance. * @return An {@code Iterator} instance.
*/ */
@Override @Override
public Iterator<String> iterator() { public Iterator<String> iterator() {
return parts.iterator(); return parts.iterator();
} }
} }

View file

@ -64,6 +64,7 @@ import eu.siacs.conversations.crypto.sasl.Plain;
import eu.siacs.conversations.crypto.sasl.SaslMechanism; import eu.siacs.conversations.crypto.sasl.SaslMechanism;
import eu.siacs.conversations.crypto.sasl.ScramSha1; import eu.siacs.conversations.crypto.sasl.ScramSha1;
import eu.siacs.conversations.crypto.sasl.ScramSha256; import eu.siacs.conversations.crypto.sasl.ScramSha256;
import eu.siacs.conversations.crypto.sasl.ScramSha512;
import eu.siacs.conversations.entities.Account; import eu.siacs.conversations.entities.Account;
import eu.siacs.conversations.entities.Message; import eu.siacs.conversations.entities.Message;
import eu.siacs.conversations.entities.ServiceDiscoveryResult; import eu.siacs.conversations.entities.ServiceDiscoveryResult;
@ -870,20 +871,21 @@ public class XmppConnection implements Runnable {
} }
private void authenticate() throws IOException { private void authenticate() throws IOException {
final List<String> mechanisms = extractMechanisms(streamFeatures final List<String> mechanisms = extractMechanisms(streamFeatures.findChild("mechanisms"));
.findChild("mechanisms"));
final Element auth = new Element("auth", Namespace.SASL); final Element auth = new Element("auth", Namespace.SASL);
if (mechanisms.contains("EXTERNAL") && account.getPrivateKeyAlias() != null) { if (mechanisms.contains(External.MECHANISM) && account.getPrivateKeyAlias() != null) {
saslMechanism = new External(tagWriter, account, mXmppConnectionService.getRNG()); saslMechanism = new External(tagWriter, account, mXmppConnectionService.getRNG());
} else if (mechanisms.contains("SCRAM-SHA-256")) { } else if (mechanisms.contains(ScramSha512.MECHANISM)) {
saslMechanism = new ScramSha512(tagWriter, account, mXmppConnectionService.getRNG());
} else if (mechanisms.contains(ScramSha256.MECHANISM)) {
saslMechanism = new ScramSha256(tagWriter, account, mXmppConnectionService.getRNG()); saslMechanism = new ScramSha256(tagWriter, account, mXmppConnectionService.getRNG());
} else if (mechanisms.contains("SCRAM-SHA-1")) { } else if (mechanisms.contains(ScramSha1.MECHANISM)) {
saslMechanism = new ScramSha1(tagWriter, account, mXmppConnectionService.getRNG()); saslMechanism = new ScramSha1(tagWriter, account, mXmppConnectionService.getRNG());
} else if (mechanisms.contains("PLAIN") && !account.getJid().getDomain().toEscapedString().equals("nimbuzz.com")) { } else if (mechanisms.contains(Plain.MECHANISM) && !account.getJid().getDomain().toEscapedString().equals("nimbuzz.com")) {
saslMechanism = new Plain(tagWriter, account); saslMechanism = new Plain(tagWriter, account);
} else if (mechanisms.contains("DIGEST-MD5")) { } else if (mechanisms.contains(DigestMd5.MECHANISM)) {
saslMechanism = new DigestMd5(tagWriter, account, mXmppConnectionService.getRNG()); saslMechanism = new DigestMd5(tagWriter, account, mXmppConnectionService.getRNG());
} else if (mechanisms.contains("ANONYMOUS")) { } else if (mechanisms.contains(Anonymous.MECHANISM)) {
saslMechanism = new Anonymous(tagWriter, account, mXmppConnectionService.getRNG()); saslMechanism = new Anonymous(tagWriter, account, mXmppConnectionService.getRNG());
} }
if (saslMechanism != null) { if (saslMechanism != null) {
@ -1265,27 +1267,27 @@ public class XmppConnection implements Runnable {
request.setTo(account.getDomain()); request.setTo(account.getDomain());
request.addChild("query", Namespace.DISCO_ITEMS).setAttribute("node", Namespace.COMMANDS); request.addChild("query", Namespace.DISCO_ITEMS).setAttribute("node", Namespace.COMMANDS);
sendIqPacket(request, (account, response) -> { sendIqPacket(request, (account, response) -> {
if (response.getType() == IqPacket.TYPE.RESULT) { if (response.getType() == IqPacket.TYPE.RESULT) {
final Element query = response.findChild("query",Namespace.DISCO_ITEMS); final Element query = response.findChild("query", Namespace.DISCO_ITEMS);
if (query == null) { if (query == null) {
return; return;
} }
final HashMap<String, Jid> commands = new HashMap<>(); final HashMap<String, Jid> commands = new HashMap<>();
for(final Element child : query.getChildren()) { for (final Element child : query.getChildren()) {
if ("item".equals(child.getName())) { if ("item".equals(child.getName())) {
final String node = child.getAttribute("node"); final String node = child.getAttribute("node");
final Jid jid = child.getAttributeAsJid("jid"); final Jid jid = child.getAttributeAsJid("jid");
if (node != null && jid != null) { if (node != null && jid != null) {
commands.put(node, jid); commands.put(node, jid);
} }
} }
} }
Log.d(Config.LOGTAG,commands.toString()); Log.d(Config.LOGTAG, commands.toString());
synchronized (this.commands) { synchronized (this.commands) {
this.commands.clear(); this.commands.clear();
this.commands.putAll(commands); this.commands.putAll(commands);
} }
} }
}); });
} }