diff --git a/README.adoc b/README.adoc index 27815f523..84d68c6b1 100644 --- a/README.adoc +++ b/README.adoc @@ -64,7 +64,7 @@ In the `examples` directory, there is a separate Maven project that shows how th Implementations / adapters for the following algorithms are included: ciphers:: - `aes{128,192,256}-{cbc,ctr}`, `aes{128,256}-gcm@openssh.com`, `blowfish-{cbc,ctr}`, `3des-{cbc,ctr}`, `twofish{128,192,256}-{cbc,ctr}`, `twofish-cbc`, `serpent{128,192,256}-{cbc,ctr}`, `idea-{cbc,ctr}`, `cast128-{cbc,ctr}`, `arcfour`, `arcfour{128,256}` + `aes{128,192,256}-{cbc,ctr}`, `aes{128,256}-gcm@openssh.com`, `blowfish-{cbc,ctr}`, `chacha20-poly1305@openssh.com`, `3des-{cbc,ctr}`, `twofish{128,192,256}-{cbc,ctr}`, `twofish-cbc`, `serpent{128,192,256}-{cbc,ctr}`, `idea-{cbc,ctr}`, `cast128-{cbc,ctr}`, `arcfour`, `arcfour{128,256}` SSHJ also supports the following extended (non official) ciphers: `camellia{128,192,256}-{cbc,ctr}`, `camellia{128,192,256}-{cbc,ctr}@openssh.org` key exchange:: diff --git a/src/itest/docker-image/test-container/sshd_config b/src/itest/docker-image/test-container/sshd_config index 48a513319..426c8414c 100644 --- a/src/itest/docker-image/test-container/sshd_config +++ b/src/itest/docker-image/test-container/sshd_config @@ -133,4 +133,4 @@ macs umac-64-etm@openssh.com,umac-128-etm@openssh.com,hmac-sha2-256-etm@openssh. 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 +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,chacha20-poly1305@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 index 76461aee5..5f3c8c755 100644 --- a/src/itest/groovy/com/hierynomus/sshj/transport/cipher/CipherSpec.groovy +++ b/src/itest/groovy/com/hierynomus/sshj/transport/cipher/CipherSpec.groovy @@ -47,7 +47,8 @@ class CipherSpec extends IntegrationBaseSpec { BlockCiphers.AES256CBC(), BlockCiphers.AES256CTR(), GcmCiphers.AES128GCM(), - GcmCiphers.AES256GCM()] + GcmCiphers.AES256GCM(), + ChachaPolyCiphers.CHACHA_POLY_OPENSSH()] cipher = cipherFactory.name } diff --git a/src/main/java/com/hierynomus/sshj/transport/cipher/ChachaPolyCipher.java b/src/main/java/com/hierynomus/sshj/transport/cipher/ChachaPolyCipher.java new file mode 100644 index 000000000..42a296751 --- /dev/null +++ b/src/main/java/com/hierynomus/sshj/transport/cipher/ChachaPolyCipher.java @@ -0,0 +1,165 @@ +/* + * 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 java.security.GeneralSecurityException; +import java.security.InvalidAlgorithmParameterException; +import java.security.InvalidKeyException; + +import java.security.spec.AlgorithmParameterSpec; +import java.util.Arrays; +import javax.crypto.spec.IvParameterSpec; + +import net.schmizz.sshj.common.SSHRuntimeException; +import net.schmizz.sshj.common.SecurityUtils; +import net.schmizz.sshj.transport.cipher.BaseCipher; + +public class ChachaPolyCipher extends BaseCipher { + + private static final int CHACHA_KEY_SIZE = 32; + private static final int AAD_LENGTH = 4; + private static final int POLY_TAG_LENGTH = 16; + + private static final String CIPHER_CHACHA = "CHACHA"; + private static final String MAC_POLY1305 = "POLY1305"; + + private static final byte[] POLY_KEY_INPUT = new byte[32]; + + private final int authSize; + + private byte[] encryptedAad; + + protected Mode mode; + protected javax.crypto.Cipher aadCipher; + protected javax.crypto.Mac mac; + protected java.security.Key cipherKey; + protected java.security.Key aadCipherKey; + + public ChachaPolyCipher(int authSize, int bsize, String algorithm) { + super(0, bsize, algorithm, CIPHER_CHACHA); + this.authSize = authSize; + } + + @Override + public int getAuthenticationTagSize() { + return authSize; + } + + @Override + public void setSequenceNumber(long seq) { + byte[] seqAsBytes = longToBytes(seq); + AlgorithmParameterSpec ivSpec = new IvParameterSpec(seqAsBytes); + + try { + cipher.init(getMode(mode), cipherKey, ivSpec); + aadCipher.init(getMode(mode), aadCipherKey, ivSpec); + } catch (GeneralSecurityException e) { + throw new SSHRuntimeException(e); + } + + byte[] polyKeyBytes = cipher.update(POLY_KEY_INPUT); + cipher.update(POLY_KEY_INPUT); // this update is required to set the block counter of ChaCha to 1 + try { + mac.init(getKeySpec(polyKeyBytes)); + } catch (GeneralSecurityException e) { + throw new SSHRuntimeException(e); + } + + encryptedAad = null; + } + + @Override + protected void initCipher(javax.crypto.Cipher cipher, Mode mode, byte[] key, byte[] iv) + throws InvalidKeyException, InvalidAlgorithmParameterException { + this.mode = mode; + + cipherKey = getKeySpec(Arrays.copyOfRange(key, 0, CHACHA_KEY_SIZE)); + aadCipherKey = getKeySpec(Arrays.copyOfRange(key, CHACHA_KEY_SIZE, 2 * CHACHA_KEY_SIZE)); + + try { + aadCipher = SecurityUtils.getCipher(CIPHER_CHACHA); + mac = SecurityUtils.getMAC(MAC_POLY1305); + } catch (GeneralSecurityException e) { + cipher = null; + aadCipher = null; + mac = null; + throw new SSHRuntimeException(e); + } + + setSequenceNumber(0); + } + + @Override + public void updateAAD(byte[] data, int offset, int length) { + if (offset != 0 || length != AAD_LENGTH) { + throw new IllegalArgumentException( + String.format("updateAAD called with offset %d and length %d", offset, length)); + } + + if (mode == Mode.Decrypt) { + encryptedAad = Arrays.copyOfRange(data, 0, AAD_LENGTH); + } + + try { + aadCipher.update(data, 0, AAD_LENGTH, data, 0); + } catch (GeneralSecurityException e) { + throw new SSHRuntimeException("Error updating data through cipher", e); + } + } + + @Override + public void updateAAD(byte[] data) { + updateAAD(data, 0, AAD_LENGTH); + } + + @Override + public void update(byte[] input, int inputOffset, int inputLen) { + if (inputOffset != AAD_LENGTH) { + throw new IllegalArgumentException("updateAAD called with inputOffset " + inputOffset); + } + + final int macInputLength = AAD_LENGTH + inputLen; + + if (mode == Mode.Decrypt) { + byte[] macInput = new byte[macInputLength]; + System.arraycopy(encryptedAad, 0, macInput, 0, AAD_LENGTH); + System.arraycopy(input, AAD_LENGTH, macInput, AAD_LENGTH, inputLen); + + byte[] expectedPolyTag = mac.doFinal(macInput); + byte[] actualPolyTag = Arrays.copyOfRange(input, macInputLength, macInputLength + POLY_TAG_LENGTH); + if (!Arrays.equals(actualPolyTag, expectedPolyTag)) { + throw new SSHRuntimeException("MAC Error"); + } + } + + try { + cipher.update(input, AAD_LENGTH, inputLen, input, AAD_LENGTH); + } catch (GeneralSecurityException e) { + throw new SSHRuntimeException("Error updating data through cipher", e); + } + + if (mode == Mode.Encrypt) { + byte[] macInput = Arrays.copyOf(input, macInputLength); + byte[] polyTag = mac.doFinal(macInput); + System.arraycopy(polyTag, 0, input, macInputLength, POLY_TAG_LENGTH); + } + } + + private byte[] longToBytes(long lng) { + return new byte[] { (byte) (lng >> 56), (byte) (lng >> 48), (byte) (lng >> 40), (byte) (lng >> 32), + (byte) (lng >> 24), (byte) (lng >> 16), (byte) (lng >> 8), (byte) lng }; + } +} diff --git a/src/main/java/com/hierynomus/sshj/transport/cipher/ChachaPolyCiphers.java b/src/main/java/com/hierynomus/sshj/transport/cipher/ChachaPolyCiphers.java new file mode 100644 index 000000000..2585d8e76 --- /dev/null +++ b/src/main/java/com/hierynomus/sshj/transport/cipher/ChachaPolyCiphers.java @@ -0,0 +1,56 @@ +/* + * 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 ChachaPolyCiphers { + + public static Factory CHACHA_POLY_OPENSSH() { + return new Factory(16, 512, "chacha20-poly1305@openssh.com", "ChaCha20"); + } + + public static class Factory + implements net.schmizz.sshj.common.Factory.Named { + + private final int authSize; + private final int keySize; + private final String name; + private final String cipher; + + public Factory(int authSize, int keySize, String name, String cipher) { + this.authSize = authSize; + this.keySize = keySize; + this.name = name; + this.cipher = cipher; + } + + @Override + public Cipher create() { + return new ChachaPolyCipher(authSize, keySize / 8, cipher); + } + + @Override + public String getName() { + return name; + } + + @Override + public String toString() { + return getName(); + } + } +} diff --git a/src/main/java/net/schmizz/sshj/transport/Decoder.java b/src/main/java/net/schmizz/sshj/transport/Decoder.java index 02309c1b8..39efdc720 100644 --- a/src/main/java/net/schmizz/sshj/transport/Decoder.java +++ b/src/main/java/net/schmizz/sshj/transport/Decoder.java @@ -145,6 +145,7 @@ private SSHPacket decompressed() } private int decryptLengthAAD() throws TransportException { + cipher.setSequenceNumber(seq + 1 & 0xffffffffL); cipher.updateAAD(inputBuffer.array(), 0, 4); final int len; @@ -185,10 +186,6 @@ private void checkPacketLength(int len) throws TransportException { } } -// private void decryptPayload(final byte[] data, int offset, int length) { -// cipher.update(data, cipherSize, packetLength + 4 - cipherSize); -// } - /** * Adds {@code len} bytes from {@code b} to the decoder buffer. When a packet has been successfully decoded, hooks * in to {@link SSHPacketHandler#handle} of the {@link SSHPacketHandler} this decoder was initialized with. diff --git a/src/main/java/net/schmizz/sshj/transport/Encoder.java b/src/main/java/net/schmizz/sshj/transport/Encoder.java index f9cc17499..2ecaaa315 100644 --- a/src/main/java/net/schmizz/sshj/transport/Encoder.java +++ b/src/main/java/net/schmizz/sshj/transport/Encoder.java @@ -57,8 +57,6 @@ private void putMAC(SSHPacket buffer, int startOfPacket, int endOfPadding) { * @param buffer the buffer to encode * * @return the sequence no. of encoded packet - * - * @throws TransportException */ long encode(SSHPacket buffer) { encodeLock.lock(); @@ -140,11 +138,12 @@ long encode(SSHPacket buffer) { } } - protected void aeadOutgoingBuffer(Buffer buf, int offset, int len) { + 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.setSequenceNumber(seq); cipher.updateWithAAD(data, offset, 4, len); } 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 b50f18c12..0b144a52d 100644 --- a/src/main/java/net/schmizz/sshj/transport/cipher/BaseCipher.java +++ b/src/main/java/net/schmizz/sshj/transport/cipher/BaseCipher.java @@ -113,4 +113,9 @@ public void updateWithAAD(byte[] input, int offset, int aadLen, int inputLen) { updateAAD(input, offset, aadLen); update(input, offset + aadLen, inputLen); } + + @Override + public void setSequenceNumber(long seq) { + + } } 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 c7f0ad739..8b191a213 100644 --- a/src/main/java/net/schmizz/sshj/transport/cipher/Cipher.java +++ b/src/main/java/net/schmizz/sshj/transport/cipher/Cipher.java @@ -78,4 +78,6 @@ enum Mode { * @param inputLen The number of bytes to update - starting at offset + aadLen */ void updateWithAAD(byte[] input, int offset, int aadLen, int inputLen); + + void setSequenceNumber(long seq); } 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 246bf7074..559b31edc 100644 --- a/src/main/java/net/schmizz/sshj/transport/cipher/NoneCipher.java +++ b/src/main/java/net/schmizz/sshj/transport/cipher/NoneCipher.java @@ -73,4 +73,9 @@ public void updateAAD(byte[] data) { public void updateWithAAD(byte[] input, int offset, int aadLen, int inputLen) { } + + @Override + public void setSequenceNumber(long seq) { + + } } diff --git a/src/test/java/com/hierynomus/sshj/transport/ChachaPolyCipherTest.java b/src/test/java/com/hierynomus/sshj/transport/ChachaPolyCipherTest.java new file mode 100644 index 000000000..6a281176f --- /dev/null +++ b/src/test/java/com/hierynomus/sshj/transport/ChachaPolyCipherTest.java @@ -0,0 +1,103 @@ +/* + * 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 java.nio.charset.StandardCharsets; +import java.util.Arrays; + +import com.hierynomus.sshj.transport.cipher.ChachaPolyCiphers; +import net.schmizz.sshj.common.SSHRuntimeException; +import net.schmizz.sshj.transport.cipher.Cipher; +import org.junit.Test; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.fail; + +public class ChachaPolyCipherTest { + + private static final int AAD_LENGTH = 4; + private static final int POLY_TAG_LENGTH = 16; + + private static final ChachaPolyCiphers.Factory FACTORY = ChachaPolyCiphers.CHACHA_POLY_OPENSSH(); + private static final String PLAINTEXT = "[Secret authenticated message using Chacha20Poly1305"; + + @Test + public void testEncryptDecrypt() { + Cipher enc = FACTORY.create(); + byte[] key = new byte[enc.getBlockSize()]; + Arrays.fill(key, (byte) 1); + enc.init(Cipher.Mode.Encrypt, key, new byte[0]); + + byte[] aad = new byte[AAD_LENGTH]; + byte[] ptBytes = PLAINTEXT.getBytes(StandardCharsets.UTF_8); + byte[] message = new byte[AAD_LENGTH + ptBytes.length + POLY_TAG_LENGTH]; + Arrays.fill(aad, (byte) 2); + System.arraycopy(aad, 0, message, 0, AAD_LENGTH); + System.arraycopy(ptBytes, 0, message, AAD_LENGTH, ptBytes.length); + + enc.updateWithAAD(message, 0, AAD_LENGTH, ptBytes.length); + byte[] corrupted = message.clone(); + + Cipher dec = FACTORY.create(); + dec.init(Cipher.Mode.Decrypt, key, new byte[0]); + dec.updateWithAAD(message, 0, AAD_LENGTH, ptBytes.length); + + assertArrayEquals(aad, Arrays.copyOf(message, AAD_LENGTH)); + String decodedString = + new String(Arrays.copyOfRange(message, AAD_LENGTH, AAD_LENGTH + ptBytes.length), StandardCharsets.UTF_8); + assertEquals(PLAINTEXT, decodedString); + + corrupted[corrupted.length - 1] += 1; + Cipher failingDec = FACTORY.create(); + failingDec.init(Cipher.Mode.Decrypt, key, new byte[0]); + try { + failingDec.updateWithAAD(corrupted, 0, AAD_LENGTH, ptBytes.length); + fail("Modified authentication tag should not validate"); + } catch (SSHRuntimeException e) { + assertEquals("MAC Error", e.getMessage()); + } + } + + @Test + public void testCheckOnUpdateParameters() { + Cipher cipher = FACTORY.create(); + try { + cipher.update(null, 8, 42); + fail("Invalid inputOffset should trigger exception"); + } catch (IllegalArgumentException e) { + assertEquals("updateAAD called with inputOffset 8", e.getMessage()); + } + } + + @Test + public void testCheckOnUpdateAADParameters() { + Cipher cipher = FACTORY.create(); + try { + cipher.updateAAD(null, 1, AAD_LENGTH); + fail("Invalid offset should trigger exception"); + } catch (IllegalArgumentException e) { + assertEquals("updateAAD called with offset 1 and length 4", e.getMessage()); + } + + try { + cipher.updateAAD(null, 0, 5); + fail("Invalid length should trigger exception"); + } catch (IllegalArgumentException e) { + assertEquals("updateAAD called with offset 0 and length 5", e.getMessage()); + } + } +}