diff --git a/src/itest/docker-image/test-container/sshd_config b/src/itest/docker-image/test-container/sshd_config index 96b90dde..48a51331 100644 --- a/src/itest/docker-image/test-container/sshd_config +++ b/src/itest/docker-image/test-container/sshd_config @@ -131,4 +131,6 @@ Subsystem sftp /usr/lib/ssh/sftp-server KexAlgorithms curve25519-sha256,curve25519-sha256@libssh.org,ecdh-sha2-nistp256,ecdh-sha2-nistp384,ecdh-sha2-nistp521,diffie-hellman-group-exchange-sha256,diffie-hellman-group16-sha512,diffie-hellman-group18-sha512,diffie-hellman-group14-sha256,diffie-hellman-group14-sha1,diffie-hellman-group1-sha1,diffie-hellman-group-exchange-sha1 macs umac-64-etm@openssh.com,umac-128-etm@openssh.com,hmac-sha2-256-etm@openssh.com,hmac-sha2-512-etm@openssh.com,hmac-ripemd160-etm@openssh.com,umac-64@openssh.com,umac-128@openssh.com,hmac-sha2-256,hmac-sha2-512,hmac-ripemd160,hmac-ripemd160@openssh.com -TrustedUserCAKeys /etc/ssh/users_rsa_ca.pub \ No newline at end of file +TrustedUserCAKeys /etc/ssh/users_rsa_ca.pub + +Ciphers 3des-cbc,blowfish-cbc,aes128-cbc,aes192-cbc,aes256-cbc,aes128-ctr,aes192-ctr,aes256-ctr,aes128-gcm@openssh.com,aes256-gcm@openssh.com \ No newline at end of file diff --git a/src/itest/groovy/com/hierynomus/sshj/transport/cipher/CipherSpec.groovy b/src/itest/groovy/com/hierynomus/sshj/transport/cipher/CipherSpec.groovy new file mode 100644 index 00000000..76461aee --- /dev/null +++ b/src/itest/groovy/com/hierynomus/sshj/transport/cipher/CipherSpec.groovy @@ -0,0 +1,54 @@ +/* + * Copyright (C)2009 - SSHJ Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.hierynomus.sshj.transport.cipher + +import com.hierynomus.sshj.IntegrationBaseSpec +import net.schmizz.sshj.DefaultConfig +import spock.lang.Unroll + +class CipherSpec extends IntegrationBaseSpec { + + @Unroll + def "should correctly connect with #cipher Cipher"() { + given: + def cfg = new DefaultConfig() + cfg.setCipherFactories(cipherFactory) + def client = getConnectedClient(cfg) + + when: + client.authPublickey(USERNAME, KEYFILE) + + then: + client.authenticated + + cleanup: + client.disconnect() + + where: + cipherFactory << [BlockCiphers.TripleDESCBC(), + BlockCiphers.BlowfishCBC(), + BlockCiphers.AES128CBC(), + BlockCiphers.AES128CTR(), + BlockCiphers.AES192CBC(), + BlockCiphers.AES192CTR(), + BlockCiphers.AES256CBC(), + BlockCiphers.AES256CTR(), + GcmCiphers.AES128GCM(), + GcmCiphers.AES256GCM()] + cipher = cipherFactory.name + } + +} diff --git a/src/main/java/com/hierynomus/sshj/transport/cipher/GcmCipher.java b/src/main/java/com/hierynomus/sshj/transport/cipher/GcmCipher.java new file mode 100644 index 00000000..e6d41227 --- /dev/null +++ b/src/main/java/com/hierynomus/sshj/transport/cipher/GcmCipher.java @@ -0,0 +1,164 @@ +/* + * Copyright (C)2009 - SSHJ Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.hierynomus.sshj.transport.cipher; + +import net.schmizz.sshj.common.SSHRuntimeException; +import net.schmizz.sshj.transport.cipher.BaseCipher; + +import javax.crypto.Cipher; +import javax.crypto.SecretKey; +import javax.crypto.spec.GCMParameterSpec; +import java.security.GeneralSecurityException; +import java.security.InvalidAlgorithmParameterException; +import java.security.InvalidKeyException; + +public class GcmCipher extends BaseCipher { + + protected int authSize; + protected Mode mode; + protected boolean initialized; + protected CounterGCMParameterSpec parameters; + protected SecretKey secretKey; + + public GcmCipher(int ivsize, int authSize, int bsize, String algorithm, String transformation) { + super(ivsize, bsize, algorithm, transformation); + this.authSize = authSize; + } + + @Override + public int getAuthenticationTagSize() { + return authSize; + } + + protected Cipher getInitializedCipherInstance() throws GeneralSecurityException { + if (!initialized) { + cipher.init(mode == Mode.Encrypt ? Cipher.ENCRYPT_MODE : Cipher.DECRYPT_MODE, secretKey, parameters); + initialized = true; + } + return cipher; + } + + @Override + protected void initCipher(Cipher cipher, Mode mode, byte[] key, byte[] iv) throws InvalidKeyException, InvalidAlgorithmParameterException { + this.mode = mode; + this.secretKey = getKeySpec(key); + this.parameters = new CounterGCMParameterSpec(getAuthenticationTagSize() * Byte.SIZE, iv); + cipher.init(getMode(mode), secretKey, parameters); + initialized = true; + } + + @Override + public void updateAAD(byte[] data, int offset, int length) { + try { + Cipher cipher = getInitializedCipherInstance(); + cipher.updateAAD(data, offset, length); + } catch (GeneralSecurityException e) { + throw new SSHRuntimeException("Error updating data through cipher", e); + } + } + + @Override + public void update(byte[] input, int inputOffset, int inputLen) { + if (mode == Mode.Decrypt) { + inputLen += getAuthenticationTagSize(); + } + try { + Cipher cipher = getInitializedCipherInstance(); + cipher.doFinal(input, inputOffset, inputLen, input, inputOffset); + } catch (GeneralSecurityException e) { + throw new SSHRuntimeException("Error updating data through cipher", e); + } + /* + * As the RFC stated, the IV used in AES-GCM cipher for SSH is a 4-byte constant plus a 8-byte invocation + * counter. After cipher.doFinal() is called, IV invocation counter is increased by 1, and changes to the IV + * requires reinitializing the cipher, so initialized have to be false here, for the next invocation. + * + * Refer to RFC5647, Section 7.1 + */ + parameters.incrementCounter(); + initialized = false; + } + + /** + * Algorithm parameters for AES/GCM that assumes the IV uses an 8-byte counter field as its most significant bytes. + */ + protected static class CounterGCMParameterSpec extends GCMParameterSpec { + protected final byte[] iv; + + protected CounterGCMParameterSpec(int tLen, byte[] src) { + super(tLen, src); + if (src.length != 12) { + throw new IllegalArgumentException("GCM nonce must be 12 bytes, but given len=" + src.length); + } + iv = src.clone(); + } + + protected void incrementCounter() { + int off = iv.length - 8; + long counter = getLong(iv, off, 8); + putLong(addExact(counter, 1L), iv, off, 8); + } + + @Override + public byte[] getIV() { + // JCE implementation of GCM will complain if the reference doesn't change between inits + return iv.clone(); + } + + static long addExact(long var0, long var2) { + long var4 = var0 + var2; + if (((var0 ^ var4) & (var2 ^ var4)) < 0L) { + throw new ArithmeticException("long overflow"); + } else { + return var4; + } + } + + static long getLong(byte[] buf, int off, int len) { + if (len < 8) { + throw new IllegalArgumentException("Not enough data for a long: required=8, available=" + len); + } + + long l = (long) buf[off] << 56; + l |= ((long) buf[off + 1] & 0xff) << 48; + l |= ((long) buf[off + 2] & 0xff) << 40; + l |= ((long) buf[off + 3] & 0xff) << 32; + l |= ((long) buf[off + 4] & 0xff) << 24; + l |= ((long) buf[off + 5] & 0xff) << 16; + l |= ((long) buf[off + 6] & 0xff) << 8; + l |= (long) buf[off + 7] & 0xff; + + return l; + } + + static int putLong(long value, byte[] buf, int off, int len) { + if (len < 8) { + throw new IllegalArgumentException("Not enough data for a long: required=8, available=" + len); + } + + buf[off] = (byte) (value >> 56); + buf[off + 1] = (byte) (value >> 48); + buf[off + 2] = (byte) (value >> 40); + buf[off + 3] = (byte) (value >> 32); + buf[off + 4] = (byte) (value >> 24); + buf[off + 5] = (byte) (value >> 16); + buf[off + 6] = (byte) (value >> 8); + buf[off + 7] = (byte) value; + + return 8; + } + } +} diff --git a/src/main/java/com/hierynomus/sshj/transport/cipher/GcmCiphers.java b/src/main/java/com/hierynomus/sshj/transport/cipher/GcmCiphers.java new file mode 100644 index 00000000..2a288516 --- /dev/null +++ b/src/main/java/com/hierynomus/sshj/transport/cipher/GcmCiphers.java @@ -0,0 +1,74 @@ +/* + * Copyright (C)2009 - SSHJ Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.hierynomus.sshj.transport.cipher; + +import net.schmizz.sshj.transport.cipher.Cipher; + +public class GcmCiphers { + + public static final String GALOIS_COUNTER_MODE = "GCM"; + + public static Factory AES128GCM() { + return new Factory(12, 16, 128, "aes128-gcm@openssh.com", "AES", GALOIS_COUNTER_MODE); + } + + public static Factory AES256GCM() { + return new Factory(12, 16, 256, "aes256-gcm@openssh.com", "AES", GALOIS_COUNTER_MODE); + } + + /** Named factory for BlockCipher */ + public static class Factory + implements net.schmizz.sshj.common.Factory.Named { + + private int keysize; + private int authSize; + private String cipher; + private String mode; + private String name; + private int ivsize; + + /** + * @param ivsize + * @param keysize The keysize used in bits. + * @param name + * @param cipher + * @param mode + */ + public Factory(int ivsize, int authSize, int keysize, String name, String cipher, String mode) { + this.name = name; + this.keysize = keysize; + this.cipher = cipher; + this.mode = mode; + this.ivsize = ivsize; + this.authSize = authSize; + } + + @Override + public Cipher create() { + return new GcmCipher(ivsize, authSize, keysize / 8, cipher, cipher + "/" + mode + "/NoPadding"); + } + + @Override + public String getName() { + return name; + } + + @Override + public String toString() { + return getName(); + } + } +} diff --git a/src/main/java/net/schmizz/sshj/DefaultConfig.java b/src/main/java/net/schmizz/sshj/DefaultConfig.java index 746148c7..49e7c95e 100644 --- a/src/main/java/net/schmizz/sshj/DefaultConfig.java +++ b/src/main/java/net/schmizz/sshj/DefaultConfig.java @@ -18,6 +18,7 @@ package net.schmizz.sshj; import com.hierynomus.sshj.key.KeyAlgorithm; import com.hierynomus.sshj.key.KeyAlgorithms; import com.hierynomus.sshj.transport.cipher.BlockCiphers; +import com.hierynomus.sshj.transport.cipher.GcmCiphers; import com.hierynomus.sshj.transport.cipher.StreamCiphers; import com.hierynomus.sshj.transport.kex.DHGroups; import com.hierynomus.sshj.transport.kex.ExtInfoClientFactory; @@ -171,6 +172,8 @@ public class DefaultConfig BlockCiphers.AES192CTR(), BlockCiphers.AES256CBC(), BlockCiphers.AES256CTR(), + GcmCiphers.AES128GCM(), + GcmCiphers.AES256GCM(), BlockCiphers.BlowfishCBC(), BlockCiphers.BlowfishCTR(), BlockCiphers.Cast128CBC(), diff --git a/src/main/java/net/schmizz/sshj/transport/Converter.java b/src/main/java/net/schmizz/sshj/transport/Converter.java index 0e817e2e..9d532f96 100644 --- a/src/main/java/net/schmizz/sshj/transport/Converter.java +++ b/src/main/java/net/schmizz/sshj/transport/Converter.java @@ -45,6 +45,7 @@ abstract class Converter { protected long seq = -1; protected boolean authed; protected boolean etm; + protected boolean authMode; long getSequenceNumber() { return seq; @@ -57,7 +58,11 @@ abstract class Converter { if (compression != null) compression.init(getCompressionType()); this.cipherSize = cipher.getIVSize(); - this.etm = mac.isEtm(); + this.etm = this.mac != null && mac.isEtm(); + if(cipher.getAuthenticationTagSize() > 0) { + this.cipherSize = cipher.getAuthenticationTagSize(); + this.authMode = true; + } } void setAuthenticated() { diff --git a/src/main/java/net/schmizz/sshj/transport/Decoder.java b/src/main/java/net/schmizz/sshj/transport/Decoder.java index c3619cd5..02309c1b 100644 --- a/src/main/java/net/schmizz/sshj/transport/Decoder.java +++ b/src/main/java/net/schmizz/sshj/transport/Decoder.java @@ -70,87 +70,41 @@ final class Decoder * * @return number of bytes needed before further decoding possible */ - private int decode() - throws SSHException { - - if (etm) { - return decodeEtm(); - } else { - return decodeMte(); - } - } - - /** - * Decode an Encrypt-Then-Mac packet. - */ - private int decodeEtm() throws SSHException { - int bytesNeeded; - while (true) { - if (packetLength == -1) { - assert inputBuffer.rpos() == 0 : "buffer cleared"; - bytesNeeded = 4 - inputBuffer.available(); - if (bytesNeeded <= 0) { - // In Encrypt-Then-Mac, the packetlength is sent unencrypted. - packetLength = inputBuffer.readUInt32AsInt(); - checkPacketLength(packetLength); - } else { - // Needs more data - break; - } - } else { - assert inputBuffer.rpos() == 4 : "packet length read"; - bytesNeeded = packetLength + mac.getBlockSize() - inputBuffer.available(); - if (bytesNeeded <= 0) { - seq = seq + 1 & 0xffffffffL; - checkMAC(inputBuffer.array()); - decryptBuffer(4, packetLength); - inputBuffer.wpos(packetLength + 4 - inputBuffer.readByte()); - final SSHPacket plain = usingCompression() ? decompressed() : inputBuffer; - if (log.isTraceEnabled()) { - log.trace("Received packet #{}: {}", seq, plain.printHex()); - } - packetHandler.handle(plain.readMessageID(), plain); // Process the decoded packet - inputBuffer.clear(); - packetLength = -1; - } else { - // Needs more data - break; - } - } - } - return bytesNeeded; - } - - /** - * Decode a Mac-Then-Encrypt packet - * @return - * @throws SSHException - */ - private int decodeMte() throws SSHException { + private int decode() throws SSHException { int need; - - /* Decoding loop */ - for (; ; ) - + for(;;) { if (packetLength == -1) { // Waiting for beginning of packet assert inputBuffer.rpos() == 0 : "buffer cleared"; need = cipherSize - inputBuffer.available(); if (need <= 0) { - packetLength = decryptLength(); + if (authMode) { + packetLength = decryptLengthAAD(); + } else if (etm) { + packetLength = inputBuffer.readUInt32AsInt(); + checkPacketLength(packetLength); + } else { + packetLength = decryptLength(); + } } else { // Need more data break; } } else { assert inputBuffer.rpos() == 4 : "packet length read"; - need = packetLength + (mac != null ? mac.getBlockSize() : 0) - inputBuffer.available(); + need = (authMode) ? packetLength + cipherSize - inputBuffer.available() : packetLength + (mac != null ? mac.getBlockSize() : 0) - inputBuffer.available(); if (need <= 0) { - decryptBuffer(cipherSize, packetLength + 4 - cipherSize); // Decrypt the rest of the payload seq = seq + 1 & 0xffffffffL; - if (mac != null) { + if (authMode) { + cipher.update(inputBuffer.array(), 4, packetLength); + } else if (etm) { checkMAC(inputBuffer.array()); + decryptBuffer(4, packetLength); + } else { + decryptBuffer(cipherSize, packetLength + 4 - cipherSize); // Decrypt the rest of the payload + if (mac != null) { + checkMAC(inputBuffer.array()); + } } - // Exclude the padding & MAC inputBuffer.wpos(packetLength + 4 - inputBuffer.readByte()); final SSHPacket plain = usingCompression() ? decompressed() : inputBuffer; if (log.isTraceEnabled()) { @@ -160,16 +114,20 @@ final class Decoder inputBuffer.clear(); packetLength = -1; } else { - // Need more data + // Needs more data break; } } - + } return need; } private void checkMAC(final byte[] data) throws TransportException { + if (mac == null) { + return; + } + mac.update(seq); // seq num mac.update(data, 0, packetLength + 4); // packetLength+4 = entire packet w/o mac mac.doFinal(macResult, 0); // compute @@ -186,6 +144,20 @@ final class Decoder return uncompressBuffer; } + private int decryptLengthAAD() throws TransportException { + cipher.updateAAD(inputBuffer.array(), 0, 4); + + final int len; + try { + len = inputBuffer.readUInt32AsInt(); + } catch (Buffer.BufferException be) { + throw new TransportException(be); + } + checkPacketLength(len); + + return len; + } + private int decryptLength() throws TransportException { decryptBuffer(0, cipherSize); @@ -237,7 +209,9 @@ final class Decoder @Override void setAlgorithms(Cipher cipher, MAC mac, Compression compression) { super.setAlgorithms(cipher, mac, compression); - macResult = new byte[mac.getBlockSize()]; + if (mac != null) { + macResult = new byte[mac.getBlockSize()]; + } } @Override diff --git a/src/main/java/net/schmizz/sshj/transport/Encoder.java b/src/main/java/net/schmizz/sshj/transport/Encoder.java index 9ece2041..f9cc1749 100644 --- a/src/main/java/net/schmizz/sshj/transport/Encoder.java +++ b/src/main/java/net/schmizz/sshj/transport/Encoder.java @@ -15,6 +15,7 @@ */ package net.schmizz.sshj.transport; +import net.schmizz.sshj.common.Buffer; import net.schmizz.sshj.common.LoggerFactory; import net.schmizz.sshj.common.SSHPacket; import net.schmizz.sshj.transport.cipher.Cipher; @@ -83,7 +84,7 @@ final class Encoder // Compute padding length int padLen = cipherSize - (lengthWithoutPadding % cipherSize); - if (padLen < 4) { + if (padLen < 4 || (authMode && padLen < cipherSize)) { padLen += cipherSize; } @@ -94,6 +95,14 @@ final class Encoder padLen += cipherSize; packetLen = 1 + payloadSize + padLen; } + /* + * In AES-GCM ciphers, they require packets must be a multiple of 16 bytes (which is also block size of AES) + * as mentioned in RFC5647 Section 7.2. So we are calculating the extra padding as necessary here + */ + if (authMode && packetLen % cipherSize != 0) { + padLen += cipherSize - (packetLen % cipherSize); + packetLen = 1 + payloadSize + padLen; + } final int endOfPadding = startOfPacket + 4 + packetLen; @@ -101,6 +110,7 @@ final class Encoder buffer.wpos(startOfPacket); buffer.putUInt32(packetLen); buffer.putByte((byte) padLen); + // Now wpos will mark end of padding buffer.wpos(endOfPadding); @@ -109,14 +119,17 @@ final class Encoder seq = seq + 1 & 0xffffffffL; - if (etm) { + if (authMode) { + int wpos = buffer.wpos(); + buffer.wpos(wpos + cipherSize); + aeadOutgoingBuffer(buffer, startOfPacket, packetLen); + } else if (etm) { cipher.update(buffer.array(), startOfPacket + 4, packetLen); putMAC(buffer, startOfPacket, endOfPadding); } else { if (mac != null) { putMAC(buffer, startOfPacket, endOfPadding); } - cipher.update(buffer.array(), startOfPacket, 4 + packetLen); } buffer.rpos(startOfPacket); // Make ready-to-read @@ -127,6 +140,14 @@ final class Encoder } } + protected void aeadOutgoingBuffer(Buffer buf, int offset, int len) { + if (cipher == null || cipher.getAuthenticationTagSize() == 0) { + throw new IllegalArgumentException("AEAD mode requires an AEAD cipher"); + } + byte[] data = buf.array(); + cipher.updateWithAAD(data, offset, 4, len); + } + @Override void setAlgorithms(Cipher cipher, MAC mac, Compression compression) { encodeLock.lock(); diff --git a/src/main/java/net/schmizz/sshj/transport/KeyExchanger.java b/src/main/java/net/schmizz/sshj/transport/KeyExchanger.java index eedaccae..bc53a732 100644 --- a/src/main/java/net/schmizz/sshj/transport/KeyExchanger.java +++ b/src/main/java/net/schmizz/sshj/transport/KeyExchanger.java @@ -15,7 +15,6 @@ */ package net.schmizz.sshj.transport; -import com.hierynomus.sshj.key.KeyAlgorithm; import net.schmizz.concurrent.ErrorDeliveryUtil; import net.schmizz.concurrent.Event; import net.schmizz.sshj.common.*; @@ -323,13 +322,25 @@ final class KeyExchanger resizedKey(encryptionKey_S2C, cipher_S2C.getBlockSize(), hash, kex.getK(), kex.getH()), initialIV_S2C); - final MAC mac_C2S = Factory.Named.Util.create(transport.getConfig().getMACFactories(), negotiatedAlgs - .getClient2ServerMACAlgorithm()); - mac_C2S.init(resizedKey(integrityKey_C2S, mac_C2S.getBlockSize(), hash, kex.getK(), kex.getH())); + /* + * For AES-GCM ciphers, MAC will also be AES-GCM, so it is handled by the cipher itself. + * In that case, both s2c and c2s MACs are ignored. + * + * Refer to RFC5647 Section 5.1 + */ + MAC mac_C2S = null; + if(cipher_C2S.getAuthenticationTagSize() == 0) { + mac_C2S = Factory.Named.Util.create(transport.getConfig().getMACFactories(), negotiatedAlgs + .getClient2ServerMACAlgorithm()); + mac_C2S.init(resizedKey(integrityKey_C2S, mac_C2S.getBlockSize(), hash, kex.getK(), kex.getH())); + } - final MAC mac_S2C = Factory.Named.Util.create(transport.getConfig().getMACFactories(), - negotiatedAlgs.getServer2ClientMACAlgorithm()); - mac_S2C.init(resizedKey(integrityKey_S2C, mac_S2C.getBlockSize(), hash, kex.getK(), kex.getH())); + MAC mac_S2C = null; + if(cipher_S2C.getAuthenticationTagSize() == 0) { + mac_S2C = Factory.Named.Util.create(transport.getConfig().getMACFactories(), + negotiatedAlgs.getServer2ClientMACAlgorithm()); + mac_S2C.init(resizedKey(integrityKey_S2C, mac_S2C.getBlockSize(), hash, kex.getK(), kex.getH())); + } final Compression compression_S2C = Factory.Named.Util.create(transport.getConfig().getCompressionFactories(), diff --git a/src/main/java/net/schmizz/sshj/transport/cipher/BaseCipher.java b/src/main/java/net/schmizz/sshj/transport/cipher/BaseCipher.java index 6133b85a..b50f18c1 100644 --- a/src/main/java/net/schmizz/sshj/transport/cipher/BaseCipher.java +++ b/src/main/java/net/schmizz/sshj/transport/cipher/BaseCipher.java @@ -42,7 +42,7 @@ public abstract class BaseCipher private final String algorithm; private final String transformation; - private javax.crypto.Cipher cipher; + protected javax.crypto.Cipher cipher; public BaseCipher(int ivsize, int bsize, String algorithm, String transformation) { this.ivsize = ivsize; @@ -61,6 +61,11 @@ public abstract class BaseCipher return ivsize; } + @Override + public int getAuthenticationTagSize() { + return 0; + } + @Override public void init(Mode mode, byte[] key, byte[] iv) { key = BaseCipher.resize(key, bsize); @@ -75,6 +80,7 @@ public abstract class BaseCipher } protected abstract void initCipher(javax.crypto.Cipher cipher, Mode mode, byte[] key, byte[] iv) throws InvalidKeyException, InvalidAlgorithmParameterException; + protected SecretKeySpec getKeySpec(byte[] key) { return new SecretKeySpec(key, algorithm); } @@ -92,4 +98,19 @@ public abstract class BaseCipher } } + @Override + public void updateAAD(byte[] data, int offset, int length) { + throw new UnsupportedOperationException(getClass() + " does not support AAD operations"); + } + + @Override + public void updateAAD(byte[] data) { + updateAAD(data, 0, data.length); + } + + @Override + public void updateWithAAD(byte[] input, int offset, int aadLen, int inputLen) { + updateAAD(input, offset, aadLen); + update(input, offset + aadLen, inputLen); + } } diff --git a/src/main/java/net/schmizz/sshj/transport/cipher/Cipher.java b/src/main/java/net/schmizz/sshj/transport/cipher/Cipher.java index 6a6e51bd..c7f0ad73 100644 --- a/src/main/java/net/schmizz/sshj/transport/cipher/Cipher.java +++ b/src/main/java/net/schmizz/sshj/transport/cipher/Cipher.java @@ -29,6 +29,9 @@ public interface Cipher { /** @return the size of the initialization vector */ int getIVSize(); + /** @return Size of the authentication tag (AT) in bytes or 0 if this cipher does not support authentication */ + int getAuthenticationTagSize(); + /** * Initialize the cipher for encryption or decryption with the given private key and initialization vector * @@ -47,4 +50,32 @@ public interface Cipher { */ void update(byte[] input, int inputOffset, int inputLen); + /** + * Adds the provided input data as additional authenticated data during encryption or decryption. + * + * @param data The additional data to authenticate + * @param offset The offset of the additional data in the buffer + * @param length The number of bytes in the buffer to use for authentication + */ + void updateAAD(byte[] data, int offset, int length); + + /** + * Adds the provided input data as additional authenticated data during encryption or decryption. + * + * @param data The data to authenticate + */ + void updateAAD(byte[] data); + + /** + * Performs in-place authenticated encryption or decryption with additional data (AEAD). Authentication tags are + * implicitly appended after the output ciphertext or implicitly verified after the input ciphertext. Header data + * indicated by the {@code aadLen} parameter are authenticated but not encrypted/decrypted, while payload data + * indicated by the {@code inputLen} parameter are authenticated and encrypted/decrypted. + * + * @param input The input/output bytes + * @param offset The offset of the data in the input buffer + * @param aadLen The number of bytes to use as additional authenticated data - starting at offset + * @param inputLen The number of bytes to update - starting at offset + aadLen + */ + void updateWithAAD(byte[] input, int offset, int aadLen, int inputLen); } diff --git a/src/main/java/net/schmizz/sshj/transport/cipher/NoneCipher.java b/src/main/java/net/schmizz/sshj/transport/cipher/NoneCipher.java index d576d3c6..246bf707 100644 --- a/src/main/java/net/schmizz/sshj/transport/cipher/NoneCipher.java +++ b/src/main/java/net/schmizz/sshj/transport/cipher/NoneCipher.java @@ -44,6 +44,11 @@ public class NoneCipher return 8; } + @Override + public int getAuthenticationTagSize() { + return 0; + } + @Override public void init(Mode mode, byte[] bytes, byte[] bytes1) { // Nothing to do @@ -54,4 +59,18 @@ public class NoneCipher // Nothing to do } + @Override + public void updateAAD(byte[] data, int offset, int length) { + + } + + @Override + public void updateAAD(byte[] data) { + + } + + @Override + public void updateWithAAD(byte[] input, int offset, int aadLen, int inputLen) { + + } } diff --git a/src/test/java/com/hierynomus/sshj/transport/GcmCipherDecryptSshPacketTest.java b/src/test/java/com/hierynomus/sshj/transport/GcmCipherDecryptSshPacketTest.java new file mode 100644 index 00000000..985d7688 --- /dev/null +++ b/src/test/java/com/hierynomus/sshj/transport/GcmCipherDecryptSshPacketTest.java @@ -0,0 +1,69 @@ +/* + * Copyright (C)2009 - SSHJ Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.hierynomus.sshj.transport; + +import com.hierynomus.sshj.transport.cipher.GcmCiphers; +import net.schmizz.sshj.common.IOUtils; +import net.schmizz.sshj.common.SSHPacket; +import net.schmizz.sshj.transport.cipher.Cipher; +import org.bouncycastle.jce.provider.BouncyCastleProvider; +import org.junit.BeforeClass; +import org.junit.experimental.theories.DataPoints; +import org.junit.experimental.theories.Theories; +import org.junit.experimental.theories.Theory; +import org.junit.runner.RunWith; + +import java.io.File; +import java.io.FileInputStream; +import java.security.Security; +import java.util.Arrays; + +import static org.junit.Assert.assertArrayEquals; + +/** + * Unit test to decrypt SSH traffic with OpenSSH and Apache Mina SSHD (master) using AES-GCM ciphers, for verifying + * cipher behaviour. + */ +@RunWith(Theories.class) +public class GcmCipherDecryptSshPacketTest { + + @DataPoints + public static final String sets[][] = new String[][]{{"mina-sshd", "3"}, {"openssh", "4"}}; + + @BeforeClass + public static void setupBeforeClass() { + Security.addProvider(new BouncyCastleProvider()); + } + + @Theory + public void testDecryptPacket(String[] args) throws Exception { + ClassLoader classLoader = getClass().getClassLoader(); + byte[] iv = IOUtils.readFully(classLoader.getResourceAsStream("ssh-packets/gcm/"+args[0]+"/s2c.iv.bin")).toByteArray(); + byte[] key = IOUtils.readFully(classLoader.getResourceAsStream("ssh-packets/gcm/"+args[0]+"/s2c.key.bin")).toByteArray(); + Cipher cipher = GcmCiphers.AES128GCM().create(); + cipher.init(Cipher.Mode.Decrypt, key, iv); + for(int i=1; i<=Integer.parseInt(args[1]); i++) { + byte[] data = IOUtils.readFully(classLoader.getResourceAsStream("ssh-packets/gcm/"+args[0]+"/client.receive."+i+".bin")).toByteArray(); + SSHPacket inputBuffer = new SSHPacket(data); + cipher.updateAAD(inputBuffer.array(), 0, 4); + int size = inputBuffer.readUInt32AsInt(); + cipher.update(inputBuffer.array(), 4, size); + byte[] expected = IOUtils.readFully(classLoader.getResourceAsStream("ssh-packets/gcm/"+args[0]+"/client.decrypted."+i+".bin")).toByteArray(); + assertArrayEquals(Arrays.copyOfRange(expected, 0, size+4), + Arrays.copyOfRange(inputBuffer.array(), 0, size+4)); + } + } +} diff --git a/src/test/java/com/hierynomus/sshj/transport/GcmCipherTest.java b/src/test/java/com/hierynomus/sshj/transport/GcmCipherTest.java new file mode 100644 index 00000000..b9784d0b --- /dev/null +++ b/src/test/java/com/hierynomus/sshj/transport/GcmCipherTest.java @@ -0,0 +1,73 @@ +/* + * Copyright (C)2009 - SSHJ Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.hierynomus.sshj.transport; + +import com.hierynomus.sshj.transport.cipher.GcmCiphers; +import net.schmizz.sshj.common.SSHRuntimeException; +import net.schmizz.sshj.transport.cipher.Cipher; +import org.junit.experimental.theories.DataPoints; +import org.junit.experimental.theories.Theories; +import org.junit.experimental.theories.Theory; +import org.junit.runner.RunWith; + +import javax.crypto.AEADBadTagException; +import java.nio.charset.StandardCharsets; + +import static org.junit.Assert.*; + +@RunWith(Theories.class) +public class GcmCipherTest { + + public static final @DataPoints + GcmCiphers.Factory[] cipherFactories = { GcmCiphers.AES128GCM(), GcmCiphers.AES256GCM() }; + + @Theory + public void testEncryptDecrypt(GcmCiphers.Factory factory) throws Exception { + Cipher enc = factory.create(); + byte[] key = new byte[enc.getBlockSize()]; + byte[] iv = new byte[enc.getIVSize()]; + enc.init(Cipher.Mode.Encrypt, key, iv); + + byte[] aad = getClass().getName().getBytes(StandardCharsets.UTF_8); + enc.updateAAD(aad); + String plaintext = "[Secret authenticated message using AES-GCM"; + byte[] ptBytes = plaintext.getBytes(StandardCharsets.UTF_8); + byte[] output = new byte[ptBytes.length + enc.getAuthenticationTagSize()]; + System.arraycopy(ptBytes, 0, output, 0, ptBytes.length); + enc.update(output, 0, ptBytes.length); + + Cipher dec = factory.create(); + dec.init(Cipher.Mode.Decrypt, key, iv); + dec.updateAAD(aad); + byte[] input = output.clone(); + dec.update(input, 0, ptBytes.length); + assertEquals(getClass().getName(), new String(aad, StandardCharsets.UTF_8)); + assertEquals(plaintext, new String(input, 0, ptBytes.length, StandardCharsets.UTF_8)); + + byte[] corrupted = output.clone(); + corrupted[corrupted.length - 1] += 1; + Cipher failingDec = factory.create(); + failingDec.init(Cipher.Mode.Decrypt, key, iv); + try { + failingDec.updateAAD(aad.clone()); + failingDec.update(corrupted, 0, ptBytes.length); + fail("Modified authentication tag should not validate"); + } catch (SSHRuntimeException e) { + assertNotNull(e); + assertEquals(AEADBadTagException.class, e.getCause().getClass()); + } + } +} diff --git a/src/test/java/net/schmizz/sshj/transport/DecoderDecryptGcmCipherSshPacketTest.java b/src/test/java/net/schmizz/sshj/transport/DecoderDecryptGcmCipherSshPacketTest.java new file mode 100644 index 00000000..6387e4d4 --- /dev/null +++ b/src/test/java/net/schmizz/sshj/transport/DecoderDecryptGcmCipherSshPacketTest.java @@ -0,0 +1,116 @@ +/* + * Copyright (C)2009 - SSHJ Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package net.schmizz.sshj.transport; + +import com.hierynomus.sshj.transport.cipher.GcmCiphers; +import net.schmizz.sshj.Config; +import net.schmizz.sshj.common.IOUtils; +import net.schmizz.sshj.common.LoggerFactory; +import net.schmizz.sshj.common.SSHException; +import net.schmizz.sshj.common.SSHPacket; +import net.schmizz.sshj.transport.cipher.Cipher; +import org.bouncycastle.jce.provider.BouncyCastleProvider; +import org.junit.Before; +import org.junit.Test; + +import java.security.SecureRandom; +import java.security.Security; + +import static org.junit.Assert.assertArrayEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +public class DecoderDecryptGcmCipherSshPacketTest { + + private int PACKET_LENGTH; + + private byte[] key; + + private byte[] iv; + + private byte[] data; + + private byte[] decrypted; + + private Decoder decoder; + + @Before + public void setUp() throws Exception { + Security.addProvider(new BouncyCastleProvider()); + ClassLoader classLoader = DecoderDecryptGcmCipherSshPacketTest.class.getClassLoader(); + iv = IOUtils.readFully(classLoader.getResourceAsStream("ssh-packets/gcm/mina-sshd/s2c.iv.bin" )).toByteArray(); + key = IOUtils.readFully(classLoader.getResourceAsStream("ssh-packets/gcm/mina-sshd/s2c.key.bin" )).toByteArray(); + data = IOUtils.readFully(classLoader.getResourceAsStream("ssh-packets/gcm/mina-sshd/client.receive.1.bin" )).toByteArray(); + + SSHPacket packet = new SSHPacket(IOUtils.readFully(classLoader.getResourceAsStream("ssh-packets/gcm/mina-sshd/client.decrypted.1.bin" )).toByteArray()); + PACKET_LENGTH = packet.readUInt32AsInt(); + decrypted = new byte[PACKET_LENGTH]; + System.arraycopy(packet.array(), 0, decrypted, 0, PACKET_LENGTH); + + Config config = mock(Config.class); + Transport transport = mock(Transport.class); + when(transport.getConfig()).thenReturn(config); + when(config.getLoggerFactory()).thenReturn(LoggerFactory.DEFAULT); + doAnswer(invocation -> { + SSHPacket p = invocation.getArgument(1); + byte[] verify = new byte[PACKET_LENGTH]; + System.arraycopy(p.array(), 0, verify, 0, PACKET_LENGTH); + assertArrayEquals(decrypted, verify); + return null; + }).when(transport).handle(any(), any()); + + decoder = new Decoder(transport); + Cipher cipher = GcmCiphers.AES128GCM().create(); + cipher.init(Cipher.Mode.Decrypt, key, iv); + decoder.setAlgorithms(cipher, null, null); + } + + @Test + public void testDecodeInOneGo() throws SSHException { + decoder.received(data, data.length); + } + + @Test + public void testDecodeInConstantChunks() throws SSHException { + int chunkSize = 16; + int remain = PACKET_LENGTH; + int pos = 0; + while(remain >= 0) { + byte[] chunk = new byte[chunkSize]; + System.arraycopy(data, pos, chunk, 0, chunkSize); + decoder.received(chunk, chunk.length); + pos += chunkSize; + remain -= chunkSize; + } + } + + @Test + public void testDecodeInRandomChunks() throws SSHException { + SecureRandom sr = new SecureRandom(); + int remain = PACKET_LENGTH; + int pos = 0; + while(remain >= 0) { + int chunkSize = sr.nextInt(10); + if (chunkSize - remain < 0) + chunkSize = remain; + byte[] chunk = new byte[chunkSize]; + System.arraycopy(data, pos, chunk, 0, chunkSize); + decoder.received(chunk, chunk.length); + pos += chunkSize; + remain -= chunkSize; + } + } +} diff --git a/src/test/resources/ssh-packets/gcm/mina-sshd/client.decrypted.1.bin b/src/test/resources/ssh-packets/gcm/mina-sshd/client.decrypted.1.bin new file mode 100644 index 00000000..34e6826c Binary files /dev/null and b/src/test/resources/ssh-packets/gcm/mina-sshd/client.decrypted.1.bin differ diff --git a/src/test/resources/ssh-packets/gcm/mina-sshd/client.decrypted.2.bin b/src/test/resources/ssh-packets/gcm/mina-sshd/client.decrypted.2.bin new file mode 100644 index 00000000..2d208473 Binary files /dev/null and b/src/test/resources/ssh-packets/gcm/mina-sshd/client.decrypted.2.bin differ diff --git a/src/test/resources/ssh-packets/gcm/mina-sshd/client.decrypted.3.bin b/src/test/resources/ssh-packets/gcm/mina-sshd/client.decrypted.3.bin new file mode 100644 index 00000000..31ce9c05 Binary files /dev/null and b/src/test/resources/ssh-packets/gcm/mina-sshd/client.decrypted.3.bin differ diff --git a/src/test/resources/ssh-packets/gcm/mina-sshd/client.receive.1.bin b/src/test/resources/ssh-packets/gcm/mina-sshd/client.receive.1.bin new file mode 100644 index 00000000..2234c87d Binary files /dev/null and b/src/test/resources/ssh-packets/gcm/mina-sshd/client.receive.1.bin differ diff --git a/src/test/resources/ssh-packets/gcm/mina-sshd/client.receive.2.bin b/src/test/resources/ssh-packets/gcm/mina-sshd/client.receive.2.bin new file mode 100644 index 00000000..4e2bfca8 Binary files /dev/null and b/src/test/resources/ssh-packets/gcm/mina-sshd/client.receive.2.bin differ diff --git a/src/test/resources/ssh-packets/gcm/mina-sshd/client.receive.3.bin b/src/test/resources/ssh-packets/gcm/mina-sshd/client.receive.3.bin new file mode 100644 index 00000000..97c7a1cf Binary files /dev/null and b/src/test/resources/ssh-packets/gcm/mina-sshd/client.receive.3.bin differ diff --git a/src/test/resources/ssh-packets/gcm/mina-sshd/s2c.iv.bin b/src/test/resources/ssh-packets/gcm/mina-sshd/s2c.iv.bin new file mode 100644 index 00000000..388868e4 --- /dev/null +++ b/src/test/resources/ssh-packets/gcm/mina-sshd/s2c.iv.bin @@ -0,0 +1 @@ +}GL8a<ųǁ0ݺ8s1 _NZX!2 1+VPܤ \ No newline at end of file diff --git a/src/test/resources/ssh-packets/gcm/mina-sshd/s2c.key.bin b/src/test/resources/ssh-packets/gcm/mina-sshd/s2c.key.bin new file mode 100644 index 00000000..dee166c6 --- /dev/null +++ b/src/test/resources/ssh-packets/gcm/mina-sshd/s2c.key.bin @@ -0,0 +1 @@ +ѝe_7>nPÚ/A_Ş{f.~f5ZhM9; \ No newline at end of file diff --git a/src/test/resources/ssh-packets/gcm/openssh/client.decrypted.1.bin b/src/test/resources/ssh-packets/gcm/openssh/client.decrypted.1.bin new file mode 100644 index 00000000..2ee5b931 Binary files /dev/null and b/src/test/resources/ssh-packets/gcm/openssh/client.decrypted.1.bin differ diff --git a/src/test/resources/ssh-packets/gcm/openssh/client.decrypted.2.bin b/src/test/resources/ssh-packets/gcm/openssh/client.decrypted.2.bin new file mode 100644 index 00000000..9be5517e Binary files /dev/null and b/src/test/resources/ssh-packets/gcm/openssh/client.decrypted.2.bin differ diff --git a/src/test/resources/ssh-packets/gcm/openssh/client.decrypted.3.bin b/src/test/resources/ssh-packets/gcm/openssh/client.decrypted.3.bin new file mode 100644 index 00000000..1c984897 Binary files /dev/null and b/src/test/resources/ssh-packets/gcm/openssh/client.decrypted.3.bin differ diff --git a/src/test/resources/ssh-packets/gcm/openssh/client.decrypted.4.bin b/src/test/resources/ssh-packets/gcm/openssh/client.decrypted.4.bin new file mode 100644 index 00000000..81503203 Binary files /dev/null and b/src/test/resources/ssh-packets/gcm/openssh/client.decrypted.4.bin differ diff --git a/src/test/resources/ssh-packets/gcm/openssh/client.receive.1.bin b/src/test/resources/ssh-packets/gcm/openssh/client.receive.1.bin new file mode 100644 index 00000000..a934f8eb Binary files /dev/null and b/src/test/resources/ssh-packets/gcm/openssh/client.receive.1.bin differ diff --git a/src/test/resources/ssh-packets/gcm/openssh/client.receive.2.bin b/src/test/resources/ssh-packets/gcm/openssh/client.receive.2.bin new file mode 100644 index 00000000..7f9a3e38 Binary files /dev/null and b/src/test/resources/ssh-packets/gcm/openssh/client.receive.2.bin differ diff --git a/src/test/resources/ssh-packets/gcm/openssh/client.receive.3.bin b/src/test/resources/ssh-packets/gcm/openssh/client.receive.3.bin new file mode 100644 index 00000000..9038aedb Binary files /dev/null and b/src/test/resources/ssh-packets/gcm/openssh/client.receive.3.bin differ diff --git a/src/test/resources/ssh-packets/gcm/openssh/client.receive.4.bin b/src/test/resources/ssh-packets/gcm/openssh/client.receive.4.bin new file mode 100644 index 00000000..97416f98 Binary files /dev/null and b/src/test/resources/ssh-packets/gcm/openssh/client.receive.4.bin differ diff --git a/src/test/resources/ssh-packets/gcm/openssh/s2c.iv.bin b/src/test/resources/ssh-packets/gcm/openssh/s2c.iv.bin new file mode 100644 index 00000000..276ab9b8 --- /dev/null +++ b/src/test/resources/ssh-packets/gcm/openssh/s2c.iv.bin @@ -0,0 +1 @@ +K䪳@B/Y*Psu"]0tE `erlںM~OEδ\'` \ No newline at end of file diff --git a/src/test/resources/ssh-packets/gcm/openssh/s2c.key.bin b/src/test/resources/ssh-packets/gcm/openssh/s2c.key.bin new file mode 100644 index 00000000..07f7dfd4 Binary files /dev/null and b/src/test/resources/ssh-packets/gcm/openssh/s2c.key.bin differ