From 5e8aadb87ad47c748bd465060e9f204408e3795d Mon Sep 17 00:00:00 2001 From: Paul Gregoire Date: Fri, 11 Sep 2026 13:22:05 -0700 Subject: [PATCH 1/6] Add the DTLS 1.3 ack content type (RFC 9147 7.1), relates to github #1468. --- tls/src/main/java/org/bouncycastle/tls/ContentType.java | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tls/src/main/java/org/bouncycastle/tls/ContentType.java b/tls/src/main/java/org/bouncycastle/tls/ContentType.java index 0ed5167555..5631cad473 100644 --- a/tls/src/main/java/org/bouncycastle/tls/ContentType.java +++ b/tls/src/main/java/org/bouncycastle/tls/ContentType.java @@ -11,6 +11,8 @@ public class ContentType public static final short application_data = 23; public static final short heartbeat = 24; public static final short tls12_cid = 25; + /** RFC 9147 7.1 */ + public static final short ack = 26; public static String getName(short contentType) { @@ -27,7 +29,9 @@ public static String getName(short contentType) case heartbeat: return "heartbeat"; case tls12_cid: - return "tls12_cid"; + return "tls12_cid"; + case ack: + return "ack"; default: return "UNKNOWN"; } From 92a868c10880d91de587ba0a00f9712eef32fd18 Mon Sep 17 00:00:00 2001 From: Paul Gregoire Date: Fri, 11 Sep 2026 13:22:05 -0700 Subject: [PATCH 2/6] Add DTLS 1.3 record number encryption mask primitive for AES and ChaCha20 (RFC 9147 4.2.3), relates to github #1468. --- .../tls/crypto/impl/TlsRecordNumberMask.java | 25 ++++ .../impl/bc/BcTlsAESRecordNumberMask.java | 47 ++++++++ .../bc/BcTlsChaCha20RecordNumberMask.java | 55 +++++++++ .../impl/jcajce/JceAESRecordNumberMask.java | 54 +++++++++ .../jcajce/JceChaCha20RecordNumberMask.java | 58 ++++++++++ .../java/org/bouncycastle/tls/AllTests.java | 1 + .../tls/DTLSRecordNumberMaskTest.java | 108 ++++++++++++++++++ 7 files changed, 348 insertions(+) create mode 100644 tls/src/main/java/org/bouncycastle/tls/crypto/impl/TlsRecordNumberMask.java create mode 100644 tls/src/main/java/org/bouncycastle/tls/crypto/impl/bc/BcTlsAESRecordNumberMask.java create mode 100644 tls/src/main/java/org/bouncycastle/tls/crypto/impl/bc/BcTlsChaCha20RecordNumberMask.java create mode 100644 tls/src/main/java/org/bouncycastle/tls/crypto/impl/jcajce/JceAESRecordNumberMask.java create mode 100644 tls/src/main/java/org/bouncycastle/tls/crypto/impl/jcajce/JceChaCha20RecordNumberMask.java create mode 100644 tls/src/test/java/org/bouncycastle/tls/DTLSRecordNumberMaskTest.java diff --git a/tls/src/main/java/org/bouncycastle/tls/crypto/impl/TlsRecordNumberMask.java b/tls/src/main/java/org/bouncycastle/tls/crypto/impl/TlsRecordNumberMask.java new file mode 100644 index 0000000000..2c00169c1a --- /dev/null +++ b/tls/src/main/java/org/bouncycastle/tls/crypto/impl/TlsRecordNumberMask.java @@ -0,0 +1,25 @@ +package org.bouncycastle.tls.crypto.impl; + +import java.io.IOException; + +/** + * Record number encryption mask generator for DTLS 1.3 (RFC 9147 4.2.3). + *

+ * The mask is derived from the first 16 bytes of the protected record using the block function underlying the + * negotiated AEAD (AES-ECB for AES-based AEADs, the ChaCha20 block function for ChaCha20-Poly1305), and is XORed + * with the sequence number bytes of the unified header. + *

+ */ +public interface TlsRecordNumberMask +{ + /** + * Set the sequence number key ("sn" traffic key) for this direction. + */ + void setKey(byte[] key, int keyOff, int keyLen) throws IOException; + + /** + * Generate the 16-byte mask for a record whose ciphertext starts at 'ciphertextOff'. At least 16 bytes of + * ciphertext must be available. The mask is written to mask[maskOff..maskOff + 16). + */ + void generateMask(byte[] ciphertext, int ciphertextOff, byte[] mask, int maskOff) throws IOException; +} diff --git a/tls/src/main/java/org/bouncycastle/tls/crypto/impl/bc/BcTlsAESRecordNumberMask.java b/tls/src/main/java/org/bouncycastle/tls/crypto/impl/bc/BcTlsAESRecordNumberMask.java new file mode 100644 index 0000000000..1021ecaa52 --- /dev/null +++ b/tls/src/main/java/org/bouncycastle/tls/crypto/impl/bc/BcTlsAESRecordNumberMask.java @@ -0,0 +1,47 @@ +package org.bouncycastle.tls.crypto.impl.bc; + +import java.io.IOException; + +import org.bouncycastle.crypto.BlockCipher; +import org.bouncycastle.crypto.params.KeyParameter; +import org.bouncycastle.tls.AlertDescription; +import org.bouncycastle.tls.TlsFatalAlert; +import org.bouncycastle.tls.crypto.impl.TlsRecordNumberMask; + +/** + * RFC 9147 4.2.3: Mask = AES-ECB(sn_key, Ciphertext[0..15]). + */ +public class BcTlsAESRecordNumberMask + implements TlsRecordNumberMask +{ + private final BlockCipher cipher; + + public BcTlsAESRecordNumberMask(BlockCipher cipher) + { + this.cipher = cipher; + } + + public void setKey(byte[] key, int keyOff, int keyLen) throws IOException + { + try + { + cipher.init(true, new KeyParameter(key, keyOff, keyLen)); + } + catch (RuntimeException e) + { + throw new TlsFatalAlert(AlertDescription.internal_error, e); + } + } + + public void generateMask(byte[] ciphertext, int ciphertextOff, byte[] mask, int maskOff) throws IOException + { + try + { + cipher.processBlock(ciphertext, ciphertextOff, mask, maskOff); + } + catch (RuntimeException e) + { + throw new TlsFatalAlert(AlertDescription.internal_error, e); + } + } +} diff --git a/tls/src/main/java/org/bouncycastle/tls/crypto/impl/bc/BcTlsChaCha20RecordNumberMask.java b/tls/src/main/java/org/bouncycastle/tls/crypto/impl/bc/BcTlsChaCha20RecordNumberMask.java new file mode 100644 index 0000000000..50dd3d7385 --- /dev/null +++ b/tls/src/main/java/org/bouncycastle/tls/crypto/impl/bc/BcTlsChaCha20RecordNumberMask.java @@ -0,0 +1,55 @@ +package org.bouncycastle.tls.crypto.impl.bc; + +import java.io.IOException; + +import org.bouncycastle.crypto.engines.ChaCha7539Engine; +import org.bouncycastle.crypto.params.KeyParameter; +import org.bouncycastle.crypto.params.ParametersWithIV; +import org.bouncycastle.tls.AlertDescription; +import org.bouncycastle.tls.TlsFatalAlert; +import org.bouncycastle.tls.crypto.impl.TlsRecordNumberMask; +import org.bouncycastle.util.Pack; + +/** + * RFC 9147 4.2.3: Mask = ChaCha20(sn_key, Ciphertext[0..3], Ciphertext[4..15]), i.e. the ChaCha20 block + * selected by the 32-bit little-endian counter in the first 4 ciphertext bytes and the 96-bit nonce in the + * following 12 bytes. + */ +public class BcTlsChaCha20RecordNumberMask + implements TlsRecordNumberMask +{ + private static final byte[] ZEROES = new byte[16]; + + private final ChaCha7539Engine cipher = new ChaCha7539Engine(); + + private KeyParameter key; + + public void setKey(byte[] key, int keyOff, int keyLen) throws IOException + { + this.key = new KeyParameter(key, keyOff, keyLen); + } + + public void generateMask(byte[] ciphertext, int ciphertextOff, byte[] mask, int maskOff) throws IOException + { + if (null == key) + { + throw new TlsFatalAlert(AlertDescription.internal_error); + } + + try + { + long counter = Pack.littleEndianToInt(ciphertext, ciphertextOff) & 0xFFFFFFFFL; + + byte[] nonce = new byte[12]; + System.arraycopy(ciphertext, ciphertextOff + 4, nonce, 0, 12); + + cipher.init(true, new ParametersWithIV(key, nonce)); + cipher.skip(counter * 64L); + cipher.processBytes(ZEROES, 0, 16, mask, maskOff); + } + catch (RuntimeException e) + { + throw new TlsFatalAlert(AlertDescription.internal_error, e); + } + } +} diff --git a/tls/src/main/java/org/bouncycastle/tls/crypto/impl/jcajce/JceAESRecordNumberMask.java b/tls/src/main/java/org/bouncycastle/tls/crypto/impl/jcajce/JceAESRecordNumberMask.java new file mode 100644 index 0000000000..818e5f7446 --- /dev/null +++ b/tls/src/main/java/org/bouncycastle/tls/crypto/impl/jcajce/JceAESRecordNumberMask.java @@ -0,0 +1,54 @@ +package org.bouncycastle.tls.crypto.impl.jcajce; + +import java.io.IOException; +import java.security.GeneralSecurityException; + +import javax.crypto.Cipher; +import javax.crypto.spec.SecretKeySpec; + +import org.bouncycastle.jcajce.util.JcaJceHelper; +import org.bouncycastle.tls.AlertDescription; +import org.bouncycastle.tls.TlsFatalAlert; +import org.bouncycastle.tls.crypto.impl.TlsRecordNumberMask; + +/** + * RFC 9147 4.2.3: Mask = AES-ECB(sn_key, Ciphertext[0..15]), via a JCA "AES/ECB/NoPadding" cipher. + */ +public class JceAESRecordNumberMask + implements TlsRecordNumberMask +{ + private final Cipher cipher; + + public JceAESRecordNumberMask(JcaJceHelper helper) throws GeneralSecurityException + { + this.cipher = helper.createCipher("AES/ECB/NoPadding"); + } + + public void setKey(byte[] key, int keyOff, int keyLen) throws IOException + { + try + { + cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(key, keyOff, keyLen, "AES")); + } + catch (GeneralSecurityException e) + { + throw new TlsFatalAlert(AlertDescription.internal_error, e); + } + } + + public void generateMask(byte[] ciphertext, int ciphertextOff, byte[] mask, int maskOff) throws IOException + { + try + { + int len = cipher.doFinal(ciphertext, ciphertextOff, 16, mask, maskOff); + if (16 != len) + { + throw new TlsFatalAlert(AlertDescription.internal_error); + } + } + catch (GeneralSecurityException e) + { + throw new TlsFatalAlert(AlertDescription.internal_error, e); + } + } +} diff --git a/tls/src/main/java/org/bouncycastle/tls/crypto/impl/jcajce/JceChaCha20RecordNumberMask.java b/tls/src/main/java/org/bouncycastle/tls/crypto/impl/jcajce/JceChaCha20RecordNumberMask.java new file mode 100644 index 0000000000..faa1b40ce0 --- /dev/null +++ b/tls/src/main/java/org/bouncycastle/tls/crypto/impl/jcajce/JceChaCha20RecordNumberMask.java @@ -0,0 +1,58 @@ +package org.bouncycastle.tls.crypto.impl.jcajce; + +import java.io.IOException; + +import org.bouncycastle.crypto.engines.ChaCha7539Engine; +import org.bouncycastle.crypto.params.KeyParameter; +import org.bouncycastle.crypto.params.ParametersWithIV; +import org.bouncycastle.tls.AlertDescription; +import org.bouncycastle.tls.TlsFatalAlert; +import org.bouncycastle.tls.crypto.impl.TlsRecordNumberMask; +import org.bouncycastle.util.Pack; + +/** + * RFC 9147 4.2.3: Mask = ChaCha20(sn_key, Ciphertext[0..3], Ciphertext[4..15]). + *

+ * The JCA has no ChaCha20 cipher able to position the block counter on the Java versions this module targets + * (the base sources compile for Java 8 and below; {@code ChaCha20ParameterSpec}, which carries a counter, only + * arrived in Java 11), so the lightweight engine is used here, as {@link JcaNonceGenerator} does for its DRBG. + *

+ */ +public class JceChaCha20RecordNumberMask + implements TlsRecordNumberMask +{ + private static final byte[] ZEROES = new byte[16]; + + private final ChaCha7539Engine cipher = new ChaCha7539Engine(); + + private KeyParameter key; + + public void setKey(byte[] key, int keyOff, int keyLen) throws IOException + { + this.key = new KeyParameter(key, keyOff, keyLen); + } + + public void generateMask(byte[] ciphertext, int ciphertextOff, byte[] mask, int maskOff) throws IOException + { + if (null == key) + { + throw new TlsFatalAlert(AlertDescription.internal_error); + } + + try + { + long counter = Pack.littleEndianToInt(ciphertext, ciphertextOff) & 0xFFFFFFFFL; + + byte[] nonce = new byte[12]; + System.arraycopy(ciphertext, ciphertextOff + 4, nonce, 0, 12); + + cipher.init(true, new ParametersWithIV(key, nonce)); + cipher.skip(counter * 64L); + cipher.processBytes(ZEROES, 0, 16, mask, maskOff); + } + catch (RuntimeException e) + { + throw new TlsFatalAlert(AlertDescription.internal_error, e); + } + } +} diff --git a/tls/src/test/java/org/bouncycastle/tls/AllTests.java b/tls/src/test/java/org/bouncycastle/tls/AllTests.java index 9d9671220a..d8570e229b 100644 --- a/tls/src/test/java/org/bouncycastle/tls/AllTests.java +++ b/tls/src/test/java/org/bouncycastle/tls/AllTests.java @@ -24,6 +24,7 @@ public static Test suite() suite.addTestSuite(Add13CertificateStatusTest.class); suite.addTestSuite(CheckTlsFeaturesExtensionTest.class); suite.addTestSuite(DTLSReassemblerTest.class); + suite.addTestSuite(DTLSRecordNumberMaskTest.class); suite.addTestSuite(SpreadCertificateStatusTest.class); return new BCTestSetup(suite); diff --git a/tls/src/test/java/org/bouncycastle/tls/DTLSRecordNumberMaskTest.java b/tls/src/test/java/org/bouncycastle/tls/DTLSRecordNumberMaskTest.java new file mode 100644 index 0000000000..80489c94d8 --- /dev/null +++ b/tls/src/test/java/org/bouncycastle/tls/DTLSRecordNumberMaskTest.java @@ -0,0 +1,108 @@ +package org.bouncycastle.tls; + +import java.security.SecureRandom; + +import org.bouncycastle.crypto.engines.AESEngine; +import org.bouncycastle.crypto.engines.ChaCha7539Engine; +import org.bouncycastle.crypto.params.KeyParameter; +import org.bouncycastle.crypto.params.ParametersWithIV; +import org.bouncycastle.jcajce.util.DefaultJcaJceHelper; +import org.bouncycastle.tls.crypto.impl.TlsRecordNumberMask; +import org.bouncycastle.tls.crypto.impl.bc.BcTlsAESRecordNumberMask; +import org.bouncycastle.tls.crypto.impl.bc.BcTlsChaCha20RecordNumberMask; +import org.bouncycastle.tls.crypto.impl.jcajce.JceAESRecordNumberMask; +import org.bouncycastle.tls.crypto.impl.jcajce.JceChaCha20RecordNumberMask; +import org.bouncycastle.util.Arrays; + +import junit.framework.TestCase; + +/** + * RFC 9147 4.2.3 record number encryption masks: AES-ECB over the first 16 ciphertext bytes, or the ChaCha20 + * block selected by the first 4 ciphertext bytes (counter) and the next 12 (nonce). Both backends must agree + * with an independent lightweight reference. + */ +public class DTLSRecordNumberMaskTest + extends TestCase +{ + private static final SecureRandom RANDOM = new SecureRandom(); + + public void testAESMaskMatchesECBReference() throws Exception + { + byte[] key = new byte[16]; + byte[] ciphertext = new byte[40]; + RANDOM.nextBytes(key); + RANDOM.nextBytes(ciphertext); + + AESEngine reference = new AESEngine(); + reference.init(true, new KeyParameter(key)); + byte[] expected = new byte[16]; + reference.processBlock(ciphertext, 8, expected, 0); + + TlsRecordNumberMask bc = new BcTlsAESRecordNumberMask(new AESEngine()); + bc.setKey(key, 0, key.length); + byte[] actualBc = new byte[16]; + bc.generateMask(ciphertext, 8, actualBc, 0); + assertTrue(Arrays.areEqual(expected, actualBc)); + + TlsRecordNumberMask jce = new JceAESRecordNumberMask(new DefaultJcaJceHelper()); + jce.setKey(key, 0, key.length); + byte[] actualJce = new byte[16]; + jce.generateMask(ciphertext, 8, actualJce, 0); + assertTrue(Arrays.areEqual(expected, actualJce)); + } + + public void testChaCha20MaskMatchesKeystreamReference() throws Exception + { + byte[] key = new byte[32]; + byte[] ciphertext = new byte[32]; + RANDOM.nextBytes(key); + RANDOM.nextBytes(ciphertext); + + // counter = 3 (little-endian), so the mask is keystream bytes [192, 208) + ciphertext[0] = 3; + ciphertext[1] = 0; + ciphertext[2] = 0; + ciphertext[3] = 0; + + byte[] nonce = Arrays.copyOfRange(ciphertext, 4, 16); + ChaCha7539Engine reference = new ChaCha7539Engine(); + reference.init(true, new ParametersWithIV(new KeyParameter(key), nonce)); + byte[] stream = new byte[3 * 64 + 16]; + reference.processBytes(stream, 0, stream.length, stream, 0); + byte[] expected = Arrays.copyOfRange(stream, 192, 208); + + TlsRecordNumberMask bc = new BcTlsChaCha20RecordNumberMask(); + bc.setKey(key, 0, key.length); + byte[] actualBc = new byte[16]; + bc.generateMask(ciphertext, 0, actualBc, 0); + assertTrue(Arrays.areEqual(expected, actualBc)); + + TlsRecordNumberMask jce = new JceChaCha20RecordNumberMask(); + jce.setKey(key, 0, key.length); + byte[] actualJce = new byte[16]; + jce.generateMask(ciphertext, 0, actualJce, 0); + assertTrue(Arrays.areEqual(expected, actualJce)); + } + + public void testChaCha20MaskLargeCounter() throws Exception + { + byte[] key = new byte[32]; + byte[] ciphertext = new byte[16]; + RANDOM.nextBytes(key); + RANDOM.nextBytes(ciphertext); + ciphertext[3] = (byte)0xFF; // counter near 2^32 + + TlsRecordNumberMask bc = new BcTlsChaCha20RecordNumberMask(); + bc.setKey(key, 0, key.length); + byte[] a = new byte[16]; + bc.generateMask(ciphertext, 0, a, 0); + + TlsRecordNumberMask jce = new JceChaCha20RecordNumberMask(); + jce.setKey(key, 0, key.length); + byte[] b = new byte[16]; + jce.generateMask(ciphertext, 0, b, 0); + + assertTrue(Arrays.areEqual(a, b)); + assertFalse(Arrays.areEqual(new byte[16], a)); + } +} From db62603c14d46539f6bbae6229c81bc9d540e387 Mon Sep 17 00:00:00 2001 From: Paul Gregoire Date: Fri, 11 Sep 2026 13:22:05 -0700 Subject: [PATCH 3/6] Add DTLS 1.3 record protection to TlsAEADCipher via a new TlsDTLS13Cipher interface, relates to github #1468. --- .../tls/crypto/TlsDTLS13Cipher.java | 57 ++++ .../tls/crypto/impl/TlsAEADCipher.java | 269 ++++++++++++++- .../tls/crypto/impl/bc/BcTlsCrypto.java | 12 +- .../tls/crypto/impl/jcajce/JcaTlsCrypto.java | 12 +- .../java/org/bouncycastle/tls/AllTests.java | 1 + .../tls/TlsAEADCipherDTLS13Test.java | 318 ++++++++++++++++++ 6 files changed, 653 insertions(+), 16 deletions(-) create mode 100644 tls/src/main/java/org/bouncycastle/tls/crypto/TlsDTLS13Cipher.java create mode 100644 tls/src/test/java/org/bouncycastle/tls/TlsAEADCipherDTLS13Test.java diff --git a/tls/src/main/java/org/bouncycastle/tls/crypto/TlsDTLS13Cipher.java b/tls/src/main/java/org/bouncycastle/tls/crypto/TlsDTLS13Cipher.java new file mode 100644 index 0000000000..8c0eb5a54a --- /dev/null +++ b/tls/src/main/java/org/bouncycastle/tls/crypto/TlsDTLS13Cipher.java @@ -0,0 +1,57 @@ +package org.bouncycastle.tls.crypto; + +import java.io.IOException; + +/** + * The optional DTLS 1.3 (RFC 9147) record protection extension to {@link TlsCipher}, implemented by ciphers + * that can protect DTLS 1.3 records. + */ +public interface TlsDTLS13Cipher +{ + /** + * Encode the passed in plaintext as a DTLS 1.3 protected record (RFC 9147 4). The supplied unified header is + * the AEAD additional data; when its L bit is set the (zeroed) length field is filled in by this method. The + * returned record holds the header, with record number encryption (RFC 9147 4.2.3) applied to its sequence + * number bytes, followed by the encrypted record. + * + * @param seqNo the 64-bit record sequence number (the epoch is not included, unlike DTLS 1.2). + * @param contentType the true content type, written into the DTLSInnerPlaintext. + * @param header array holding the unified header with the sequence number in the clear. + * @param headerOff offset of the header in the array. + * @param headerLen length of the header. + * @param plaintext array holding input plaintext to the cipher. + * @param offset offset into input array the plaintext starts at. + * @param len length of the plaintext in the array. + * @return A {@link TlsEncodeResult} whose buffer holds the complete record (header followed by ciphertext), + * with 'recordType' set to the first byte of the unified header. + * @throws IOException + */ + TlsEncodeResult encodeDTLS13Plaintext(long seqNo, short contentType, byte[] header, int headerOff, int headerLen, + byte[] plaintext, int offset, int len) throws IOException; + + /** + * Decrypt (in place) the record number of a received DTLS 1.3 protected record (RFC 9147 4.2.3). The record + * starts with its unified header; at least 16 bytes of ciphertext must follow the header. + * + * @param record array holding the received record. + * @param recordOff offset of the record (its unified header) in the array. + * @param recordLen length of the record. + * @throws IOException + */ + void decryptDTLS13RecordNumber(byte[] record, int recordOff, int recordLen) throws IOException; + + /** + * Decode a received DTLS 1.3 protected record. The unified header (with the sequence number already decrypted) + * at 'recordOff' is the AEAD additional data, and the ciphertext immediately follows it. + * + * @param seqNo the 64-bit record sequence number reconstructed by the caller (the epoch is not included). + * @param record array holding the received record. + * @param recordOff offset of the record (its unified header) in the array. + * @param headerLen length of the unified header. + * @param ciphertextLen length of the ciphertext following the header. + * @return A {@link TlsDecodeResult} containing the result of decoding. + * @throws IOException + */ + TlsDecodeResult decodeDTLS13Ciphertext(long seqNo, byte[] record, int recordOff, int headerLen, int ciphertextLen) + throws IOException; +} diff --git a/tls/src/main/java/org/bouncycastle/tls/crypto/impl/TlsAEADCipher.java b/tls/src/main/java/org/bouncycastle/tls/crypto/impl/TlsAEADCipher.java index 92cad80395..91c7893aed 100644 --- a/tls/src/main/java/org/bouncycastle/tls/crypto/impl/TlsAEADCipher.java +++ b/tls/src/main/java/org/bouncycastle/tls/crypto/impl/TlsAEADCipher.java @@ -11,16 +11,17 @@ import org.bouncycastle.tls.crypto.TlsCipher; import org.bouncycastle.tls.crypto.TlsCryptoParameters; import org.bouncycastle.tls.crypto.TlsCryptoUtils; +import org.bouncycastle.tls.crypto.TlsDTLS13Cipher; import org.bouncycastle.tls.crypto.TlsDecodeResult; import org.bouncycastle.tls.crypto.TlsEncodeResult; import org.bouncycastle.tls.crypto.TlsSecret; import org.bouncycastle.util.Arrays; /** - * A generic TLS 1.2 AEAD cipher. + * A generic AEAD cipher, covering TLS 1.2, TLS 1.3 and DTLS 1.3 record protection. */ public final class TlsAEADCipher - implements TlsCipher + implements TlsCipher, TlsDTLS13Cipher { public static final int AEAD_CCM = 1; public static final int AEAD_CHACHA20_POLY1305 = 2; @@ -32,6 +33,13 @@ public final class TlsAEADCipher private static final byte[] EPOCH_1 = { 0x00, 0x01 }; + private static final int DTLS13_FIXED_BITS = 0x20; + private static final int DTLS13_FIXED_BITS_MASK = 0xE0; + private static final int DTLS13_FLAG_CID = 0x10; + private static final int DTLS13_FLAG_SEQ16 = 0x08; + private static final int DTLS13_FLAG_LENGTH = 0x04; + private static final int DTLS13_MIN_CIPHERTEXT_LENGTH = 16; + private final TlsCryptoParameters cryptoParams; private final int keySize; private final int macSize; @@ -47,6 +55,9 @@ public final class TlsAEADCipher private final int nonceMode; private final AEADNonceGenerator nonceGenerator; + private final boolean isDTLSv13; + private final TlsRecordNumberMask decryptMask, encryptMask; + /** @deprecated Use version with extra 'nonceGeneratorFactory' parameter */ @Deprecated @SuppressWarnings("InlineMeSuggester") @@ -59,6 +70,20 @@ public TlsAEADCipher(TlsCryptoParameters cryptoParams, TlsAEADCipherImpl encrypt public TlsAEADCipher(TlsCryptoParameters cryptoParams, TlsAEADCipherImpl encryptCipher, TlsAEADCipherImpl decryptCipher, int keySize, int macSize, int aeadType, AEADNonceGeneratorFactory nonceGeneratorFactory) throws IOException + { + this(cryptoParams, encryptCipher, decryptCipher, keySize, macSize, aeadType, nonceGeneratorFactory, null, + null); + } + + /** + * @param encryptMask record number encryption mask for the sending direction (DTLS 1.3 only, may be null + * when DTLS 1.3 will not be negotiated). + * @param decryptMask record number encryption mask for the receiving direction (DTLS 1.3 only). + */ + public TlsAEADCipher(TlsCryptoParameters cryptoParams, TlsAEADCipherImpl encryptCipher, + TlsAEADCipherImpl decryptCipher, int keySize, int macSize, int aeadType, + AEADNonceGeneratorFactory nonceGeneratorFactory, TlsRecordNumberMask encryptMask, + TlsRecordNumberMask decryptMask) throws IOException { final SecurityParameters securityParameters = cryptoParams.getSecurityParametersHandshake(); final ProtocolVersion negotiatedVersion = securityParameters.getNegotiatedVersion(); @@ -69,8 +94,12 @@ public TlsAEADCipher(TlsCryptoParameters cryptoParams, TlsAEADCipherImpl encrypt } this.isTLSv13 = TlsImplUtils.isTLSv13(negotiatedVersion); + this.isDTLSv13 = isTLSv13 && negotiatedVersion.isDTLS(); this.nonceMode = getNonceMode(isTLSv13, aeadType); + this.encryptMask = encryptMask; + this.decryptMask = decryptMask; + decryptConnectionID = securityParameters.getConnectionIDPeer(); encryptConnectionID = securityParameters.getConnectionIDLocal(); @@ -105,8 +134,8 @@ public TlsAEADCipher(TlsCryptoParameters cryptoParams, TlsAEADCipherImpl encrypt if (isTLSv13) { nonceGenerator = null; - rekeyCipher(securityParameters, decryptCipher, decryptNonce, !isServer); - rekeyCipher(securityParameters, encryptCipher, encryptNonce, isServer); + rekeyCipher(securityParameters, decryptCipher, decryptNonce, decryptMask, !isServer); + rekeyCipher(securityParameters, encryptCipher, encryptNonce, encryptMask, isServer); return; } @@ -346,14 +375,220 @@ public TlsDecodeResult decodeCiphertext(long seqNo, short recordType, ProtocolVe return new TlsDecodeResult(ciphertext, encryptionOffset, plaintextLength, contentType); } + public TlsEncodeResult encodeDTLS13Plaintext(long seqNo, short contentType, byte[] header, int headerOff, + int headerLen, byte[] plaintext, int plaintextOffset, int plaintextLength) throws IOException + { + if (!isDTLSv13) + { + throw new TlsFatalAlert(AlertDescription.internal_error); + } + + int firstByte = header[headerOff] & 0xFF; + int cidLength = getDTLS13HeaderConnectionIDLength(firstByte, encryptConnectionID); + int seqNumOff = 1 + cidLength; + int seqNumLen = getDTLS13SequenceNumberLength(firstByte); + boolean hasLength = (firstByte & DTLS13_FLAG_LENGTH) != 0; + int expectedHeaderLen = seqNumOff + seqNumLen + (hasLength ? 2 : 0); + if (headerLen != expectedHeaderLen) + { + throw new TlsFatalAlert(AlertDescription.internal_error); + } + + /* + * RFC 9147 4.2.3. Senders MUST pad short plaintexts out [...] in order to make a suitable-length + * ciphertext (at least 16 bytes, for record number encryption). + */ + int innerPlaintextLength = plaintextLength + 1; + int minInnerPlaintextLength = DTLS13_MIN_CIPHERTEXT_LENGTH - macSize; + if (innerPlaintextLength < minInnerPlaintextLength) + { + innerPlaintextLength = minInnerPlaintextLength; + } + + byte[] nonce = createDTLS13Nonce(encryptNonce, seqNo); + + encryptCipher.init(nonce, macSize); + + int ciphertextLength = encryptCipher.getOutputSize(innerPlaintextLength); + TlsUtils.checkUint16(ciphertextLength); + + byte[] output = new byte[headerLen + ciphertextLength]; + System.arraycopy(header, headerOff, output, 0, headerLen); + if (hasLength) + { + TlsUtils.writeUint16(ciphertextLength, output, headerLen - 2); + } + + // RFC 9147 4. The entire header (prior to record number encryption) is the additional data. + byte[] additionalData = Arrays.copyOfRange(output, 0, headerLen); + + int outputPos = headerLen; + try + { + System.arraycopy(plaintext, plaintextOffset, output, outputPos, plaintextLength); + output[outputPos + plaintextLength] = (byte)contentType; + // NOTE: Any padding bytes after the content type are already zero + + outputPos += encryptCipher.doFinal(additionalData, output, outputPos, innerPlaintextLength, output, + outputPos); + } + catch (RuntimeException e) + { + throw new TlsFatalAlert(AlertDescription.internal_error, e); + } + + if (outputPos != output.length) + { + throw new TlsFatalAlert(AlertDescription.internal_error); + } + + applyDTLS13RecordNumberMask(encryptMask, output, seqNumOff, seqNumLen, headerLen); + + return new TlsEncodeResult(output, 0, output.length, (short)firstByte); + } + + public void decryptDTLS13RecordNumber(byte[] record, int recordOff, int recordLen) throws IOException + { + if (!isDTLSv13) + { + throw new TlsFatalAlert(AlertDescription.internal_error); + } + if (recordLen < 1 || (record[recordOff] & DTLS13_FIXED_BITS_MASK) != DTLS13_FIXED_BITS) + { + throw new TlsFatalAlert(AlertDescription.decode_error); + } + + int firstByte = record[recordOff] & 0xFF; + int cidLength = getDTLS13HeaderConnectionIDLength(firstByte, decryptConnectionID); + int seqNumOff = 1 + cidLength; + int seqNumLen = getDTLS13SequenceNumberLength(firstByte); + int headerLen = seqNumOff + seqNumLen + ((firstByte & DTLS13_FLAG_LENGTH) != 0 ? 2 : 0); + + if (recordLen < headerLen + DTLS13_MIN_CIPHERTEXT_LENGTH) + { + throw new TlsFatalAlert(AlertDescription.decode_error); + } + + applyDTLS13RecordNumberMask(decryptMask, record, recordOff + seqNumOff, seqNumLen, recordOff + headerLen); + } + + public TlsDecodeResult decodeDTLS13Ciphertext(long seqNo, byte[] record, int recordOff, int headerLen, + int ciphertextLen) throws IOException + { + if (!isDTLSv13) + { + throw new TlsFatalAlert(AlertDescription.internal_error); + } + if (ciphertextLen < DTLS13_MIN_CIPHERTEXT_LENGTH || getPlaintextDecodeLimit(ciphertextLen) < 0) + { + throw new TlsFatalAlert(AlertDescription.decode_error); + } + + byte[] nonce = createDTLS13Nonce(decryptNonce, seqNo); + + decryptCipher.init(nonce, macSize); + + int encryptionOffset = recordOff + headerLen; + int innerPlaintextLength = decryptCipher.getOutputSize(ciphertextLen); + + byte[] additionalData = Arrays.copyOfRange(record, recordOff, recordOff + headerLen); + + int outputPos; + try + { + outputPos = decryptCipher.doFinal(additionalData, record, encryptionOffset, ciphertextLen, record, + encryptionOffset); + } + catch (RuntimeException e) + { + throw new TlsFatalAlert(AlertDescription.bad_record_mac, e); + } + + if (outputPos != innerPlaintextLength) + { + throw new TlsFatalAlert(AlertDescription.internal_error); + } + + // Strip padding and read true content type from DTLSInnerPlaintext + short contentType; + int plaintextLength = innerPlaintextLength; + for (;;) + { + if (--plaintextLength < 0) + { + // NOTE: The DTLS record layer deliberately converts this alert into a silent discard + throw new TlsFatalAlert(AlertDescription.unexpected_message); + } + + byte octet = record[encryptionOffset + plaintextLength]; + if (0 != octet) + { + contentType = (short)(octet & 0xFF); + break; + } + } + + return new TlsDecodeResult(record, encryptionOffset, plaintextLength, contentType); + } + + private static void applyDTLS13RecordNumberMask(TlsRecordNumberMask mask, byte[] record, int seqNumOff, + int seqNumLen, int ciphertextOff) throws IOException + { + if (null == mask) + { + throw new TlsFatalAlert(AlertDescription.internal_error); + } + + byte[] maskBytes = new byte[16]; + mask.generateMask(record, ciphertextOff, maskBytes, 0); + + for (int i = 0; i < seqNumLen; ++i) + { + record[seqNumOff + i] ^= maskBytes[i]; + } + } + + private byte[] createDTLS13Nonce(byte[] fixedNonce, long seqNo) + { + /* + * RFC 9147 4. In DTLS 1.3 the 64-bit sequence_number is used as the sequence number for the AEAD + * computation; unlike DTLS 1.2, the epoch is not included. + */ + byte[] nonce = new byte[fixedNonce.length]; + TlsUtils.writeUint64(seqNo, nonce, nonce.length - 8); + for (int i = 0; i < fixedNonce.length; ++i) + { + nonce[i] ^= fixedNonce[i]; + } + return nonce; + } + + private static int getDTLS13HeaderConnectionIDLength(int firstByte, byte[] connectionID) throws IOException + { + int cidLength = Arrays.isNullOrEmpty(connectionID) ? 0 : connectionID.length; + boolean hasCID = (firstByte & DTLS13_FLAG_CID) != 0; + if (hasCID != (cidLength > 0)) + { + throw new TlsFatalAlert(AlertDescription.decode_error); + } + return cidLength; + } + + private static int getDTLS13SequenceNumberLength(int firstByte) + { + return (firstByte & DTLS13_FLAG_SEQ16) != 0 ? 2 : 1; + } + public void rekeyDecoder() throws IOException { - rekeyCipher(cryptoParams.getSecurityParametersConnection(), decryptCipher, decryptNonce, !cryptoParams.isServer()); + rekeyCipher(cryptoParams.getSecurityParametersConnection(), decryptCipher, decryptNonce, decryptMask, + !cryptoParams.isServer()); } public void rekeyEncoder() throws IOException { - rekeyCipher(cryptoParams.getSecurityParametersConnection(), encryptCipher, encryptNonce, cryptoParams.isServer()); + rekeyCipher(cryptoParams.getSecurityParametersConnection(), encryptCipher, encryptNonce, encryptMask, + cryptoParams.isServer()); } public boolean usesOpaqueRecordTypeDecode() @@ -413,7 +648,7 @@ else if (isTLSv13) } private void rekeyCipher(SecurityParameters securityParameters, TlsAEADCipherImpl cipher, byte[] nonce, - boolean serverSecret) throws IOException + TlsRecordNumberMask mask, boolean serverSecret) throws IOException { if (!isTLSv13) { @@ -430,17 +665,31 @@ private void rekeyCipher(SecurityParameters securityParameters, TlsAEADCipherImp throw new TlsFatalAlert(AlertDescription.internal_error); } - setup13Cipher(cipher, nonce, secret, securityParameters.getPRFCryptoHashAlgorithm()); + setup13Cipher(cipher, nonce, mask, secret, securityParameters.getPRFCryptoHashAlgorithm()); } - private void setup13Cipher(TlsAEADCipherImpl cipher, byte[] nonce, TlsSecret secret, int cryptoHashAlgorithm) - throws IOException + private void setup13Cipher(TlsAEADCipherImpl cipher, byte[] nonce, TlsRecordNumberMask mask, TlsSecret secret, + int cryptoHashAlgorithm) throws IOException { byte[] key = hkdfExpandLabel(secret, cryptoHashAlgorithm, "key", keySize).extract(); byte[] iv = hkdfExpandLabel(secret, cryptoHashAlgorithm, "iv", fixed_iv_length).extract(); cipher.setKey(key, 0, keySize); System.arraycopy(iv, 0, nonce, 0, fixed_iv_length); + + if (isDTLSv13) + { + /* + * RFC 9147 4.2.3. [sender]_sn_key = HKDF-Expand-Label(Secret, "sn", "", key_length) + */ + if (null == mask) + { + throw new TlsFatalAlert(AlertDescription.internal_error, "No record number mask for DTLS 1.3"); + } + + byte[] snKey = hkdfExpandLabel(secret, cryptoHashAlgorithm, "sn", keySize).extract(); + mask.setKey(snKey, 0, keySize); + } } private static int getNonceMode(boolean isTLSv13, int aeadType) throws IOException diff --git a/tls/src/main/java/org/bouncycastle/tls/crypto/impl/bc/BcTlsCrypto.java b/tls/src/main/java/org/bouncycastle/tls/crypto/impl/bc/BcTlsCrypto.java index f6e657552e..4df08f8f9d 100644 --- a/tls/src/main/java/org/bouncycastle/tls/crypto/impl/bc/BcTlsCrypto.java +++ b/tls/src/main/java/org/bouncycastle/tls/crypto/impl/bc/BcTlsCrypto.java @@ -630,7 +630,8 @@ protected BlockCipher createCBCBlockCipher(int encryptionAlgorithm) protected TlsCipher createChaCha20Poly1305(TlsCryptoParameters cryptoParams) throws IOException { return new TlsAEADCipher(cryptoParams, new BcChaCha20Poly1305(true), new BcChaCha20Poly1305(false), 32, 16, - TlsAEADCipher.AEAD_CHACHA20_POLY1305, null); + TlsAEADCipher.AEAD_CHACHA20_POLY1305, null, new BcTlsChaCha20RecordNumberMask(), + new BcTlsChaCha20RecordNumberMask()); } protected TlsAEADCipher createCipher_AES_CCM(TlsCryptoParameters cryptoParams, int cipherKeySize, int macSize) @@ -640,7 +641,7 @@ protected TlsAEADCipher createCipher_AES_CCM(TlsCryptoParameters cryptoParams, i BcTlsAEADCipherImpl decrypt = new BcTlsAEADCipherImpl(createAEADBlockCipher_AES_CCM(), false); return new TlsAEADCipher(cryptoParams, encrypt, decrypt, cipherKeySize, macSize, TlsAEADCipher.AEAD_CCM, - null); + null, new BcTlsAESRecordNumberMask(createAESEngine()), new BcTlsAESRecordNumberMask(createAESEngine())); } protected TlsAEADCipher createCipher_AES_GCM(TlsCryptoParameters cryptoParams, int cipherKeySize, int macSize) @@ -649,7 +650,8 @@ protected TlsAEADCipher createCipher_AES_GCM(TlsCryptoParameters cryptoParams, i BcTlsAEADCipherImpl encrypt = new BcTlsAEADCipherImpl(createAEADBlockCipher_AES_GCM(), true); BcTlsAEADCipherImpl decrypt = new BcTlsAEADCipherImpl(createAEADBlockCipher_AES_GCM(), false); - return new TlsAEADCipher(cryptoParams, encrypt, decrypt, cipherKeySize, macSize, TlsAEADCipher.AEAD_GCM, null); + return new TlsAEADCipher(cryptoParams, encrypt, decrypt, cipherKeySize, macSize, TlsAEADCipher.AEAD_GCM, + null, new BcTlsAESRecordNumberMask(createAESEngine()), new BcTlsAESRecordNumberMask(createAESEngine())); } protected TlsAEADCipher createCipher_ARIA_GCM(TlsCryptoParameters cryptoParams, int cipherKeySize, int macSize) @@ -682,6 +684,8 @@ protected TlsCipher createCipher_CBC(TlsCryptoParameters cryptoParams, int encry return new TlsBlockCipher(cryptoParams, encrypt, decrypt, clientMAC, serverMAC, cipherKeySize); } + // TODO[dtls13] RFC 9147 defines no record number mask for SM4, so these suites must be excluded + // from DTLS 1.3 suite selection. protected TlsAEADCipher createCipher_SM4_CCM(TlsCryptoParameters cryptoParams) throws IOException { @@ -691,6 +695,8 @@ protected TlsAEADCipher createCipher_SM4_CCM(TlsCryptoParameters cryptoParams) return new TlsAEADCipher(cryptoParams, encrypt, decrypt, 16, 16, TlsAEADCipher.AEAD_CCM, null); } + // TODO[dtls13] RFC 9147 defines no record number mask for SM4, so these suites must be excluded + // from DTLS 1.3 suite selection. protected TlsAEADCipher createCipher_SM4_GCM(TlsCryptoParameters cryptoParams) throws IOException { diff --git a/tls/src/main/java/org/bouncycastle/tls/crypto/impl/jcajce/JcaTlsCrypto.java b/tls/src/main/java/org/bouncycastle/tls/crypto/impl/jcajce/JcaTlsCrypto.java index 134c426701..fb12bc79b9 100644 --- a/tls/src/main/java/org/bouncycastle/tls/crypto/impl/jcajce/JcaTlsCrypto.java +++ b/tls/src/main/java/org/bouncycastle/tls/crypto/impl/jcajce/JcaTlsCrypto.java @@ -1374,7 +1374,8 @@ private TlsCipher createChaCha20Poly1305(TlsCryptoParameters cryptoParams) throws IOException, GeneralSecurityException { return new TlsAEADCipher(cryptoParams, new JceChaCha20Poly1305(this, helper, true), - new JceChaCha20Poly1305(this, helper, false), 32, 16, TlsAEADCipher.AEAD_CHACHA20_POLY1305, null); + new JceChaCha20Poly1305(this, helper, false), 32, 16, TlsAEADCipher.AEAD_CHACHA20_POLY1305, null, + new JceChaCha20RecordNumberMask(), new JceChaCha20RecordNumberMask()); } private TlsAEADCipher createCipher_AES_CCM(TlsCryptoParameters cryptoParams, int cipherKeySize, int macSize) @@ -1382,7 +1383,7 @@ private TlsAEADCipher createCipher_AES_CCM(TlsCryptoParameters cryptoParams, int { return new TlsAEADCipher(cryptoParams, createAEADCipher("AES/CCM/NoPadding", "AES", cipherKeySize, true), createAEADCipher("AES/CCM/NoPadding", "AES", cipherKeySize, false), cipherKeySize, macSize, - TlsAEADCipher.AEAD_CCM, null); + TlsAEADCipher.AEAD_CCM, null, new JceAESRecordNumberMask(helper), new JceAESRecordNumberMask(helper)); } private TlsAEADCipher createCipher_AES_GCM(TlsCryptoParameters cryptoParams, int cipherKeySize, int macSize) @@ -1390,7 +1391,8 @@ private TlsAEADCipher createCipher_AES_GCM(TlsCryptoParameters cryptoParams, int { return new TlsAEADCipher(cryptoParams, createAEADCipher("AES/GCM/NoPadding", "AES", cipherKeySize, true), createAEADCipher("AES/GCM/NoPadding", "AES", cipherKeySize, false), cipherKeySize, macSize, - TlsAEADCipher.AEAD_GCM, getFipsGCMNonceGeneratorFactory()); + TlsAEADCipher.AEAD_GCM, getFipsGCMNonceGeneratorFactory(), new JceAESRecordNumberMask(helper), + new JceAESRecordNumberMask(helper)); } private TlsAEADCipher createCipher_ARIA_GCM(TlsCryptoParameters cryptoParams, int cipherKeySize, int macSize) @@ -1422,6 +1424,8 @@ protected TlsCipher createCipher_CBC(TlsCryptoParameters cryptoParams, String al return new TlsBlockCipher(cryptoParams, encrypt, decrypt, clientMAC, serverMAC, cipherKeySize); } + // TODO[dtls13] RFC 9147 defines no record number mask for SM4, so these suites must be excluded + // from DTLS 1.3 suite selection. private TlsAEADCipher createCipher_SM4_CCM(TlsCryptoParameters cryptoParams) throws IOException, GeneralSecurityException { @@ -1431,6 +1435,8 @@ private TlsAEADCipher createCipher_SM4_CCM(TlsCryptoParameters cryptoParams) TlsAEADCipher.AEAD_CCM, null); } + // TODO[dtls13] RFC 9147 defines no record number mask for SM4, so these suites must be excluded + // from DTLS 1.3 suite selection. private TlsAEADCipher createCipher_SM4_GCM(TlsCryptoParameters cryptoParams) throws IOException, GeneralSecurityException { diff --git a/tls/src/test/java/org/bouncycastle/tls/AllTests.java b/tls/src/test/java/org/bouncycastle/tls/AllTests.java index d8570e229b..a3d66333bb 100644 --- a/tls/src/test/java/org/bouncycastle/tls/AllTests.java +++ b/tls/src/test/java/org/bouncycastle/tls/AllTests.java @@ -26,6 +26,7 @@ public static Test suite() suite.addTestSuite(DTLSReassemblerTest.class); suite.addTestSuite(DTLSRecordNumberMaskTest.class); suite.addTestSuite(SpreadCertificateStatusTest.class); + suite.addTestSuite(TlsAEADCipherDTLS13Test.class); return new BCTestSetup(suite); } diff --git a/tls/src/test/java/org/bouncycastle/tls/TlsAEADCipherDTLS13Test.java b/tls/src/test/java/org/bouncycastle/tls/TlsAEADCipherDTLS13Test.java new file mode 100644 index 0000000000..13d8e2f7e3 --- /dev/null +++ b/tls/src/test/java/org/bouncycastle/tls/TlsAEADCipherDTLS13Test.java @@ -0,0 +1,318 @@ +package org.bouncycastle.tls; + +import java.io.IOException; +import java.security.SecureRandom; + +import org.bouncycastle.jce.provider.BouncyCastleProvider; +import org.bouncycastle.tls.crypto.CryptoHashAlgorithm; +import org.bouncycastle.tls.crypto.TlsCrypto; +import org.bouncycastle.tls.crypto.TlsDTLS13Cipher; +import org.bouncycastle.tls.crypto.TlsDecodeResult; +import org.bouncycastle.tls.crypto.TlsEncodeResult; +import org.bouncycastle.tls.crypto.impl.bc.BcTlsCrypto; +import org.bouncycastle.tls.crypto.impl.jcajce.JcaTlsCryptoProvider; +import org.bouncycastle.util.Arrays; + +import junit.framework.TestCase; + +/** + * RFC 9147 4: DTLS 1.3 record protection in TlsAEADCipher. The unified header is the AEAD additional data, the + * nonce uses the 64-bit sequence number without the epoch, the sequence number bytes are masked, and short + * ciphertexts are padded to (and rejected below) 16 bytes. + */ +public class TlsAEADCipherDTLS13Test + extends TestCase +{ + private static final SecureRandom RANDOM = new SecureRandom(); + + // 0b001 fixed bits, S = 1 (16-bit seq), L = 1 (length present), epoch bits = 3 + private static final int HDR_EPOCH3 = 0x2C | 0x03; + + static AbstractTlsContext createContext(TlsCrypto crypto, boolean server, int cipherSuite, int hash, + byte[] clientSecret, byte[] serverSecret) throws IOException + { + AbstractTlsContext context = server ? (AbstractTlsContext)new TlsServerContextImpl(crypto) + : (AbstractTlsContext)new TlsClientContextImpl(crypto); + + TlsPeer peer = new DefaultTlsClient(crypto) + { + public TlsAuthentication getAuthentication() + { + return null; + } + }; + + context.handshakeBeginning(peer); + + SecurityParameters sp = context.getSecurityParametersHandshake(); + sp.negotiatedVersion = ProtocolVersion.DTLSv13; + sp.cipherSuite = cipherSuite; + sp.prfCryptoHashAlgorithm = hash; + sp.trafficSecretClient = crypto.createSecret(clientSecret); + sp.trafficSecretServer = crypto.createSecret(serverSecret); + return context; + } + + static byte[] header(int firstByte, long seq) + { + byte[] header = new byte[5]; + header[0] = (byte)firstByte; + TlsUtils.writeUint16((int)(seq & 0xFFFF), header, 1); + TlsUtils.writeUint16(0, header, 3); + return header; + } + + /** + * A unified header in whichever of the four RFC 9147 4 forms 'firstByte' selects (no connection ID). Any + * length field is left zero for the cipher to fill in. + */ + static byte[] compactHeader(int firstByte, long seq) + { + boolean seq16 = DTLS13UnifiedHeader.hasSeq16(firstByte); + boolean hasLength = DTLS13UnifiedHeader.hasLength(firstByte); + + byte[] header = new byte[1 + (seq16 ? 2 : 1) + (hasLength ? 2 : 0)]; + header[0] = (byte)firstByte; + if (seq16) + { + TlsUtils.writeUint16((int)(seq & 0xFFFFL), header, 1); + } + else + { + TlsUtils.writeUint8((int)(seq & 0xFFL), header, 1); + } + return header; + } + + private static TlsDTLS13Cipher[] createPair(TlsCrypto crypto, int cipherSuite, int hash) throws IOException + { + byte[] clientSecret = new byte[48]; + byte[] serverSecret = new byte[48]; + RANDOM.nextBytes(clientSecret); + RANDOM.nextBytes(serverSecret); + + AbstractTlsContext client = createContext(crypto, false, cipherSuite, hash, clientSecret, serverSecret); + AbstractTlsContext server = createContext(crypto, true, cipherSuite, hash, clientSecret, serverSecret); + + return new TlsDTLS13Cipher[]{ (TlsDTLS13Cipher)TlsUtils.initCipher(client), + (TlsDTLS13Cipher)TlsUtils.initCipher(server) }; + } + + private void implTestRoundTrip(TlsCrypto crypto, int cipherSuite, int hash, int plaintextLen) throws IOException + { + TlsDTLS13Cipher[] pair = createPair(crypto, cipherSuite, hash); + TlsDTLS13Cipher clientCipher = pair[0], serverCipher = pair[1]; + + byte[] plaintext = new byte[plaintextLen]; + RANDOM.nextBytes(plaintext); + long seq = 0x123456L; + + byte[] header = header(HDR_EPOCH3, seq); + TlsEncodeResult encoded = clientCipher.encodeDTLS13Plaintext(seq, ContentType.application_data, header, 0, + header.length, plaintext, 0, plaintext.length); + + assertEquals(HDR_EPOCH3, encoded.recordType); + assertEquals(HDR_EPOCH3, encoded.buf[encoded.off] & 0xFF); + + int ciphertextLen = TlsUtils.readUint16(encoded.buf, encoded.off + 3); + assertEquals(encoded.len, 5 + ciphertextLen); + assertTrue("ciphertext must be at least 16 bytes", ciphertextLen >= 16); + + byte[] record = Arrays.copyOfRange(encoded.buf, encoded.off, encoded.off + encoded.len); + + serverCipher.decryptDTLS13RecordNumber(record, 0, record.length); + assertEquals((int)(seq & 0xFFFF), TlsUtils.readUint16(record, 1)); + + TlsDecodeResult decoded = serverCipher.decodeDTLS13Ciphertext(seq, record, 0, 5, ciphertextLen); + assertEquals(ContentType.application_data, decoded.contentType); + assertEquals(plaintextLen, decoded.len); + assertTrue(Arrays.areEqual(plaintext, Arrays.copyOfRange(decoded.buf, decoded.off, decoded.off + decoded.len))); + } + + public void testRoundTripAES128GCM() throws Exception + { + implTestRoundTrip(new BcTlsCrypto(), CipherSuite.TLS_AES_128_GCM_SHA256, CryptoHashAlgorithm.sha256, 100); + implTestRoundTrip(new JcaTlsCryptoProvider().setProvider(new BouncyCastleProvider()).create(RANDOM), CipherSuite.TLS_AES_128_GCM_SHA256, + CryptoHashAlgorithm.sha256, 100); + } + + public void testRoundTripAES256GCM() throws Exception + { + implTestRoundTrip(new BcTlsCrypto(), CipherSuite.TLS_AES_256_GCM_SHA384, CryptoHashAlgorithm.sha384, 1000); + } + + public void testRoundTripChaCha20() throws Exception + { + implTestRoundTrip(new BcTlsCrypto(), CipherSuite.TLS_CHACHA20_POLY1305_SHA256, CryptoHashAlgorithm.sha256, 33); + implTestRoundTrip(new JcaTlsCryptoProvider().setProvider(new BouncyCastleProvider()).create(RANDOM), CipherSuite.TLS_CHACHA20_POLY1305_SHA256, + CryptoHashAlgorithm.sha256, 33); + } + + public void testShortPlaintextIsPaddedForCCM8() throws Exception + { + // 1 byte content + 1 byte type + 8 byte tag = 10 bytes; RFC 9147 4.2.3 requires padding to 16 + implTestRoundTrip(new BcTlsCrypto(), CipherSuite.TLS_AES_128_CCM_8_SHA256, CryptoHashAlgorithm.sha256, 1); + implTestRoundTrip(new BcTlsCrypto(), CipherSuite.TLS_AES_128_CCM_8_SHA256, CryptoHashAlgorithm.sha256, 0); + } + + public void testMaskDiffersFromClearSequenceNumber() throws Exception + { + TlsDTLS13Cipher[] pair = createPair(new BcTlsCrypto(), CipherSuite.TLS_AES_128_GCM_SHA256, + CryptoHashAlgorithm.sha256); + boolean anyMasked = false; + for (long seq = 0; seq < 8; ++seq) + { + byte[] header = header(HDR_EPOCH3, seq); + TlsEncodeResult encoded = pair[0].encodeDTLS13Plaintext(seq, ContentType.application_data, header, 0, + header.length, new byte[20], 0, 20); + if (TlsUtils.readUint16(encoded.buf, encoded.off + 1) != seq) + { + anyMasked = true; + } + } + assertTrue("sequence numbers were never masked", anyMasked); + } + + public void testTamperedHeaderFailsAuthentication() throws Exception + { + TlsDTLS13Cipher[] pair = createPair(new BcTlsCrypto(), CipherSuite.TLS_AES_128_GCM_SHA256, + CryptoHashAlgorithm.sha256); + long seq = 7; + byte[] header = header(HDR_EPOCH3, seq); + TlsEncodeResult encoded = pair[0].encodeDTLS13Plaintext(seq, ContentType.handshake, header, 0, header.length, + new byte[40], 0, 40); + byte[] record = Arrays.copyOfRange(encoded.buf, encoded.off, encoded.off + encoded.len); + pair[1].decryptDTLS13RecordNumber(record, 0, record.length); + + // flip an epoch bit in the header: AAD changes, so authentication must fail + record[0] ^= 0x01; + try + { + pair[1].decodeDTLS13Ciphertext(seq, record, 0, 5, record.length - 5); + fail("expected bad_record_mac"); + } + catch (TlsFatalAlert e) + { + assertEquals(AlertDescription.bad_record_mac, e.getAlertDescription()); + } + } + + public void testShortRecordIsRejected() throws Exception + { + TlsDTLS13Cipher[] pair = createPair(new BcTlsCrypto(), CipherSuite.TLS_AES_128_GCM_SHA256, + CryptoHashAlgorithm.sha256); + byte[] record = new byte[5 + 15]; + record[0] = (byte)HDR_EPOCH3; + try + { + pair[1].decryptDTLS13RecordNumber(record, 0, record.length); + fail("expected decode_error"); + } + catch (TlsFatalAlert e) + { + assertEquals(AlertDescription.decode_error, e.getAlertDescription()); + } + try + { + pair[1].decodeDTLS13Ciphertext(0, record, 0, 5, 15); + fail("expected decode_error"); + } + catch (TlsFatalAlert e) + { + assertEquals(AlertDescription.decode_error, e.getAlertDescription()); + } + } + + public void testNonDTLS13CipherRejectsDTLS13Calls() throws Exception + { + TlsCrypto crypto = new BcTlsCrypto(); + AbstractTlsContext context = createContext(crypto, false, CipherSuite.TLS_AES_128_GCM_SHA256, + CryptoHashAlgorithm.sha256, new byte[32], new byte[32]); + // same cipher class, but a non-DTLS-1.3 version must refuse all three DTLS 1.3 entry points + context.getSecurityParametersHandshake().negotiatedVersion = ProtocolVersion.TLSv13; + TlsDTLS13Cipher cipher = (TlsDTLS13Cipher)TlsUtils.initCipher(context); + try + { + cipher.encodeDTLS13Plaintext(0, ContentType.application_data, header(HDR_EPOCH3, 0), 0, 5, new byte[20], + 0, 20); + fail("expected internal_error"); + } + catch (TlsFatalAlert e) + { + assertEquals(AlertDescription.internal_error, e.getAlertDescription()); + } + try + { + cipher.decryptDTLS13RecordNumber(new byte[32], 0, 32); + fail("expected internal_error"); + } + catch (TlsFatalAlert e) + { + assertEquals(AlertDescription.internal_error, e.getAlertDescription()); + } + try + { + cipher.decodeDTLS13Ciphertext(0, new byte[32], 0, 5, 27); + fail("expected internal_error"); + } + catch (TlsFatalAlert e) + { + assertEquals(AlertDescription.internal_error, e.getAlertDescription()); + } + } + + private void implTestCompactRoundTrip(int firstByte, int plaintextLen) throws IOException + { + TlsDTLS13Cipher[] pair = createPair(new BcTlsCrypto(), CipherSuite.TLS_AES_128_GCM_SHA256, + CryptoHashAlgorithm.sha256); + + byte[] plaintext = new byte[plaintextLen]; + RANDOM.nextBytes(plaintext); + long seq = 0x4BL; + + byte[] header = compactHeader(firstByte, seq); + int headerLen = header.length; + assertEquals(DTLS13UnifiedHeader.getHeaderLength(firstByte, 0), headerLen); + + TlsEncodeResult encoded = pair[0].encodeDTLS13Plaintext(seq, ContentType.application_data, header, 0, + headerLen, plaintext, 0, plaintext.length); + assertEquals(firstByte, encoded.recordType); + + byte[] record = Arrays.copyOfRange(encoded.buf, encoded.off, encoded.off + encoded.len); + + // With no length field the receiver takes the ciphertext as the rest of the datagram + int ciphertextLen = record.length - headerLen; + assertTrue("ciphertext must be at least 16 bytes", ciphertextLen >= 16); + if (DTLS13UnifiedHeader.hasLength(firstByte)) + { + assertEquals(ciphertextLen, TlsUtils.readUint16(record, headerLen - 2)); + } + + pair[1].decryptDTLS13RecordNumber(record, 0, record.length); + if (DTLS13UnifiedHeader.hasSeq16(firstByte)) + { + assertEquals((int)(seq & 0xFFFFL), TlsUtils.readUint16(record, 1)); + } + else + { + assertEquals((int)(seq & 0xFFL), TlsUtils.readUint8(record, 1)); + } + + TlsDecodeResult decoded = pair[1].decodeDTLS13Ciphertext(seq, record, 0, headerLen, ciphertextLen); + assertEquals(ContentType.application_data, decoded.contentType); + assertEquals(plaintextLen, decoded.len); + assertTrue(Arrays.areEqual(plaintext, Arrays.copyOfRange(decoded.buf, decoded.off, decoded.off + decoded.len))); + } + + public void testRoundTripCompactHeaders() throws Exception + { + // S = 0, L = 1: 8-bit sequence number, length present (4-byte header) + implTestCompactRoundTrip(0x24 | 0x03, 60); + // S = 1, L = 0: 16-bit sequence number, no length (3-byte header) + implTestCompactRoundTrip(0x28 | 0x03, 60); + // S = 0, L = 0: the minimal 2-byte header + implTestCompactRoundTrip(0x20 | 0x03, 60); + // short plaintexts must still be padded up to a 16-byte ciphertext + implTestCompactRoundTrip(0x20 | 0x03, 0); + } +} From dda1d9199d25297efcbdfc006547955d9677c691 Mon Sep 17 00:00:00 2001 From: Paul Gregoire Date: Fri, 11 Sep 2026 13:22:06 -0700 Subject: [PATCH 4/6] Add DTLS 1.3 unified header codec and sequence number reconstruction (RFC 9147 4.2.2), relates to github #1468. --- .../bouncycastle/tls/DTLS13UnifiedHeader.java | 152 ++++++++++++++++++ .../bouncycastle/tls/DTLSReplayWindow.java | 8 + .../java/org/bouncycastle/tls/AllTests.java | 1 + .../tls/DTLS13UnifiedHeaderTest.java | 92 +++++++++++ 4 files changed, 253 insertions(+) create mode 100644 tls/src/main/java/org/bouncycastle/tls/DTLS13UnifiedHeader.java create mode 100644 tls/src/test/java/org/bouncycastle/tls/DTLS13UnifiedHeaderTest.java diff --git a/tls/src/main/java/org/bouncycastle/tls/DTLS13UnifiedHeader.java b/tls/src/main/java/org/bouncycastle/tls/DTLS13UnifiedHeader.java new file mode 100644 index 0000000000..73c3f2a9b8 --- /dev/null +++ b/tls/src/main/java/org/bouncycastle/tls/DTLS13UnifiedHeader.java @@ -0,0 +1,152 @@ +package org.bouncycastle.tls; + +/** + * RFC 9147 4. The DTLS 1.3 unified header for DTLSCiphertext records. + *
+ *  0 1 2 3 4 5 6 7
+ * +-+-+-+-+-+-+-+-+
+ * |0|0|1|C|S|L|E E|
+ * +-+-+-+-+-+-+-+-+
+ * 
+ * C: connection ID present, S: 16-bit (1) or 8-bit (0) sequence number, L: length present, EE: low two bits of + * the epoch. Records written by this implementation always use the full form (S = 1, L = 1). + */ +class DTLS13UnifiedHeader +{ + static final int FIXED_BITS = 0x20; + static final int FIXED_BITS_MASK = 0xE0; + static final int FLAG_CID = 0x10; + static final int FLAG_SEQ16 = 0x08; + static final int FLAG_LENGTH = 0x04; + static final int EPOCH_BITS_MASK = 0x03; + + /** RFC 9147 4.2.3. Record number encryption needs at least 16 bytes of ciphertext. */ + static final int MIN_CIPHERTEXT_LENGTH = 16; + + private static final long MAX_SEQUENCE_NUMBER = (1L << 48) - 1; + + static boolean isCiphertextRecord(int firstByte) + { + return (firstByte & FIXED_BITS_MASK) == FIXED_BITS; + } + + static boolean hasConnectionID(int firstByte) + { + return (firstByte & FLAG_CID) != 0; + } + + static boolean hasSeq16(int firstByte) + { + return (firstByte & FLAG_SEQ16) != 0; + } + + static boolean hasLength(int firstByte) + { + return (firstByte & FLAG_LENGTH) != 0; + } + + static boolean matchesEpoch(int firstByte, int epoch) + { + return (firstByte & EPOCH_BITS_MASK) == (epoch & EPOCH_BITS_MASK); + } + + static int getSequenceNumberLength(int firstByte) + { + return hasSeq16(firstByte) ? 2 : 1; + } + + static int getHeaderLength(int firstByte, int connectionIDLength) + { + return 1 + connectionIDLength + getSequenceNumberLength(firstByte) + (hasLength(firstByte) ? 2 : 0); + } + + static int getWriteHeaderLength(int connectionIDLength) + { + return 1 + connectionIDLength + 2 + 2; + } + + /** + * The smallest conforming header a peer may send per RFC 9147 4: first byte, connection ID, and an 8-bit + * sequence number, with no length field (S = 0, L = 0). Since a peer is free to use that compact form, + * this is what the receive limit must budget for; assuming our own (full) write form would under-report the + * plaintext limit and reject legal records. + * + * @return the minimum length of a header that may be received. + */ + static int getMinReadHeaderLength(int connectionIDLength) + { + return 1 + connectionIDLength + 1; + } + + /** + * Write a full-form header (16-bit sequence number, length present). The length field is left zero for the + * cipher to fill in once the ciphertext length is known. + * + * @return the header length. + */ + static int writeHeader(int epoch, long sequenceNumber, byte[] connectionID, byte[] buf, int off) + { + int cidLength = null == connectionID ? 0 : connectionID.length; + + int firstByte = FIXED_BITS | FLAG_SEQ16 | FLAG_LENGTH | (epoch & EPOCH_BITS_MASK); + if (cidLength > 0) + { + firstByte |= FLAG_CID; + } + + int pos = off; + buf[pos++] = (byte)firstByte; + if (cidLength > 0) + { + System.arraycopy(connectionID, 0, buf, pos, cidLength); + pos += cidLength; + } + TlsUtils.writeUint16((int)(sequenceNumber & 0xFFFFL), buf, pos); + pos += 2; + TlsUtils.writeUint16(0, buf, pos); + pos += 2; + return pos - off; + } + + /** + * RFC 9147 4.2.2. Reconstruct the full sequence number as the value numerically closest to 'expected' (one + * plus the highest successfully deprotected sequence number) whose low 'seqBitCount' bits equal 'seqBits'. + */ + static long reconstructSequenceNumber(long expected, int seqBits, int seqBitCount) + { + long modulus = 1L << seqBitCount; + long lowMask = modulus - 1; + + long candidate = (expected & ~lowMask) | (seqBits & lowMask); + long best = candidate; + long bestDistance = distance(candidate, expected); + + long lower = candidate - modulus; + if (lower >= 0) + { + long d = distance(lower, expected); + if (d < bestDistance) + { + best = lower; + bestDistance = d; + } + } + + long upper = candidate + modulus; + if (upper <= MAX_SEQUENCE_NUMBER) + { + long d = distance(upper, expected); + if (d < bestDistance) + { + best = upper; + } + } + + return best; + } + + private static long distance(long a, long b) + { + return a > b ? a - b : b - a; + } +} diff --git a/tls/src/main/java/org/bouncycastle/tls/DTLSReplayWindow.java b/tls/src/main/java/org/bouncycastle/tls/DTLSReplayWindow.java index 79eada85df..ab02108076 100644 --- a/tls/src/main/java/org/bouncycastle/tls/DTLSReplayWindow.java +++ b/tls/src/main/java/org/bouncycastle/tls/DTLSReplayWindow.java @@ -82,6 +82,14 @@ boolean reportAuthenticated(long seq) } } + /** + * @return the highest sequence number reported authenticated so far, or -1 if none. + */ + long getLatestConfirmedSeq() + { + return latestConfirmedSeq; + } + void reset(long seq) { if ((seq & VALID_SEQ_MASK) != seq) diff --git a/tls/src/test/java/org/bouncycastle/tls/AllTests.java b/tls/src/test/java/org/bouncycastle/tls/AllTests.java index a3d66333bb..7d9dea561d 100644 --- a/tls/src/test/java/org/bouncycastle/tls/AllTests.java +++ b/tls/src/test/java/org/bouncycastle/tls/AllTests.java @@ -23,6 +23,7 @@ public static Test suite() suite.addTestSuite(AbstractTlsServerResetTest.class); suite.addTestSuite(Add13CertificateStatusTest.class); suite.addTestSuite(CheckTlsFeaturesExtensionTest.class); + suite.addTestSuite(DTLS13UnifiedHeaderTest.class); suite.addTestSuite(DTLSReassemblerTest.class); suite.addTestSuite(DTLSRecordNumberMaskTest.class); suite.addTestSuite(SpreadCertificateStatusTest.class); diff --git a/tls/src/test/java/org/bouncycastle/tls/DTLS13UnifiedHeaderTest.java b/tls/src/test/java/org/bouncycastle/tls/DTLS13UnifiedHeaderTest.java new file mode 100644 index 0000000000..938a1574d7 --- /dev/null +++ b/tls/src/test/java/org/bouncycastle/tls/DTLS13UnifiedHeaderTest.java @@ -0,0 +1,92 @@ +package org.bouncycastle.tls; + +import junit.framework.TestCase; + +/** + * RFC 9147 4 unified header (0b001CSLEE) and 4.2.2 sequence number reconstruction. + */ +public class DTLS13UnifiedHeaderTest + extends TestCase +{ + public void testFixedBits() + { + assertTrue(DTLS13UnifiedHeader.isCiphertextRecord(0x20)); + assertTrue(DTLS13UnifiedHeader.isCiphertextRecord(0x3F)); + assertFalse(DTLS13UnifiedHeader.isCiphertextRecord(ContentType.handshake)); + assertFalse(DTLS13UnifiedHeader.isCiphertextRecord(ContentType.alert)); + assertFalse(DTLS13UnifiedHeader.isCiphertextRecord(ContentType.ack)); + assertFalse(DTLS13UnifiedHeader.isCiphertextRecord(0x40)); + } + + public void testHeaderLengths() + { + assertEquals(2, DTLS13UnifiedHeader.getHeaderLength(0x20, 0)); // minimal: 8-bit seq, no length + assertEquals(3, DTLS13UnifiedHeader.getHeaderLength(0x28, 0)); // 16-bit seq + assertEquals(4, DTLS13UnifiedHeader.getHeaderLength(0x24, 0)); // 8-bit seq + length + assertEquals(5, DTLS13UnifiedHeader.getHeaderLength(0x2C, 0)); // full + assertEquals(9, DTLS13UnifiedHeader.getHeaderLength(0x3C, 4)); // full + 4-byte CID + assertEquals(5, DTLS13UnifiedHeader.getWriteHeaderLength(0)); + assertEquals(9, DTLS13UnifiedHeader.getWriteHeaderLength(4)); + // the receive limit must budget for the smallest header a peer may send, not for our write form + assertEquals(2, DTLS13UnifiedHeader.getMinReadHeaderLength(0)); + assertEquals(6, DTLS13UnifiedHeader.getMinReadHeaderLength(4)); + assertEquals(DTLS13UnifiedHeader.getHeaderLength(0x20, 0), DTLS13UnifiedHeader.getMinReadHeaderLength(0)); + assertEquals(DTLS13UnifiedHeader.getHeaderLength(0x30, 4), DTLS13UnifiedHeader.getMinReadHeaderLength(4)); + assertEquals(1, DTLS13UnifiedHeader.getSequenceNumberLength(0x20)); + assertEquals(2, DTLS13UnifiedHeader.getSequenceNumberLength(0x28)); + } + + public void testWriteHeaderFullForm() + { + byte[] buf = new byte[5]; + int len = DTLS13UnifiedHeader.writeHeader(3, 0x123456L, null, buf, 0); + assertEquals(5, len); + assertEquals(0x2F, buf[0] & 0xFF); // 001 0 1 1 11 + assertEquals(0x3456, TlsUtils.readUint16(buf, 1)); // low 16 bits of seq + assertEquals(0, TlsUtils.readUint16(buf, 3)); // length left for the cipher + assertTrue(DTLS13UnifiedHeader.hasSeq16(buf[0] & 0xFF)); + assertTrue(DTLS13UnifiedHeader.hasLength(buf[0] & 0xFF)); + assertFalse(DTLS13UnifiedHeader.hasConnectionID(buf[0] & 0xFF)); + assertTrue(DTLS13UnifiedHeader.matchesEpoch(buf[0] & 0xFF, 3)); + assertTrue(DTLS13UnifiedHeader.matchesEpoch(buf[0] & 0xFF, 7)); + assertFalse(DTLS13UnifiedHeader.matchesEpoch(buf[0] & 0xFF, 2)); + + byte[] cid = new byte[]{ 1, 2, 3 }; + byte[] buf2 = new byte[8]; + assertEquals(8, DTLS13UnifiedHeader.writeHeader(2, 5, cid, buf2, 0)); + assertEquals(0x3E, buf2[0] & 0xFF); // C bit set, epoch bits 10 + assertEquals(1, buf2[1]); + assertEquals(3, buf2[3]); + assertEquals(5, TlsUtils.readUint16(buf2, 4)); + } + + public void testReconstructSequenceNumber() + { + // fresh epoch: expected 0 + assertEquals(5L, DTLS13UnifiedHeader.reconstructSequenceNumber(0, 5, 8)); + // exact match in the current window + assertEquals(300L, DTLS13UnifiedHeader.reconstructSequenceNumber(300, 0x2C, 8)); + // wrap forward: expected 0x1FE, bits 0x02 -> 0x202 is closer than 0x102 + assertEquals(0x202L, DTLS13UnifiedHeader.reconstructSequenceNumber(0x1FE, 0x02, 8)); + // wrap backward: expected 0x203, bits 0xFE -> 0x1FE is closer than 0x2FE + assertEquals(0x1FEL, DTLS13UnifiedHeader.reconstructSequenceNumber(0x203, 0xFE, 8)); + // 16-bit variants + assertEquals(0x1FFFEL, DTLS13UnifiedHeader.reconstructSequenceNumber(0x20003, 0xFFFE, 16)); + assertEquals(0x20002L, DTLS13UnifiedHeader.reconstructSequenceNumber(0x1FFFE, 0x0002, 16)); + // never negative + assertEquals(0xFEL, DTLS13UnifiedHeader.reconstructSequenceNumber(3, 0xFE, 8)); + // never above 2^48 - 1 + long top = (1L << 48) - 3; + assertEquals(top, DTLS13UnifiedHeader.reconstructSequenceNumber(top, (int)(top & 0xFF), 8)); + } + + public void testReplayWindowExposesLatestConfirmed() + { + DTLSReplayWindow w = new DTLSReplayWindow(); + assertEquals(-1L, w.getLatestConfirmedSeq()); + assertTrue(w.reportAuthenticated(10)); + assertEquals(10L, w.getLatestConfirmedSeq()); + assertFalse(w.reportAuthenticated(4)); + assertEquals(10L, w.getLatestConfirmedSeq()); + } +} From d5a8bd2562448a00028e1f90f0d1c7c767d33a0a Mon Sep 17 00:00:00 2001 From: Paul Gregoire Date: Fri, 11 Sep 2026 13:22:06 -0700 Subject: [PATCH 5/6] Add DTLS 1.3 record layer send/receive paths and epoch switching (RFC 9147 4), relates to github #1468. --- .../org/bouncycastle/tls/DTLSRecordLayer.java | 368 +++++++++++++- .../java/org/bouncycastle/tls/AllTests.java | 1 + .../tls/DTLSRecordLayer13Test.java | 447 ++++++++++++++++++ 3 files changed, 801 insertions(+), 15 deletions(-) create mode 100644 tls/src/test/java/org/bouncycastle/tls/DTLSRecordLayer13Test.java diff --git a/tls/src/main/java/org/bouncycastle/tls/DTLSRecordLayer.java b/tls/src/main/java/org/bouncycastle/tls/DTLSRecordLayer.java index 8e4f55a459..215aecfd4b 100644 --- a/tls/src/main/java/org/bouncycastle/tls/DTLSRecordLayer.java +++ b/tls/src/main/java/org/bouncycastle/tls/DTLSRecordLayer.java @@ -7,6 +7,7 @@ import java.net.SocketTimeoutException; import org.bouncycastle.tls.crypto.TlsCipher; +import org.bouncycastle.tls.crypto.TlsDTLS13Cipher; import org.bouncycastle.tls.crypto.TlsDecodeResult; import org.bouncycastle.tls.crypto.TlsEncodeResult; import org.bouncycastle.tls.crypto.TlsNullNullCipher; @@ -17,6 +18,12 @@ class DTLSRecordLayer { static final int RECORD_HEADER_LENGTH = 13; + /** + * RFC 9147 4. Returned by {@link #getDTLS13RecordLength(byte[], int, int)} for a record without the L bit: + * the record runs to the end of the datagram, whose true length only the caller knows. + */ + private static final int RECORD_LENGTH_REST_OF_DATAGRAM = -2; + private static final int MAX_FRAGMENT_LENGTH = 1 << 14; private static final long TCP_MSL = 1000L * 60 * 2; private static final long RETRANSMIT_TIMEOUT = TCP_MSL * 2; @@ -117,6 +124,9 @@ private static void sendDatagram(DatagramSender sender, byte[] buf, int off, int private DTLSEpoch readEpoch, writeEpoch; private int lastReceivedEpoch = -1; + // Set once a DTLS 1.3 version has been negotiated (at initPendingEpoch); selects the RFC 9147 record format + private volatile boolean dtls13 = false; + private DTLSHandshakeRetransmit retransmit = null; private DTLSEpoch retransmitEpoch = null; private Timeout retransmitTimeout = null; @@ -186,6 +196,12 @@ int getLastReceivedEpoch() return lastReceivedEpoch; } + /** The pending epoch number, or -1 when there is no pending epoch. */ + int getPendingEpoch() + { + return null == pendingEpoch ? -1 : pendingEpoch.getEpoch(); + } + ProtocolVersion getReadVersion() { return readVersion; @@ -217,23 +233,90 @@ void initPendingEpoch(TlsCipher pendingCipher) SecurityParameters securityParameters = context.getSecurityParameters(); byte[] connectionIDLocal = securityParameters.getConnectionIDLocal(); byte[] connectionIDPeer = securityParameters.getConnectionIDPeer(); - int recordHeaderLengthRead = RECORD_HEADER_LENGTH + (connectionIDPeer != null ? connectionIDPeer.length : 0); - int recordHeaderLengthWrite = RECORD_HEADER_LENGTH + (connectionIDLocal != null ? connectionIDLocal.length : 0); + int connectionIDLocalLength = connectionIDLocal != null ? connectionIDLocal.length : 0; + int connectionIDPeerLength = connectionIDPeer != null ? connectionIDPeer.length : 0; + + ProtocolVersion negotiatedVersion = securityParameters.getNegotiatedVersion(); + boolean nextDtls13 = null != negotiatedVersion && TlsUtils.isTLSv13(negotiatedVersion); + + int nextEpoch; + int recordHeaderLengthRead, recordHeaderLengthWrite; + if (nextDtls13) + { + /* + * RFC 9147 6.1. Epoch 1 is reserved for early data (not supported), so the first protected epoch + * (handshake traffic keys) is 2 and application traffic keys begin at 3. + */ + if (!(pendingCipher instanceof TlsDTLS13Cipher)) + { + throw new IllegalStateException("DTLS 1.3 requires a TlsDTLS13Cipher"); + } + + nextEpoch = writeEpoch.getEpoch() == 0 ? 2 : writeEpoch.getEpoch() + 1; + // NOTE: A peer may send the compact header form, so the read side must budget for its minimum + recordHeaderLengthRead = DTLS13UnifiedHeader.getMinReadHeaderLength(connectionIDPeerLength); + recordHeaderLengthWrite = DTLS13UnifiedHeader.getWriteHeaderLength(connectionIDLocalLength); + } + else + { + // TODO Check for overflow + nextEpoch = writeEpoch.getEpoch() + 1; + recordHeaderLengthRead = RECORD_HEADER_LENGTH + connectionIDPeerLength; + recordHeaderLengthWrite = RECORD_HEADER_LENGTH + connectionIDLocalLength; + } + + this.dtls13 = nextDtls13; + this.pendingEpoch = new DTLSEpoch(nextEpoch, pendingCipher, recordHeaderLengthRead, recordHeaderLengthWrite); + } + + /** + * DTLS 1.3: switch the read direction to the pending epoch. Once both directions use the pending epoch it + * becomes the current epoch and the pending slot is cleared. + */ + void enablePendingEpochRead() + { + if (null == pendingEpoch) + { + throw new IllegalStateException(); + } - // TODO Check for overflow - this.pendingEpoch = new DTLSEpoch(writeEpoch.getEpoch() + 1, pendingCipher, recordHeaderLengthRead, - recordHeaderLengthWrite); + this.readEpoch = pendingEpoch; + commitPendingEpochIfCurrent(); + } + + /** + * DTLS 1.3: switch the write direction to the pending epoch (see {@link #enablePendingEpochRead()}). + */ + void enablePendingEpochWrite() + { + if (null == pendingEpoch) + { + throw new IllegalStateException(); + } + + this.writeEpoch = pendingEpoch; + commitPendingEpochIfCurrent(); + } + + private void commitPendingEpochIfCurrent() + { + if (readEpoch == pendingEpoch && writeEpoch == pendingEpoch) + { + this.currentEpoch = pendingEpoch; + this.pendingEpoch = null; + } } void handshakeSuccessful(DTLSHandshakeRetransmit retransmit) { - if (readEpoch == currentEpoch || writeEpoch == currentEpoch) + if (!dtls13 && (readEpoch == currentEpoch || writeEpoch == currentEpoch)) { // TODO throw new IllegalStateException(); } - if (null != retransmit) + // DTLS 1.3 retransmission is ACK-driven (RFC 9147 7) and handled by the reliable handshake + if (null != retransmit && !dtls13) { this.retransmit = retransmit; this.retransmitEpoch = currentEpoch; @@ -241,8 +324,11 @@ void handshakeSuccessful(DTLSHandshakeRetransmit retransmit) } this.inHandshake = false; - this.currentEpoch = pendingEpoch; - this.pendingEpoch = null; + if (null != pendingEpoch) + { + this.currentEpoch = pendingEpoch; + this.pendingEpoch = null; + } } void initHeartbeat(TlsHeartbeat heartbeat, boolean heartbeatResponder) @@ -407,7 +493,7 @@ public void send(byte[] buf, int off, int len) contentType = ContentType.handshake; short handshakeType = TlsUtils.readUint8(buf, off); - if (handshakeType == HandshakeType.finished) + if (handshakeType == HandshakeType.finished && !dtls13) { DTLSEpoch nextEpoch = null; if (this.inHandshake) @@ -561,12 +647,21 @@ private int processRecord(int received, byte[] record, byte[] buf, int off, int throws IOException { // NOTE: received < 0 (timeout) is covered by this first case + if (received < 1) + { + return -1; + } + + if (dtls13 && DTLS13UnifiedHeader.isCiphertextRecord(record[0] & 0xFF)) + { + return processDTLS13Record(received, record, buf, off, len, recordCallback); + } + if (received < RECORD_HEADER_LENGTH) { return -1; } - // TODO[dtls13] Deal with opaque record type for 1.3 AEAD ciphers short recordType = TlsUtils.readUint8(record, 0); switch (recordType) @@ -768,6 +863,12 @@ else if (inHandshake && epoch == currentEpoch.getEpoch()) recordCallback.recordAccepted(flags); } + return processDecodedRecord(decoded, epoch, buf, off, len); + } + + private int processDecodedRecord(TlsDecodeResult decoded, int epoch, byte[] buf, int off, int len) + throws IOException + { switch (decoded.contentType) { case ContentType.alert: @@ -816,7 +917,8 @@ else if (inHandshake && epoch == currentEpoch.getEpoch()) continue; } - if (pendingEpoch != null) + // RFC 9147 5. DTLS 1.3 does not use the TLS 1.3 compatibility-mode change_cipher_spec + if (!dtls13 && pendingEpoch != null) { readEpoch = pendingEpoch; } @@ -884,7 +986,7 @@ else if (inHandshake && epoch == currentEpoch.getEpoch()) return -1; } - case ContentType.tls12_cid: + case ContentType.tls12_cid: default: return -1; } @@ -900,7 +1002,7 @@ else if (inHandshake && epoch == currentEpoch.getEpoch()) this.retransmitTimeout = null; } - this.lastReceivedEpoch = recordEpoch.getEpoch(); + this.lastReceivedEpoch = epoch; // NOTE: Internal error implies getReceiveLimit() was not used to allocate result space if (decoded.len > len) @@ -912,13 +1014,175 @@ else if (inHandshake && epoch == currentEpoch.getEpoch()) return decoded.len; } + /** + * RFC 9147 4. Process a DTLSCiphertext record (unified header). Invalid records are silently discarded + * (RFC 9147 4.5.2), with the same internal_error exception as the legacy path. + */ + private int processDTLS13Record(int received, byte[] record, byte[] buf, int off, int len, + DTLSRecordCallback recordCallback) throws IOException + { + int firstByte = record[0] & 0xFF; + + byte[] connectionID = context.getSecurityParameters().getConnectionIDPeer(); + int connectionIDLength = null == connectionID ? 0 : connectionID.length; + + // NOTE: Establish that the whole header is present before reading any of it + int headerLength = DTLS13UnifiedHeader.getHeaderLength(firstByte, connectionIDLength); + if (received < headerLength + DTLS13UnifiedHeader.MIN_CIPHERTEXT_LENGTH) + { + return -1; + } + + if (DTLS13UnifiedHeader.hasConnectionID(firstByte)) + { + if (connectionIDLength == 0 + || !Arrays.constantTimeAreEqual(connectionIDLength, connectionID, 0, record, 1)) + { + return -1; + } + } + else if (connectionIDLength != 0) + { + return -1; + } + + int ciphertextLength; + if (DTLS13UnifiedHeader.hasLength(firstByte)) + { + ciphertextLength = TlsUtils.readUint16(record, headerLength - 2); + if (received != headerLength + ciphertextLength) + { + return -1; + } + } + else + { + ciphertextLength = received - headerLength; + } + if (ciphertextLength < DTLS13UnifiedHeader.MIN_CIPHERTEXT_LENGTH) + { + return -1; + } + + /* + * TODO[dtls13] With only the low 2 epoch bits on the wire, a retransmitted record from an earlier epoch + * is dropped once the read epoch advances; the reliable handshake (RFC 9147 7) will need to retain + * recent epochs. + */ + DTLSEpoch recordEpoch = null; + if (DTLS13UnifiedHeader.matchesEpoch(firstByte, readEpoch.getEpoch())) + { + recordEpoch = readEpoch; + } + else if (null != retransmitEpoch && DTLS13UnifiedHeader.matchesEpoch(firstByte, retransmitEpoch.getEpoch())) + { + recordEpoch = retransmitEpoch; + } + if (null == recordEpoch) + { + return -1; + } + + TlsCipher recordCipher = recordEpoch.getCipher(); + if (!(recordCipher instanceof TlsDTLS13Cipher)) + { + // A DTLSCiphertext record can only belong to a protected epoch; epoch 0 uses the null cipher. + return -1; + } + TlsDTLS13Cipher cipher = (TlsDTLS13Cipher)recordCipher; + DTLSReplayWindow replayWindow = recordEpoch.getReplayWindow(); + + TlsDecodeResult decoded; + long seq; + try + { + cipher.decryptDTLS13RecordNumber(record, 0, received); + + int seqNumOff = 1 + connectionIDLength; + int seqBitCount = DTLS13UnifiedHeader.hasSeq16(firstByte) ? 16 : 8; + int seqBits = seqBitCount == 16 ? TlsUtils.readUint16(record, seqNumOff) + : TlsUtils.readUint8(record, seqNumOff); + + long expected = replayWindow.getLatestConfirmedSeq() + 1; + seq = DTLS13UnifiedHeader.reconstructSequenceNumber(expected, seqBits, seqBitCount); + if (replayWindow.shouldDiscard(seq)) + { + return -1; + } + + decoded = cipher.decodeDTLS13Ciphertext(seq, record, 0, headerLength, ciphertextLength); + } + catch (TlsFatalAlert fatalAlert) + { + // See processRecord: only an internal_error is propagated + if (AlertDescription.internal_error == fatalAlert.getAlertDescription()) + { + throw fatalAlert; + } + + return -1; + } + + if (decoded.len > this.plaintextLimit) + { + return -1; + } + if (decoded.len < 1 && decoded.contentType != ContentType.application_data) + { + return -1; + } + + boolean isLatestConfirmed = replayWindow.reportAuthenticated(seq); + + if (recordCallback != null) + { + int flags = DTLSRecordFlags.NONE; + + if (recordEpoch == readEpoch && isLatestConfirmed) + { + flags |= DTLSRecordFlags.IS_NEWEST; + } + + if (DTLS13UnifiedHeader.hasConnectionID(firstByte)) + { + flags |= DTLSRecordFlags.USES_CONNECTION_ID; + } + + recordCallback.recordAccepted(flags); + } + + return processDecodedRecord(decoded, recordEpoch.getEpoch(), buf, off, len); + } + private int receivePendingRecord(byte[] buf, int off, int len) throws IOException { // assert recordQueue.available() > 0; int recordLength = RECORD_HEADER_LENGTH; - if (recordQueue.available() >= recordLength) + + byte[] firstByteBuf = new byte[1]; + recordQueue.read(firstByteBuf, 0, 1, 0); + int firstByte = firstByteBuf[0] & 0xFF; + + if (dtls13 && DTLS13UnifiedHeader.isCiphertextRecord(firstByte)) + { + int available = recordQueue.available(); + byte[] head = new byte[Math.min(available, 16)]; + recordQueue.read(head, 0, head.length, 0); + + recordLength = getDTLS13RecordLength(head, 0, head.length); + if (RECORD_LENGTH_REST_OF_DATAGRAM == recordLength) + { + recordLength = available; + } + else if (recordLength < 0) + { + recordQueue.removeData(available); + return -1; + } + } + else if (recordQueue.available() >= recordLength) { int epoch = recordQueue.readUint16(3); @@ -955,6 +1219,31 @@ else if (inHandshake && epoch == currentEpoch.getEpoch()) return received; } + /** + * RFC 9147 4. Length of the DTLS 1.3 ciphertext record at the start of buf[off..off + available), or -1 if + * it cannot be determined. A record without the L bit consumes the rest of the datagram, which is reported + * as {@link #RECORD_LENGTH_REST_OF_DATAGRAM} because 'available' may be only a peek window rather than the + * true remaining length; the caller substitutes the length it knows. + */ + private int getDTLS13RecordLength(byte[] buf, int off, int available) + { + int firstByte = buf[off] & 0xFF; + + byte[] connectionID = context.getSecurityParameters().getConnectionIDPeer(); + int connectionIDLength = null == connectionID ? 0 : connectionID.length; + + int headerLength = DTLS13UnifiedHeader.getHeaderLength(firstByte, connectionIDLength); + if (available < headerLength) + { + return -1; + } + if (!DTLS13UnifiedHeader.hasLength(firstByte)) + { + return RECORD_LENGTH_REST_OF_DATAGRAM; + } + return headerLength + TlsUtils.readUint16(buf, off + headerLength - 2); + } + private int receiveRecord(byte[] buf, int off, int len, int waitMillis) throws IOException { @@ -964,6 +1253,28 @@ private int receiveRecord(byte[] buf, int off, int len, int waitMillis) } int received = receiveDatagram(buf, off, len, waitMillis); + + if (dtls13 && received >= 1 && DTLS13UnifiedHeader.isCiphertextRecord(buf[off] & 0xFF)) + { + this.inConnection = true; + + int recordLength = getDTLS13RecordLength(buf, off, received); + if (RECORD_LENGTH_REST_OF_DATAGRAM == recordLength) + { + recordLength = received; + } + else if (recordLength < 0) + { + return -1; + } + if (received > recordLength) + { + recordQueue.addData(buf, off + recordLength, received - recordLength); + received = recordLength; + } + return received; + } + if (received >= RECORD_HEADER_LENGTH) { this.inConnection = true; @@ -1053,6 +1364,12 @@ private void sendRecord(short contentType, byte[] buf, int off, int len) throws synchronized (writeLock) { + if (dtls13 && writeEpoch.getEpoch() > 0) + { + sendDTLS13Record(contentType, buf, off, len); + return; + } + int recordEpoch = writeEpoch.getEpoch(); long recordSequenceNumber = writeEpoch.allocateSequenceNumber(); long macSequenceNumber = getMacSequenceNumber(recordEpoch, recordSequenceNumber); @@ -1083,6 +1400,27 @@ private void sendRecord(short contentType, byte[] buf, int off, int len) throws } } + private void sendDTLS13Record(short contentType, byte[] buf, int off, int len) throws IOException + { + int recordEpoch = writeEpoch.getEpoch(); + long recordSequenceNumber = writeEpoch.allocateSequenceNumber(); + + byte[] connectionID = context.getSecurityParameters().getConnectionIDLocal(); + int connectionIDLength = null == connectionID ? 0 : connectionID.length; + + byte[] header = new byte[DTLS13UnifiedHeader.getWriteHeaderLength(connectionIDLength)]; + int headerLength = DTLS13UnifiedHeader.writeHeader(recordEpoch, recordSequenceNumber, connectionID, header, + 0); + + // NOTE: initPendingEpoch checked this for every DTLS 1.3 epoch + TlsDTLS13Cipher cipher = (TlsDTLS13Cipher)writeEpoch.getCipher(); + + TlsEncodeResult encoded = cipher.encodeDTLS13Plaintext(recordSequenceNumber, contentType, header, 0, + headerLength, buf, off, len); + + sendDatagram(transport, encoded.buf, encoded.off, encoded.len); + } + private static long getMacSequenceNumber(int epoch, long sequence_number) { return ((epoch & 0xFFFFFFFFL) << 48) | sequence_number; diff --git a/tls/src/test/java/org/bouncycastle/tls/AllTests.java b/tls/src/test/java/org/bouncycastle/tls/AllTests.java index 7d9dea561d..2ff20fe487 100644 --- a/tls/src/test/java/org/bouncycastle/tls/AllTests.java +++ b/tls/src/test/java/org/bouncycastle/tls/AllTests.java @@ -25,6 +25,7 @@ public static Test suite() suite.addTestSuite(CheckTlsFeaturesExtensionTest.class); suite.addTestSuite(DTLS13UnifiedHeaderTest.class); suite.addTestSuite(DTLSReassemblerTest.class); + suite.addTestSuite(DTLSRecordLayer13Test.class); suite.addTestSuite(DTLSRecordNumberMaskTest.class); suite.addTestSuite(SpreadCertificateStatusTest.class); suite.addTestSuite(TlsAEADCipherDTLS13Test.class); diff --git a/tls/src/test/java/org/bouncycastle/tls/DTLSRecordLayer13Test.java b/tls/src/test/java/org/bouncycastle/tls/DTLSRecordLayer13Test.java new file mode 100644 index 0000000000..255d2cece9 --- /dev/null +++ b/tls/src/test/java/org/bouncycastle/tls/DTLSRecordLayer13Test.java @@ -0,0 +1,447 @@ +package org.bouncycastle.tls; + +import java.io.IOException; +import java.security.SecureRandom; +import java.util.Vector; + +import org.bouncycastle.tls.crypto.CryptoHashAlgorithm; +import org.bouncycastle.tls.crypto.TlsCrypto; +import org.bouncycastle.tls.crypto.TlsDTLS13Cipher; +import org.bouncycastle.tls.crypto.TlsEncodeResult; +import org.bouncycastle.tls.crypto.impl.bc.BcTlsCrypto; +import org.bouncycastle.util.Arrays; + +import junit.framework.TestCase; + +/** + * DTLS 1.3 record layer (RFC 9147 4): protected records use the unified header, records round-trip between two + * record layers, replays and short records are dropped, several records share a datagram, and the epoch switch + * API drives epochs 2 and 3. + */ +public class DTLSRecordLayer13Test + extends TestCase +{ + private static final SecureRandom RANDOM = new SecureRandom(); + private static final int MTU = 1500; + + /** One direction of a loopback: datagrams appended by the sender, popped by the receiver. */ + static class Queue + { + final Vector datagrams = new Vector(); + + synchronized void put(byte[] datagram) + { + datagrams.addElement(datagram); + notifyAll(); + } + + synchronized byte[] take(int waitMillis) throws IOException + { + if (datagrams.isEmpty()) + { + if (waitMillis <= 0) + { + return null; + } + + try + { + wait(waitMillis); + } + catch (InterruptedException e) + { + throw new IOException("interrupted"); + } + if (datagrams.isEmpty()) + { + return null; + } + } + byte[] d = (byte[])datagrams.elementAt(0); + datagrams.removeElementAt(0); + return d; + } + + synchronized byte[] peekLast() + { + return (byte[])datagrams.elementAt(datagrams.size() - 1); + } + } + + static class QueueTransport + implements DatagramTransport + { + final Queue in, out; + + QueueTransport(Queue in, Queue out) + { + this.in = in; + this.out = out; + } + + public int getReceiveLimit() + { + return MTU; + } + + public int getSendLimit() + { + return MTU; + } + + public int receive(byte[] buf, int off, int len, int waitMillis) throws IOException + { + byte[] d = in.take(waitMillis); + if (null == d) + { + return -1; + } + int n = Math.min(len, d.length); + System.arraycopy(d, 0, buf, off, n); + return n; + } + + public void send(byte[] buf, int off, int len) throws IOException + { + out.put(Arrays.copyOfRange(buf, off, off + len)); + } + + public void close() + { + } + } + + static class Side + { + final AbstractTlsContext context; + final DTLSRecordLayer recordLayer; + + Side(AbstractTlsContext context, DTLSRecordLayer recordLayer) + { + this.context = context; + this.recordLayer = recordLayer; + } + } + + private Queue clientToServer, serverToClient; + private Side client, server; + + private void setUpPair(int cipherSuite, int hash) throws IOException + { + TlsCrypto crypto = new BcTlsCrypto(); + byte[] clientSecret = new byte[48]; + byte[] serverSecret = new byte[48]; + RANDOM.nextBytes(clientSecret); + RANDOM.nextBytes(serverSecret); + + clientToServer = new Queue(); + serverToClient = new Queue(); + + client = createSide(crypto, false, cipherSuite, hash, clientSecret, serverSecret, + new QueueTransport(serverToClient, clientToServer)); + server = createSide(crypto, true, cipherSuite, hash, clientSecret, serverSecret, + new QueueTransport(clientToServer, serverToClient)); + } + + private static Side createSide(TlsCrypto crypto, boolean isServer, int cipherSuite, int hash, + byte[] clientSecret, byte[] serverSecret, DatagramTransport transport) throws IOException + { + AbstractTlsContext context = TlsAEADCipherDTLS13Test.createContext(crypto, isServer, cipherSuite, hash, + clientSecret, serverSecret); + + TlsPeer peer = new DefaultTlsClient(crypto) + { + public TlsAuthentication getAuthentication() + { + return null; + } + }; + + DTLSRecordLayer recordLayer = new DTLSRecordLayer(context, peer, transport); + recordLayer.setWriteVersion(ProtocolVersion.DTLSv12); + recordLayer.setReadVersion(ProtocolVersion.DTLSv12); + + // epoch 2: handshake keys + recordLayer.initPendingEpoch(TlsUtils.initCipher(context)); + assertEquals(2, recordLayer.getPendingEpoch()); + recordLayer.enablePendingEpochRead(); + recordLayer.enablePendingEpochWrite(); + + // epoch 3: application keys (same secrets are fine for a record layer test) + recordLayer.initPendingEpoch(TlsUtils.initCipher(context)); + assertEquals(3, recordLayer.getPendingEpoch()); + recordLayer.enablePendingEpochWrite(); + recordLayer.enablePendingEpochRead(); + recordLayer.handshakeSuccessful(null); + + assertEquals(3, recordLayer.getReadEpoch()); + + return new Side(context, recordLayer); + } + + private static byte[] receive(Side side, int waitMillis) throws IOException + { + byte[] buf = new byte[side.recordLayer.getReceiveLimit()]; + int n = side.recordLayer.receive(buf, 0, buf.length, waitMillis); + return n < 0 ? null : Arrays.copyOf(buf, n); + } + + public void testProtectedRecordUsesUnifiedHeader() throws Exception + { + setUpPair(CipherSuite.TLS_AES_128_GCM_SHA256, CryptoHashAlgorithm.sha256); + + byte[] data = new byte[50]; + RANDOM.nextBytes(data); + client.recordLayer.send(data, 0, data.length); + + byte[] datagram = clientToServer.peekLast(); + int firstByte = datagram[0] & 0xFF; + assertTrue(DTLS13UnifiedHeader.isCiphertextRecord(firstByte)); + assertTrue(DTLS13UnifiedHeader.hasSeq16(firstByte)); + assertTrue(DTLS13UnifiedHeader.hasLength(firstByte)); + assertFalse(DTLS13UnifiedHeader.hasConnectionID(firstByte)); + assertTrue(DTLS13UnifiedHeader.matchesEpoch(firstByte, 3)); + assertEquals(datagram.length, 5 + TlsUtils.readUint16(datagram, 3)); + + byte[] received = receive(server, 1000); + assertNotNull(received); + assertTrue(Arrays.areEqual(data, received)); + } + + public void testRoundTripBothDirectionsAllSuites() throws Exception + { + int[] suites = { CipherSuite.TLS_AES_128_GCM_SHA256, CipherSuite.TLS_AES_256_GCM_SHA384, + CipherSuite.TLS_CHACHA20_POLY1305_SHA256, CipherSuite.TLS_AES_128_CCM_SHA256, + CipherSuite.TLS_AES_128_CCM_8_SHA256 }; + int[] hashes = { CryptoHashAlgorithm.sha256, CryptoHashAlgorithm.sha384, CryptoHashAlgorithm.sha256, + CryptoHashAlgorithm.sha256, CryptoHashAlgorithm.sha256 }; + + for (int s = 0; s < suites.length; ++s) + { + setUpPair(suites[s], hashes[s]); + + for (int i = 0; i < 20; ++i) + { + byte[] data = new byte[i]; + RANDOM.nextBytes(data); + client.recordLayer.send(data, 0, data.length); + byte[] got = receive(server, 1000); + assertNotNull("suite " + suites[s] + " len " + i, got); + assertTrue(Arrays.areEqual(data, got)); + + server.recordLayer.send(data, 0, data.length); + got = receive(client, 1000); + assertNotNull(got); + assertTrue(Arrays.areEqual(data, got)); + } + } + } + + public void testReplayIsDropped() throws Exception + { + setUpPair(CipherSuite.TLS_AES_128_GCM_SHA256, CryptoHashAlgorithm.sha256); + + byte[] data = new byte[10]; + client.recordLayer.send(data, 0, data.length); + byte[] datagram = (byte[])clientToServer.peekLast().clone(); + assertNotNull(receive(server, 1000)); + + clientToServer.put(datagram); + assertNull(receive(server, 200)); + } + + public void testShortAndCorruptRecordsAreDroppedSilently() throws Exception + { + setUpPair(CipherSuite.TLS_AES_128_GCM_SHA256, CryptoHashAlgorithm.sha256); + + // header claims 15 bytes of ciphertext: below the RFC 9147 4.2.3 minimum + byte[] tooShort = new byte[5 + 15]; + tooShort[0] = (byte)0x2F; + TlsUtils.writeUint16(15, tooShort, 3); + clientToServer.put(tooShort); + assertNull(receive(server, 200)); + + // valid length but garbage ciphertext: fails authentication, must not throw + byte[] garbage = new byte[5 + 40]; + garbage[0] = (byte)0x2F; + TlsUtils.writeUint16(40, garbage, 3); + RANDOM.nextBytes(garbage); + garbage[0] = (byte)0x2F; + TlsUtils.writeUint16(40, garbage, 3); + clientToServer.put(garbage); + assertNull(receive(server, 200)); + + // unknown epoch bits (epoch 1) with the rest valid-looking + byte[] wrongEpoch = new byte[5 + 40]; + wrongEpoch[0] = (byte)0x2D; + TlsUtils.writeUint16(40, wrongEpoch, 3); + clientToServer.put(wrongEpoch); + assertNull(receive(server, 200)); + + // the connection is still usable + byte[] data = new byte[7]; + client.recordLayer.send(data, 0, data.length); + assertTrue(Arrays.areEqual(data, receive(server, 1000))); + } + + public void testMultipleRecordsInOneDatagram() throws Exception + { + setUpPair(CipherSuite.TLS_AES_128_GCM_SHA256, CryptoHashAlgorithm.sha256); + + byte[] a = new byte[]{ 1, 2, 3 }; + byte[] b = new byte[]{ 4, 5, 6, 7 }; + client.recordLayer.send(a, 0, a.length); + client.recordLayer.send(b, 0, b.length); + + byte[] d1 = clientToServer.take(100); + byte[] d2 = clientToServer.take(100); + clientToServer.put(Arrays.concatenate(d1, d2)); + + assertTrue(Arrays.areEqual(a, receive(server, 1000))); + assertTrue(Arrays.areEqual(b, receive(server, 1000))); + } + + public void testApplicationDataAfterHandshakeWithRetransmitState() throws Exception + { + setUpPair(CipherSuite.TLS_AES_128_GCM_SHA256, CryptoHashAlgorithm.sha256); + + // A real handshake hands the record layer a retransmit handler; in DTLS 1.3 mode it must not + // put the write epoch into the legacy "retransmit" state that reclassifies sends as handshake. + DTLSHandshakeRetransmit retransmit = new DTLSHandshakeRetransmit() + { + public void receivedHandshakeRecord(int epoch, byte[] buf, int off, int len) + { + } + }; + client.recordLayer.handshakeSuccessful(retransmit); + + byte[] data = new byte[]{ 0x14, 0x15, 0x16 }; // first byte would parse as handshake type finished (20) + client.recordLayer.send(data, 0, data.length); + assertTrue(Arrays.areEqual(data, receive(server, 1000))); + } + + /** + * A conforming peer may send the compact header forms even though we only ever write the full form, so the + * receive path must handle them. The record is built by hand from a cipher keyed exactly like the client's + * epoch-3 cipher and placed on the wire directly, bypassing client.recordLayer.send. + */ + public void testCompactHeaderRecordIsReceived() throws Exception + { + setUpPair(CipherSuite.TLS_AES_128_GCM_SHA256, CryptoHashAlgorithm.sha256); + + TlsDTLS13Cipher cipher = (TlsDTLS13Cipher)TlsUtils.initCipher(client.context); + + byte[] data = new byte[37]; + RANDOM.nextBytes(data); + + // S = 0, L = 1, epoch bits 11: a 4-byte header with an 8-bit sequence number + long seq = 0; + byte[] header = TlsAEADCipherDTLS13Test.compactHeader(0x24 | 0x03, seq); + TlsEncodeResult encoded = cipher.encodeDTLS13Plaintext(seq, ContentType.application_data, header, 0, + header.length, data, 0, data.length); + + byte[] datagram = Arrays.copyOfRange(encoded.buf, encoded.off, encoded.off + encoded.len); + assertEquals(4 + TlsUtils.readUint16(datagram, 2), datagram.length); + clientToServer.put(datagram); + + assertTrue(Arrays.areEqual(data, receive(server, 1000))); + } + + /** + * As above, but with no length field at all (S = 1, L = 0): the receiver must take the ciphertext as the + * rest of the datagram. + */ + public void testCompactHeaderRecordWithoutLengthIsReceived() throws Exception + { + setUpPair(CipherSuite.TLS_AES_128_GCM_SHA256, CryptoHashAlgorithm.sha256); + + TlsDTLS13Cipher cipher = (TlsDTLS13Cipher)TlsUtils.initCipher(client.context); + + byte[] data = new byte[21]; + RANDOM.nextBytes(data); + + long seq = 0; + byte[] header = TlsAEADCipherDTLS13Test.compactHeader(0x28 | 0x03, seq); + assertEquals(3, header.length); + TlsEncodeResult encoded = cipher.encodeDTLS13Plaintext(seq, ContentType.application_data, header, 0, + header.length, data, 0, data.length); + + clientToServer.put(Arrays.copyOfRange(encoded.buf, encoded.off, encoded.off + encoded.len)); + + assertTrue(Arrays.areEqual(data, receive(server, 1000))); + } + + public void testLegacyHeaderPlaintextStillAcceptedAtEpochZero() throws Exception + { + // A DTLS 1.3 record layer still exchanges legacy-format plaintext records before keys exist; this + // guards the epoch-0 path used by ClientHello/ServerHello. + TlsCrypto crypto = new BcTlsCrypto(); + Queue c2s = new Queue(); + Queue s2c = new Queue(); + AbstractTlsContext context = TlsAEADCipherDTLS13Test.createContext(crypto, true, + CipherSuite.TLS_AES_128_GCM_SHA256, CryptoHashAlgorithm.sha256, new byte[32], new byte[32]); + TlsPeer peer = new DefaultTlsClient(crypto) + { + public TlsAuthentication getAuthentication() + { + return null; + } + }; + DTLSRecordLayer serverLayer = new DTLSRecordLayer(context, peer, new QueueTransport(c2s, s2c)); + serverLayer.setReadVersion(ProtocolVersion.DTLSv12); + + byte[] body = new byte[]{ HandshakeType.client_hello, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0 }; + byte[] record = new byte[13 + body.length]; + record[0] = (byte)ContentType.handshake; + TlsUtils.writeVersion(ProtocolVersion.DTLSv12, record, 1); + TlsUtils.writeUint16(0, record, 3); + TlsUtils.writeUint48(0, record, 5); + TlsUtils.writeUint16(body.length, record, 11); + System.arraycopy(body, 0, record, 13, body.length); + c2s.put(record); + + byte[] buf = new byte[serverLayer.getReceiveLimit()]; + int n = serverLayer.receive(buf, 0, buf.length, 1000); + assertEquals(body.length, n); + assertTrue(Arrays.areEqual(body, Arrays.copyOf(buf, n))); + } + + public void testCiphertextRecordClaimingEpochZeroIsDiscarded() throws Exception + { + // A DTLSCiphertext record's epoch bits can alias epoch 0 (only the low two bits are on the wire) while + // the read side is still on the epoch-0 null cipher, i.e. after initPendingEpoch but before + // enablePendingEpochRead. That must be a handled discard (-1), not an unchecked ClassCastException. + TlsCrypto crypto = new BcTlsCrypto(); + Queue c2s = new Queue(); + Queue s2c = new Queue(); + AbstractTlsContext context = TlsAEADCipherDTLS13Test.createContext(crypto, true, + CipherSuite.TLS_AES_128_GCM_SHA256, CryptoHashAlgorithm.sha256, new byte[32], new byte[32]); + TlsPeer peer = new DefaultTlsClient(crypto) + { + public TlsAuthentication getAuthentication() + { + return null; + } + }; + DTLSRecordLayer serverLayer = new DTLSRecordLayer(context, peer, new QueueTransport(c2s, s2c)); + serverLayer.setWriteVersion(ProtocolVersion.DTLSv12); + serverLayer.setReadVersion(ProtocolVersion.DTLSv12); + + // Puts the record layer into DTLS 1.3 mode (dtls13 == true) without moving the read epoch off epoch 0. + serverLayer.initPendingEpoch(TlsUtils.initCipher(context)); + assertEquals(2, serverLayer.getPendingEpoch()); + assertEquals(0, serverLayer.getReadEpoch()); + + // firstByte 0x2C: fixed bits 001, C=0, S=1, L=1, EE=00 -> matches epoch 0's low two bits. + int headerLength = 5; + int ciphertextLength = DTLS13UnifiedHeader.MIN_CIPHERTEXT_LENGTH; + byte[] record = new byte[headerLength + ciphertextLength]; + record[0] = (byte)0x2C; + TlsUtils.writeUint16(ciphertextLength, record, 3); + c2s.put(record); + + byte[] buf = new byte[serverLayer.getReceiveLimit()]; + int n = serverLayer.receive(buf, 0, buf.length, 1000); + assertEquals(-1, n); + } +} From 405626ef57f2e47885f58139dc2a6fccd9991bea Mon Sep 17 00:00:00 2001 From: Jonathan Lennox Date: Mon, 14 Sep 2026 17:08:34 -0400 Subject: [PATCH 6/6] Use the "dtls13" HKDF-Expand-Label prefix for the DTLS 1.3 key schedule (RFC 9147 section 5.9) instead of TLS 1.3's "tls13 ", so DTLS 1.3 traffic keys, record number keys, Finished keys and exporters interoperate with other implementations, relates to github #1468. --- .../bouncycastle/tls/AbstractTlsContext.java | 11 +- .../org/bouncycastle/tls/OfferedPsks.java | 8 +- .../bouncycastle/tls/TlsClientProtocol.java | 3 +- .../java/org/bouncycastle/tls/TlsUtils.java | 39 ++++-- .../tls/crypto/TlsCryptoUtils.java | 28 ++++- .../tls/crypto/impl/Tls13NullCipher.java | 18 +-- .../tls/crypto/impl/TlsAEADCipher.java | 14 ++- .../java/org/bouncycastle/tls/AllTests.java | 1 + .../tls/DTLS13KeyScheduleLabelTest.java | 116 ++++++++++++++++++ 9 files changed, 205 insertions(+), 33 deletions(-) create mode 100644 tls/src/test/java/org/bouncycastle/tls/DTLS13KeyScheduleLabelTest.java diff --git a/tls/src/main/java/org/bouncycastle/tls/AbstractTlsContext.java b/tls/src/main/java/org/bouncycastle/tls/AbstractTlsContext.java index 7f19fa9875..43b88be4b2 100644 --- a/tls/src/main/java/org/bouncycastle/tls/AbstractTlsContext.java +++ b/tls/src/main/java/org/bouncycastle/tls/AbstractTlsContext.java @@ -289,8 +289,9 @@ else if (!TlsUtils.isValidUint16(context.length)) TlsHash exporterHash = getCrypto().createHash(cryptoHashAlgorithm); byte[] emptyTranscriptHash = exporterHash.calculateHash(); - TlsSecret exporterSecret = TlsUtils.deriveSecret(getSecurityParametersConnection(), secret, asciiLabel, - emptyTranscriptHash); + SecurityParameters sp = getSecurityParametersConnection(); + + TlsSecret exporterSecret = TlsUtils.deriveSecret(sp, secret, asciiLabel, emptyTranscriptHash); byte[] exporterContext = emptyTranscriptHash; if (context.length > 0) @@ -299,8 +300,12 @@ else if (!TlsUtils.isValidUint16(context.length)) exporterContext = exporterHash.calculateHash(); } + // RFC 9147 5.9. DTLS 1.3 derives with the "dtls13" label prefix rather than TLS 1.3's "tls13 ". + boolean isDTLS = sp.getNegotiatedVersion().isDTLS(); + return TlsCryptoUtils - .hkdfExpandLabel(exporterSecret, cryptoHashAlgorithm, "exporter", exporterContext, length).extract(); + .hkdfExpandLabel(exporterSecret, cryptoHashAlgorithm, "exporter", exporterContext, length, isDTLS) + .extract(); } catch (IOException e) { diff --git a/tls/src/main/java/org/bouncycastle/tls/OfferedPsks.java b/tls/src/main/java/org/bouncycastle/tls/OfferedPsks.java index cf10f996ad..a0961caf8a 100644 --- a/tls/src/main/java/org/bouncycastle/tls/OfferedPsks.java +++ b/tls/src/main/java/org/bouncycastle/tls/OfferedPsks.java @@ -144,8 +144,12 @@ public void encode(OutputStream output) throws IOException } } + /** + * @param isDTLS whether the binders are for a DTLS 1.3 ClientHello, which selects the "dtls13" HKDF label prefix + * (RFC 9147 5.9) rather than TLS 1.3's "tls13 ". + */ static void encodeBinders(OutputStream output, TlsCrypto crypto, TlsHandshakeHash handshakeHash, - BindersConfig bindersConfig) throws IOException + BindersConfig bindersConfig, boolean isDTLS) throws IOException { TlsPSK[] psks = bindersConfig.psks; TlsSecret[] earlySecrets = bindersConfig.earlySecrets; @@ -170,7 +174,7 @@ static void encodeBinders(OutputStream output, TlsCrypto crypto, TlsHandshakeHas byte[] transcriptHash = hash.calculateHash(); byte[] binder = TlsUtils.calculatePSKBinder(crypto, isExternalPSK, pskCryptoHashAlgorithm, earlySecret, - transcriptHash); + transcriptHash, isDTLS); lengthOfBindersList += 1 + binder.length; TlsUtils.writeOpaque8(binder, output); diff --git a/tls/src/main/java/org/bouncycastle/tls/TlsClientProtocol.java b/tls/src/main/java/org/bouncycastle/tls/TlsClientProtocol.java index f44d340147..26ac518eb1 100644 --- a/tls/src/main/java/org/bouncycastle/tls/TlsClientProtocol.java +++ b/tls/src/main/java/org/bouncycastle/tls/TlsClientProtocol.java @@ -2008,7 +2008,8 @@ protected void sendClientHelloMessage() throws IOException if (null != clientBinders) { - OfferedPsks.encodeBinders(message, tlsClientContext.getCrypto(), handshakeHash, clientBinders); + // TLS over TCP, so the binders use the TLS 1.3 label prefix + OfferedPsks.encodeBinders(message, tlsClientContext.getCrypto(), handshakeHash, clientBinders, false); } message.sendClientHello(this, handshakeHash, clientHello.getBindersSize()); diff --git a/tls/src/main/java/org/bouncycastle/tls/TlsUtils.java b/tls/src/main/java/org/bouncycastle/tls/TlsUtils.java index fcb2c30906..62fed4ca67 100644 --- a/tls/src/main/java/org/bouncycastle/tls/TlsUtils.java +++ b/tls/src/main/java/org/bouncycastle/tls/TlsUtils.java @@ -1776,15 +1776,16 @@ private static byte[] calculateFinishedHMAC(SecurityParameters securityParameter { int prfCryptoHashAlgorithm = securityParameters.getPRFCryptoHashAlgorithm(); int prfHashLength = securityParameters.getPRFHashLength(); + boolean isDTLS = isDTLS(securityParameters); - return calculateFinishedHMAC(prfCryptoHashAlgorithm, prfHashLength, baseKey, transcriptHash); + return calculateFinishedHMAC(prfCryptoHashAlgorithm, prfHashLength, baseKey, transcriptHash, isDTLS); } private static byte[] calculateFinishedHMAC(int prfCryptoHashAlgorithm, int prfHashLength, TlsSecret baseKey, - byte[] transcriptHash) throws IOException + byte[] transcriptHash, boolean isDTLS) throws IOException { TlsSecret finishedKey = TlsCryptoUtils.hkdfExpandLabel(baseKey, prfCryptoHashAlgorithm, "finished", EMPTY_BYTES, - prfHashLength); + prfHashLength, isDTLS); try { @@ -1817,7 +1818,7 @@ static TlsSecret calculateMasterSecret(TlsContext context, TlsSecret preMasterSe } static byte[] calculatePSKBinder(TlsCrypto crypto, boolean isExternalPSK, int pskCryptoHashAlgorithm, - TlsSecret earlySecret, byte[] transcriptHash) throws IOException + TlsSecret earlySecret, byte[] transcriptHash, boolean isDTLS) throws IOException { int prfHashLength = TlsCryptoUtils.getHashOutputSize(pskCryptoHashAlgorithm); @@ -1825,11 +1826,11 @@ static byte[] calculatePSKBinder(TlsCrypto crypto, boolean isExternalPSK, int ps byte[] emptyTranscriptHash = crypto.createHash(pskCryptoHashAlgorithm).calculateHash(); TlsSecret binderKey = deriveSecret(pskCryptoHashAlgorithm, prfHashLength, earlySecret, label, - emptyTranscriptHash); + emptyTranscriptHash, isDTLS); try { - return calculateFinishedHMAC(pskCryptoHashAlgorithm, prfHashLength, binderKey, transcriptHash); + return calculateFinishedHMAC(pskCryptoHashAlgorithm, prfHashLength, binderKey, transcriptHash, isDTLS); } finally { @@ -2012,7 +2013,21 @@ private static void update13TrafficSecret(TlsContext context, boolean forServer) private static TlsSecret update13TrafficSecret(SecurityParameters securityParameters, TlsSecret secret) throws IOException { return TlsCryptoUtils.hkdfExpandLabel(secret, securityParameters.getPRFCryptoHashAlgorithm(), "traffic upd", - EMPTY_BYTES, securityParameters.getPRFHashLength()); + EMPTY_BYTES, securityParameters.getPRFHashLength(), isDTLS(securityParameters)); + } + + /** + * Whether the (D)TLS 1.3 key schedule for these security parameters uses the DTLS 1.3 label prefix (RFC 9147 + * 5.9), i.e. whether the negotiated version is a DTLS version. + */ + private static boolean isDTLS(SecurityParameters securityParameters) + { + ProtocolVersion negotiatedVersion = securityParameters.getNegotiatedVersion(); + if (null == negotiatedVersion) + { + throw new IllegalStateException("(D)TLS 1.3 key derivation before the version is negotiated"); + } + return negotiatedVersion.isDTLS(); } public static ASN1ObjectIdentifier getOIDForHashAlgorithm(short hashAlgorithm) @@ -6117,18 +6132,20 @@ static TlsSecret deriveSecret(SecurityParameters securityParameters, TlsSecret s int prfCryptoHashAlgorithm = securityParameters.getPRFCryptoHashAlgorithm(); int prfHashLength = securityParameters.getPRFHashLength(); - return deriveSecret(prfCryptoHashAlgorithm, prfHashLength, secret, label, transcriptHash); + return deriveSecret(prfCryptoHashAlgorithm, prfHashLength, secret, label, transcriptHash, + isDTLS(securityParameters)); } static TlsSecret deriveSecret(int prfCryptoHashAlgorithm, int prfHashLength, TlsSecret secret, String label, - byte[] transcriptHash) throws IOException + byte[] transcriptHash, boolean isDTLS) throws IOException { if (transcriptHash.length != prfHashLength) { throw new TlsFatalAlert(AlertDescription.internal_error); } - return TlsCryptoUtils.hkdfExpandLabel(secret, prfCryptoHashAlgorithm, label, transcriptHash, prfHashLength); + return TlsCryptoUtils.hkdfExpandLabel(secret, prfCryptoHashAlgorithm, label, transcriptHash, prfHashLength, + isDTLS); } static TlsSecret getSessionMasterSecret(TlsCrypto crypto, TlsSecret masterSecret) @@ -6449,7 +6466,7 @@ static OfferedPsks.SelectedConfig selectPreSharedKey(TlsServerContext serverCont } byte[] calculatedBinder = calculatePSKBinder(crypto, isExternalPSK, pskCryptoHashAlgorithm, - earlySecret, transcriptHash); + earlySecret, transcriptHash, serverContext.getServerVersion().isDTLS()); if (!Arrays.constantTimeAreEqual(calculatedBinder, binder)) { diff --git a/tls/src/main/java/org/bouncycastle/tls/crypto/TlsCryptoUtils.java b/tls/src/main/java/org/bouncycastle/tls/crypto/TlsCryptoUtils.java index e4cb2f7567..d31ab56536 100644 --- a/tls/src/main/java/org/bouncycastle/tls/crypto/TlsCryptoUtils.java +++ b/tls/src/main/java/org/bouncycastle/tls/crypto/TlsCryptoUtils.java @@ -21,6 +21,9 @@ public abstract class TlsCryptoUtils // "tls13 " private static final byte[] TLS13_PREFIX = new byte[]{ 0x74, 0x6c, 0x73, 0x31, 0x33, 0x20 }; + // "dtls13" (RFC 9147 5.9: no trailing space, so that the expanded label stays within one hash block) + private static final byte[] DTLS13_PREFIX = new byte[]{ 0x64, 0x74, 0x6c, 0x73, 0x31, 0x33 }; + public static int getHash(short hashAlgorithm) { switch (hashAlgorithm) @@ -192,8 +195,25 @@ public static int getSignature(short signatureAlgorithm) } } + /** + * HKDF-Expand-Label as defined in RFC 8446 7.1, with the "tls13 " label prefix. This is the TLS 1.3 form; for + * DTLS 1.3 use {@link #hkdfExpandLabel(TlsSecret, int, String, byte[], int, boolean)}, since RFC 9147 5.9 + * requires the "dtls13" prefix there. + */ public static TlsSecret hkdfExpandLabel(TlsSecret secret, int cryptoHashAlgorithm, String label, byte[] context, int length) throws IOException + { + return hkdfExpandLabel(secret, cryptoHashAlgorithm, label, context, length, false); + } + + /** + * HKDF-Expand-Label as defined in RFC 8446 7.1, with the label prefix selected by the protocol: "tls13 " for + * TLS 1.3, or "dtls13" for DTLS 1.3 (RFC 9147 5.9, which requires this for key separation between the two). + * + * @param isDTLS true to use the DTLS 1.3 label prefix, false for the TLS 1.3 one. + */ + public static TlsSecret hkdfExpandLabel(TlsSecret secret, int cryptoHashAlgorithm, String label, byte[] context, + int length, boolean isDTLS) throws IOException { int labelLength = label.length(); if (labelLength < 1) @@ -201,8 +221,10 @@ public static TlsSecret hkdfExpandLabel(TlsSecret secret, int cryptoHashAlgorith throw new TlsFatalAlert(AlertDescription.internal_error); } + byte[] prefix = isDTLS ? DTLS13_PREFIX : TLS13_PREFIX; + int contextLength = context.length; - int expandedLabelLength = TLS13_PREFIX.length + labelLength; + int expandedLabelLength = prefix.length + labelLength; byte[] hkdfLabel = new byte[2 + (1 + expandedLabelLength) + (1 + contextLength)]; @@ -217,9 +239,9 @@ public static TlsSecret hkdfExpandLabel(TlsSecret secret, int cryptoHashAlgorith TlsUtils.checkUint8(expandedLabelLength); TlsUtils.writeUint8(expandedLabelLength, hkdfLabel, 2); - System.arraycopy(TLS13_PREFIX, 0, hkdfLabel, 2 + 1, TLS13_PREFIX.length); + System.arraycopy(prefix, 0, hkdfLabel, 2 + 1, prefix.length); - int labelPos = 2 + (1 + TLS13_PREFIX.length); + int labelPos = 2 + (1 + prefix.length); for (int i = 0; i < labelLength; ++i) { char c = label.charAt(i); diff --git a/tls/src/main/java/org/bouncycastle/tls/crypto/impl/Tls13NullCipher.java b/tls/src/main/java/org/bouncycastle/tls/crypto/impl/Tls13NullCipher.java index f721f241ae..d294bf9b3c 100644 --- a/tls/src/main/java/org/bouncycastle/tls/crypto/impl/Tls13NullCipher.java +++ b/tls/src/main/java/org/bouncycastle/tls/crypto/impl/Tls13NullCipher.java @@ -205,15 +205,18 @@ private void rekeyHmac(SecurityParameters securityParameters, TlsHMAC hmac, byte throw new TlsFatalAlert(AlertDescription.internal_error); } - setupHmac(hmac, nonce, secret, securityParameters.getPRFCryptoHashAlgorithm()); + // RFC 9147 5.9. DTLS 1.3 derives with the "dtls13" label prefix rather than TLS 1.3's "tls13 ". + boolean isDTLS = securityParameters.getNegotiatedVersion().isDTLS(); + + setupHmac(hmac, nonce, secret, securityParameters.getPRFCryptoHashAlgorithm(), isDTLS); } - private void setupHmac(TlsHMAC hmac, byte[] nonce, TlsSecret secret, int cryptoHashAlgorithm) + private void setupHmac(TlsHMAC hmac, byte[] nonce, TlsSecret secret, int cryptoHashAlgorithm, boolean isDTLS) throws IOException { int length = hmac.getMacLength(); - byte[] key = hkdfExpandLabel(secret, cryptoHashAlgorithm, "key", length).extract(); - byte[] iv = hkdfExpandLabel(secret, cryptoHashAlgorithm, "iv", length).extract(); + byte[] key = hkdfExpandLabel(secret, cryptoHashAlgorithm, "key", length, isDTLS).extract(); + byte[] iv = hkdfExpandLabel(secret, cryptoHashAlgorithm, "iv", length, isDTLS).extract(); hmac.setKey(key, 0, length); System.arraycopy(iv, 0, nonce, 0, length); @@ -241,9 +244,10 @@ private static byte[] getAdditionalData(long seqNo, short recordType, ProtocolVe return additional_data; } - private static TlsSecret hkdfExpandLabel(TlsSecret secret, int cryptoHashAlgorithm, String label, int length) - throws IOException + private static TlsSecret hkdfExpandLabel(TlsSecret secret, int cryptoHashAlgorithm, String label, int length, + boolean isDTLS) throws IOException { - return TlsCryptoUtils.hkdfExpandLabel(secret, cryptoHashAlgorithm, label, TlsUtils.EMPTY_BYTES, length); + return TlsCryptoUtils.hkdfExpandLabel(secret, cryptoHashAlgorithm, label, TlsUtils.EMPTY_BYTES, length, + isDTLS); } } diff --git a/tls/src/main/java/org/bouncycastle/tls/crypto/impl/TlsAEADCipher.java b/tls/src/main/java/org/bouncycastle/tls/crypto/impl/TlsAEADCipher.java index 91c7893aed..5914f8f050 100644 --- a/tls/src/main/java/org/bouncycastle/tls/crypto/impl/TlsAEADCipher.java +++ b/tls/src/main/java/org/bouncycastle/tls/crypto/impl/TlsAEADCipher.java @@ -671,8 +671,9 @@ private void rekeyCipher(SecurityParameters securityParameters, TlsAEADCipherImp private void setup13Cipher(TlsAEADCipherImpl cipher, byte[] nonce, TlsRecordNumberMask mask, TlsSecret secret, int cryptoHashAlgorithm) throws IOException { - byte[] key = hkdfExpandLabel(secret, cryptoHashAlgorithm, "key", keySize).extract(); - byte[] iv = hkdfExpandLabel(secret, cryptoHashAlgorithm, "iv", fixed_iv_length).extract(); + // RFC 9147 5.9. DTLS 1.3 derives with the "dtls13" label prefix rather than TLS 1.3's "tls13 ". + byte[] key = hkdfExpandLabel(secret, cryptoHashAlgorithm, "key", keySize, isDTLSv13).extract(); + byte[] iv = hkdfExpandLabel(secret, cryptoHashAlgorithm, "iv", fixed_iv_length, isDTLSv13).extract(); cipher.setKey(key, 0, keySize); System.arraycopy(iv, 0, nonce, 0, fixed_iv_length); @@ -687,7 +688,7 @@ private void setup13Cipher(TlsAEADCipherImpl cipher, byte[] nonce, TlsRecordNumb throw new TlsFatalAlert(AlertDescription.internal_error, "No record number mask for DTLS 1.3"); } - byte[] snKey = hkdfExpandLabel(secret, cryptoHashAlgorithm, "sn", keySize).extract(); + byte[] snKey = hkdfExpandLabel(secret, cryptoHashAlgorithm, "sn", keySize, true).extract(); mask.setKey(snKey, 0, keySize); } } @@ -708,9 +709,10 @@ private static int getNonceMode(boolean isTLSv13, int aeadType) throws IOExcepti } } - private static TlsSecret hkdfExpandLabel(TlsSecret secret, int cryptoHashAlgorithm, String label, int length) - throws IOException + private static TlsSecret hkdfExpandLabel(TlsSecret secret, int cryptoHashAlgorithm, String label, int length, + boolean isDTLS) throws IOException { - return TlsCryptoUtils.hkdfExpandLabel(secret, cryptoHashAlgorithm, label, TlsUtils.EMPTY_BYTES, length); + return TlsCryptoUtils.hkdfExpandLabel(secret, cryptoHashAlgorithm, label, TlsUtils.EMPTY_BYTES, length, + isDTLS); } } diff --git a/tls/src/test/java/org/bouncycastle/tls/AllTests.java b/tls/src/test/java/org/bouncycastle/tls/AllTests.java index 2ff20fe487..6b0b7d7ece 100644 --- a/tls/src/test/java/org/bouncycastle/tls/AllTests.java +++ b/tls/src/test/java/org/bouncycastle/tls/AllTests.java @@ -23,6 +23,7 @@ public static Test suite() suite.addTestSuite(AbstractTlsServerResetTest.class); suite.addTestSuite(Add13CertificateStatusTest.class); suite.addTestSuite(CheckTlsFeaturesExtensionTest.class); + suite.addTestSuite(DTLS13KeyScheduleLabelTest.class); suite.addTestSuite(DTLS13UnifiedHeaderTest.class); suite.addTestSuite(DTLSReassemblerTest.class); suite.addTestSuite(DTLSRecordLayer13Test.class); diff --git a/tls/src/test/java/org/bouncycastle/tls/DTLS13KeyScheduleLabelTest.java b/tls/src/test/java/org/bouncycastle/tls/DTLS13KeyScheduleLabelTest.java new file mode 100644 index 0000000000..646becdf9e --- /dev/null +++ b/tls/src/test/java/org/bouncycastle/tls/DTLS13KeyScheduleLabelTest.java @@ -0,0 +1,116 @@ +package org.bouncycastle.tls; + +import java.security.SecureRandom; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; + +import junit.framework.TestCase; +import org.bouncycastle.tls.crypto.CryptoHashAlgorithm; +import org.bouncycastle.tls.crypto.TlsCryptoUtils; +import org.bouncycastle.tls.crypto.TlsSecret; +import org.bouncycastle.tls.crypto.impl.bc.BcTlsCrypto; +import org.bouncycastle.tls.crypto.impl.bc.BcTlsSecret; +import org.bouncycastle.util.Arrays; +import org.bouncycastle.util.Strings; + +/** + * RFC 9147 5.9: "Section 7.1 of [TLS13] specifies that HKDF-Expand-Label uses a label prefix of 'tls13 '. For + * DTLS 1.3, that label SHALL be 'dtls13'." Every DTLS 1.3 secret, traffic key, record number key, Finished key and + * exporter value depends on it, so a wrong prefix fails against every non-BC peer while passing every BC-to-BC + * test. The expected values are therefore computed here with an independent HKDF-Expand (javax.crypto HMAC), not + * with another BC code path. + */ +public class DTLS13KeyScheduleLabelTest + extends TestCase +{ + private static final byte[] SECRET = Strings.toByteArray("0123456789abcdef0123456789abcdef"); + private static final byte[] CONTEXT = Strings.toByteArray("transcript-hash-stand-in-value!!"); + + public void testDTLS13PrefixIsDtls13WithoutTrailingSpace() throws Exception + { + checkLabel(true, "dtls13", "key", 16); + checkLabel(true, "dtls13", "iv", 12); + checkLabel(true, "dtls13", "sn", 16); + checkLabel(true, "dtls13", "s hs traffic", 32); + checkLabel(true, "dtls13", "finished", 32); + checkLabel(true, "dtls13", "exporter", 60); + } + + public void testTLS13PrefixIsUnchanged() throws Exception + { + checkLabel(false, "tls13 ", "key", 16); + checkLabel(false, "tls13 ", "s hs traffic", 32); + } + + public void testTheTwoPrefixesGiveDifferentKeys() throws Exception + { + TlsSecret secret = secret(); + byte[] tls = TlsCryptoUtils.hkdfExpandLabel(secret, CryptoHashAlgorithm.sha256, "key", CONTEXT, 16, false) + .extract(); + byte[] dtls = TlsCryptoUtils.hkdfExpandLabel(secret, CryptoHashAlgorithm.sha256, "key", CONTEXT, 16, true) + .extract(); + assertFalse("DTLS 1.3 and TLS 1.3 must derive different keys from the same secret", + Arrays.areEqual(tls, dtls)); + } + + public void testFiveArgumentOverloadIsTheTLSForm() throws Exception + { + TlsSecret secret = secret(); + byte[] implicit = TlsCryptoUtils.hkdfExpandLabel(secret, CryptoHashAlgorithm.sha256, "key", CONTEXT, 16) + .extract(); + byte[] explicit = TlsCryptoUtils.hkdfExpandLabel(secret, CryptoHashAlgorithm.sha256, "key", CONTEXT, 16, + false).extract(); + assertTrue(Arrays.areEqual(explicit, implicit)); + } + + private void checkLabel(boolean isDTLS, String expectedPrefix, String label, int length) throws Exception + { + byte[] actual = TlsCryptoUtils.hkdfExpandLabel(secret(), CryptoHashAlgorithm.sha256, label, CONTEXT, length, + isDTLS).extract(); + byte[] expected = hkdfExpandLabel(SECRET, expectedPrefix + label, CONTEXT, length); + assertTrue("HKDF-Expand-Label(\"" + expectedPrefix + label + "\")", Arrays.areEqual(expected, actual)); + } + + private static TlsSecret secret() + { + return new BcTlsSecret(new BcTlsCrypto(new SecureRandom()), Arrays.clone(SECRET)); + } + + /** + * RFC 8446 7.1 HKDF-Expand-Label, built from the RFC 5869 HKDF-Expand definition with javax.crypto's + * HMAC-SHA256, independently of BC's TLS code. + */ + private static byte[] hkdfExpandLabel(byte[] secret, String fullLabel, byte[] context, int length) + throws Exception + { + byte[] labelBytes = Strings.toByteArray(fullLabel); + + byte[] hkdfLabel = new byte[2 + 1 + labelBytes.length + 1 + context.length]; + hkdfLabel[0] = (byte)(length >>> 8); + hkdfLabel[1] = (byte)length; + hkdfLabel[2] = (byte)labelBytes.length; + System.arraycopy(labelBytes, 0, hkdfLabel, 3, labelBytes.length); + hkdfLabel[3 + labelBytes.length] = (byte)context.length; + System.arraycopy(context, 0, hkdfLabel, 4 + labelBytes.length, context.length); + + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(secret, "HmacSHA256")); + + byte[] okm = new byte[length]; + byte[] t = new byte[0]; + int pos = 0; + for (int counter = 1; pos < length; ++counter) + { + mac.reset(); + mac.update(t); + mac.update(hkdfLabel); + mac.update((byte)counter); + t = mac.doFinal(); + int n = Math.min(t.length, length - pos); + System.arraycopy(t, 0, okm, pos, n); + pos += n; + } + return okm; + } +}