feat: implement MLS cryptographic engine in pure Kotlin (Phase 3)

Implements the core MLS (RFC 9420) engine for Marmot Protocol integration,
targeting ciphersuite 0x0001 (DHKEM-X25519, AES-128-GCM, SHA-256, Ed25519).

Components:
- codec/: TLS presentation language encoder/decoder (RFC 8446 Section 3)
- crypto/: Ed25519 signatures, X25519 ECDH, HPKE (RFC 9180), MlsCryptoProvider
  with ExpandWithLabel, DeriveSecret, SignWithLabel, EncryptWithLabel
- tree/: Left-balanced binary tree, LeafNode/ParentNode, RatchetTree with
  TreeKEM encap/decap, tree hashing, resolution, path secret derivation
- schedule/: Key schedule (epoch secret derivation chain), SecretTree
  (per-sender encryption ratchets), MLS-Exporter function
- framing/: MLSMessage, PublicMessage, PrivateMessage, content types
- messages/: Proposal (Add/Remove/Update/SelfRemove), Commit, UpdatePath,
  Welcome, GroupInfo, GroupContext, KeyPackage, GroupSecrets
- group/: MlsGroup high-level API (create, join via Welcome, add/remove
  members, encrypt/decrypt messages, export keys for Marmot outer layer)

Crypto uses expect/actual pattern: JVM/Android via java.security (EdDSA, XDH),
native platforms stubbed for future implementation. Reuses existing Quartz
primitives (AESGCM, HKDF, SHA-256, HMAC, ChaCha20-Poly1305).

Includes tests for TLS codec, binary tree arithmetic, and MLS type roundtrips.

https://claude.ai/code/session_01966YzookEUQDwszM3YCgeR
This commit is contained in:
Claude
2026-04-03 17:00:41 +00:00
parent 57a505c877
commit 119f9dd966
29 changed files with 5624 additions and 0 deletions
@@ -0,0 +1,163 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.marmot.mls
import com.vitorpamplona.quartz.marmot.mls.tree.BinaryTree
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
/**
* Tests for MLS left-balanced binary tree arithmetic (RFC 9420 Section 7.1).
*
* Test vectors match RFC 9420 Appendix C examples.
*/
class BinaryTreeTest {
@Test
fun testNodeCount() {
assertEquals(1, BinaryTree.nodeCount(1))
assertEquals(3, BinaryTree.nodeCount(2))
assertEquals(5, BinaryTree.nodeCount(3))
assertEquals(7, BinaryTree.nodeCount(4))
assertEquals(9, BinaryTree.nodeCount(5))
assertEquals(15, BinaryTree.nodeCount(8))
}
@Test
fun testLeafToNode() {
assertEquals(0, BinaryTree.leafToNode(0))
assertEquals(2, BinaryTree.leafToNode(1))
assertEquals(4, BinaryTree.leafToNode(2))
assertEquals(6, BinaryTree.leafToNode(3))
}
@Test
fun testIsLeaf() {
assertTrue(BinaryTree.isLeaf(0))
assertFalse(BinaryTree.isLeaf(1))
assertTrue(BinaryTree.isLeaf(2))
assertFalse(BinaryTree.isLeaf(3))
assertTrue(BinaryTree.isLeaf(4))
}
@Test
fun testLevel() {
// Leaves at level 0
assertEquals(0, BinaryTree.level(0))
assertEquals(0, BinaryTree.level(2))
assertEquals(0, BinaryTree.level(4))
assertEquals(0, BinaryTree.level(6))
// Level 1 parents
assertEquals(1, BinaryTree.level(1))
assertEquals(1, BinaryTree.level(5))
// Level 2 parent (root of 4-leaf tree)
assertEquals(2, BinaryTree.level(3))
// Level 3
assertEquals(3, BinaryTree.level(7))
}
@Test
fun testLeftRight() {
// For node 1 (level 1): left=0, right=2
assertEquals(0, BinaryTree.left(1))
assertEquals(2, BinaryTree.right(1))
// For node 5 (level 1): left=4, right=6
assertEquals(4, BinaryTree.left(5))
assertEquals(6, BinaryTree.right(5))
// For node 3 (level 2): left=1, right=5
assertEquals(1, BinaryTree.left(3))
assertEquals(5, BinaryTree.right(3))
}
@Test
fun testParent4Leaves() {
// Tree with 4 leaves (7 nodes):
// 3
// / \
// 1 5
// / \ / \
// 0 2 4 6
val n = BinaryTree.nodeCount(4) // 7
assertEquals(1, BinaryTree.parent(0, n))
assertEquals(1, BinaryTree.parent(2, n))
assertEquals(5, BinaryTree.parent(4, n))
assertEquals(5, BinaryTree.parent(6, n))
assertEquals(3, BinaryTree.parent(1, n))
assertEquals(3, BinaryTree.parent(5, n))
}
@Test
fun testRoot() {
assertEquals(0, BinaryTree.root(1))
assertEquals(1, BinaryTree.root(2))
assertEquals(3, BinaryTree.root(4))
assertEquals(7, BinaryTree.root(8))
}
@Test
fun testDirectPath4Leaves() {
// Leaf 0 -> direct path: [1, 3]
assertEquals(listOf(1, 3), BinaryTree.directPath(0, 4))
// Leaf 1 -> direct path: [1, 3]
assertEquals(listOf(1, 3), BinaryTree.directPath(1, 4))
// Leaf 2 -> direct path: [5, 3]
assertEquals(listOf(5, 3), BinaryTree.directPath(2, 4))
// Leaf 3 -> direct path: [5, 3]
assertEquals(listOf(5, 3), BinaryTree.directPath(3, 4))
}
@Test
fun testCopath4Leaves() {
// Leaf 0 copath: sibling of 0 is 2, sibling of 1 is 5 -> [2, 5]
assertEquals(listOf(2, 5), BinaryTree.copath(0, 4))
// Leaf 2 copath: sibling of 4 is 6, sibling of 5 is 1 -> [6, 1]
assertEquals(listOf(6, 1), BinaryTree.copath(2, 4))
}
@Test
fun testSibling() {
val n = BinaryTree.nodeCount(4)
assertEquals(2, BinaryTree.sibling(0, n))
assertEquals(0, BinaryTree.sibling(2, n))
assertEquals(6, BinaryTree.sibling(4, n))
assertEquals(5, BinaryTree.sibling(1, n))
assertEquals(1, BinaryTree.sibling(5, n))
}
@Test
fun testSubtreeLeaves() {
// Subtree of node 1 contains leaves 0, 1
assertEquals(listOf(0, 1), BinaryTree.subtreeLeaves(1, 4))
// Subtree of node 5 contains leaves 2, 3
assertEquals(listOf(2, 3), BinaryTree.subtreeLeaves(5, 4))
// Subtree of root 3 contains all leaves
assertEquals(listOf(0, 1, 2, 3), BinaryTree.subtreeLeaves(3, 4))
// Subtree of leaf 0 is just [0]
assertEquals(listOf(0), BinaryTree.subtreeLeaves(0, 4))
}
}
@@ -0,0 +1,233 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.marmot.mls
import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader
import com.vitorpamplona.quartz.marmot.mls.framing.ContentType
import com.vitorpamplona.quartz.marmot.mls.framing.MlsMessage
import com.vitorpamplona.quartz.marmot.mls.framing.PrivateMessage
import com.vitorpamplona.quartz.marmot.mls.framing.WireFormat
import com.vitorpamplona.quartz.marmot.mls.messages.Commit
import com.vitorpamplona.quartz.marmot.mls.messages.GroupContext
import com.vitorpamplona.quartz.marmot.mls.messages.Proposal
import com.vitorpamplona.quartz.marmot.mls.messages.ProposalOrRef
import com.vitorpamplona.quartz.marmot.mls.tree.Capabilities
import com.vitorpamplona.quartz.marmot.mls.tree.Credential
import com.vitorpamplona.quartz.marmot.mls.tree.Extension
import com.vitorpamplona.quartz.marmot.mls.tree.LeafNode
import com.vitorpamplona.quartz.marmot.mls.tree.LeafNodeSource
import com.vitorpamplona.quartz.marmot.mls.tree.Lifetime
import com.vitorpamplona.quartz.marmot.mls.tree.ParentNode
import kotlin.test.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
/**
* Tests for MLS type serialization/deserialization.
* Verifies round-trip encoding matches the expected wire format.
*/
class MlsTypesTest {
@Test
fun testCredentialBasicRoundTrip() {
val identity = "alice@example.com".encodeToByteArray()
val cred = Credential.Basic(identity)
val bytes = cred.toTlsBytes()
val decoded = Credential.decodeTls(TlsReader(bytes))
assertTrue(decoded is Credential.Basic)
assertContentEquals(identity, (decoded as Credential.Basic).identity)
}
@Test
fun testCapabilitiesRoundTrip() {
val caps =
Capabilities(
versions = listOf(1),
ciphersuites = listOf(1, 3),
extensions = listOf(0xF2EE),
proposals = listOf(1, 2, 3, 8),
credentials = listOf(1),
)
val bytes = caps.toTlsBytes()
val decoded = Capabilities.decodeTls(TlsReader(bytes))
assertEquals(caps.versions, decoded.versions)
assertEquals(caps.ciphersuites, decoded.ciphersuites)
assertEquals(caps.extensions, decoded.extensions)
assertEquals(caps.proposals, decoded.proposals)
assertEquals(caps.credentials, decoded.credentials)
}
@Test
fun testExtensionRoundTrip() {
val ext = Extension(0xF2EE, byteArrayOf(0x01, 0x02, 0x03))
val bytes = ext.toTlsBytes()
val decoded = Extension.decodeTls(TlsReader(bytes))
assertEquals(ext.extensionType, decoded.extensionType)
assertContentEquals(ext.extensionData, decoded.extensionData)
}
@Test
fun testLeafNodeRoundTrip() {
val leaf =
LeafNode(
encryptionKey = ByteArray(32) { it.toByte() },
signatureKey = ByteArray(32) { (it + 32).toByte() },
credential = Credential.Basic("test".encodeToByteArray()),
capabilities = Capabilities(),
leafNodeSource = LeafNodeSource.KEY_PACKAGE,
lifetime = Lifetime(1000, 2000),
extensions = emptyList(),
signature = ByteArray(64) { (it + 64).toByte() },
)
val bytes = leaf.toTlsBytes()
val decoded = LeafNode.decodeTls(TlsReader(bytes))
assertContentEquals(leaf.encryptionKey, decoded.encryptionKey)
assertContentEquals(leaf.signatureKey, decoded.signatureKey)
assertEquals(leaf.leafNodeSource, decoded.leafNodeSource)
assertEquals(leaf.lifetime?.notBefore, decoded.lifetime?.notBefore)
assertEquals(leaf.lifetime?.notAfter, decoded.lifetime?.notAfter)
assertContentEquals(leaf.signature, decoded.signature)
}
@Test
fun testParentNodeRoundTrip() {
val parent =
ParentNode(
encryptionKey = ByteArray(32) { it.toByte() },
parentHash = ByteArray(32) { (it + 1).toByte() },
unmergedLeaves = listOf(1, 3, 5),
)
val bytes = parent.toTlsBytes()
val decoded = ParentNode.decodeTls(TlsReader(bytes))
assertContentEquals(parent.encryptionKey, decoded.encryptionKey)
assertContentEquals(parent.parentHash, decoded.parentHash)
assertEquals(parent.unmergedLeaves, decoded.unmergedLeaves)
}
@Test
fun testGroupContextRoundTrip() {
val ctx =
GroupContext(
groupId = ByteArray(32) { it.toByte() },
epoch = 42,
treeHash = ByteArray(32) { (it + 1).toByte() },
confirmedTranscriptHash = ByteArray(32) { (it + 2).toByte() },
extensions = listOf(Extension(0xF2EE, byteArrayOf(0x01))),
)
val bytes = ctx.toTlsBytes()
val decoded = GroupContext.decodeTls(TlsReader(bytes))
assertContentEquals(ctx.groupId, decoded.groupId)
assertEquals(ctx.epoch, decoded.epoch)
assertContentEquals(ctx.treeHash, decoded.treeHash)
assertContentEquals(ctx.confirmedTranscriptHash, decoded.confirmedTranscriptHash)
assertEquals(1, decoded.extensions.size)
assertEquals(0xF2EE, decoded.extensions[0].extensionType)
}
@Test
fun testProposalRemoveRoundTrip() {
val proposal = Proposal.Remove(5)
val bytes = proposal.toTlsBytes()
val decoded = Proposal.decodeTls(TlsReader(bytes))
assertTrue(decoded is Proposal.Remove)
assertEquals(5, (decoded as Proposal.Remove).removedLeafIndex)
}
@Test
fun testProposalSelfRemoveRoundTrip() {
val proposal = Proposal.SelfRemove()
val bytes = proposal.toTlsBytes()
val decoded = Proposal.decodeTls(TlsReader(bytes))
assertTrue(decoded is Proposal.SelfRemove)
}
@Test
fun testCommitRoundTrip() {
val commit =
Commit(
proposals =
listOf(
ProposalOrRef.Inline(Proposal.Remove(2)),
),
updatePath = null,
)
val bytes = commit.toTlsBytes()
val decoded = Commit.decodeTls(TlsReader(bytes))
assertEquals(1, decoded.proposals.size)
assertEquals(null, decoded.updatePath)
}
@Test
fun testPrivateMessageRoundTrip() {
val msg =
PrivateMessage(
groupId = ByteArray(16) { it.toByte() },
epoch = 5,
contentType = ContentType.APPLICATION,
authenticatedData = ByteArray(0),
encryptedSenderData = ByteArray(24) { it.toByte() },
ciphertext = ByteArray(100) { it.toByte() },
)
val bytes = msg.toTlsBytes()
val decoded = PrivateMessage.decodeTls(TlsReader(bytes))
assertContentEquals(msg.groupId, decoded.groupId)
assertEquals(msg.epoch, decoded.epoch)
assertEquals(msg.contentType, decoded.contentType)
assertContentEquals(msg.encryptedSenderData, decoded.encryptedSenderData)
assertContentEquals(msg.ciphertext, decoded.ciphertext)
}
@Test
fun testMlsMessageRoundTrip() {
val privMsg =
PrivateMessage(
groupId = ByteArray(16),
epoch = 1,
contentType = ContentType.APPLICATION,
authenticatedData = ByteArray(0),
encryptedSenderData = ByteArray(8),
ciphertext = ByteArray(32),
)
val mlsMsg = MlsMessage.fromPrivateMessage(privMsg)
assertEquals(WireFormat.PRIVATE_MESSAGE, mlsMsg.wireFormat)
assertEquals(1, mlsMsg.version)
}
private fun assertTrue(condition: Boolean) {
kotlin.test.assertTrue(condition)
}
}
@@ -0,0 +1,255 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.marmot.mls
import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader
import com.vitorpamplona.quartz.marmot.mls.codec.TlsSerializable
import com.vitorpamplona.quartz.marmot.mls.codec.TlsWriter
import kotlin.test.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
/**
* Tests for TLS presentation language codec (RFC 8446 Section 3).
*
* These tests verify wire format compatibility — the encoding must exactly
* match what OpenMLS and mls-rs produce for interoperability.
*/
class TlsCodecTest {
@Test
fun testUint8RoundTrip() {
val writer = TlsWriter()
writer.putUint8(0)
writer.putUint8(127)
writer.putUint8(255)
val bytes = writer.toByteArray()
assertEquals(3, bytes.size)
val reader = TlsReader(bytes)
assertEquals(0, reader.readUint8())
assertEquals(127, reader.readUint8())
assertEquals(255, reader.readUint8())
assertFalse(reader.hasRemaining)
}
@Test
fun testUint16RoundTrip() {
val writer = TlsWriter()
writer.putUint16(0)
writer.putUint16(256)
writer.putUint16(65535)
val bytes = writer.toByteArray()
assertEquals(6, bytes.size)
val reader = TlsReader(bytes)
assertEquals(0, reader.readUint16())
assertEquals(256, reader.readUint16())
assertEquals(65535, reader.readUint16())
}
@Test
fun testUint16BigEndian() {
val writer = TlsWriter()
writer.putUint16(0x0102)
val bytes = writer.toByteArray()
assertEquals(0x01.toByte(), bytes[0])
assertEquals(0x02.toByte(), bytes[1])
}
@Test
fun testUint32RoundTrip() {
val writer = TlsWriter()
writer.putUint32(0)
writer.putUint32(0xFFFFFFFFL)
val bytes = writer.toByteArray()
assertEquals(8, bytes.size)
val reader = TlsReader(bytes)
assertEquals(0L, reader.readUint32())
assertEquals(0xFFFFFFFFL, reader.readUint32())
}
@Test
fun testUint64RoundTrip() {
val writer = TlsWriter()
writer.putUint64(0L)
writer.putUint64(Long.MAX_VALUE)
writer.putUint64(-1L) // 0xFFFFFFFFFFFFFFFF as unsigned
val bytes = writer.toByteArray()
assertEquals(24, bytes.size)
val reader = TlsReader(bytes)
assertEquals(0L, reader.readUint64())
assertEquals(Long.MAX_VALUE, reader.readUint64())
assertEquals(-1L, reader.readUint64())
}
@Test
fun testOpaque1RoundTrip() {
val data = byteArrayOf(0x01, 0x02, 0x03)
val writer = TlsWriter()
writer.putOpaque1(data)
val bytes = writer.toByteArray()
assertEquals(4, bytes.size) // 1 byte length + 3 bytes data
assertEquals(3, bytes[0].toInt()) // length prefix
val reader = TlsReader(bytes)
assertContentEquals(data, reader.readOpaque1())
}
@Test
fun testOpaque2RoundTrip() {
val data = ByteArray(300) { it.toByte() }
val writer = TlsWriter()
writer.putOpaque2(data)
val bytes = writer.toByteArray()
assertEquals(302, bytes.size)
val reader = TlsReader(bytes)
assertContentEquals(data, reader.readOpaque2())
}
@Test
fun testOpaque4RoundTrip() {
val data = byteArrayOf(0xAA.toByte(), 0xBB.toByte())
val writer = TlsWriter()
writer.putOpaque4(data)
val bytes = writer.toByteArray()
assertEquals(6, bytes.size) // 4 byte length + 2 bytes data
val reader = TlsReader(bytes)
assertContentEquals(data, reader.readOpaque4())
}
@Test
fun testEmptyOpaque() {
val writer = TlsWriter()
writer.putOpaque1(ByteArray(0))
writer.putOpaque2(ByteArray(0))
writer.putOpaque4(ByteArray(0))
val reader = TlsReader(writer.toByteArray())
assertContentEquals(ByteArray(0), reader.readOpaque1())
assertContentEquals(ByteArray(0), reader.readOpaque2())
assertContentEquals(ByteArray(0), reader.readOpaque4())
}
@Test
fun testVectorOfStructs() {
data class TestStruct(
val a: Int,
val b: Int,
) : TlsSerializable {
override fun encodeTls(writer: TlsWriter) {
writer.putUint8(a)
writer.putUint16(b)
}
}
val items = listOf(TestStruct(1, 100), TestStruct(2, 200))
val writer = TlsWriter()
writer.putVector2(items)
val reader = TlsReader(writer.toByteArray())
val decoded =
reader.readVector2 { r ->
val a = r.readUint8()
val b = r.readUint16()
TestStruct(a, b)
}
assertEquals(2, decoded.size)
assertEquals(1, decoded[0].a)
assertEquals(100, decoded[0].b)
assertEquals(2, decoded[1].a)
assertEquals(200, decoded[1].b)
}
@Test
fun testOptionalPresent() {
data class Inner(
val x: Int,
) : TlsSerializable {
override fun encodeTls(writer: TlsWriter) {
writer.putUint16(x)
}
}
val writer = TlsWriter()
writer.putOptional(Inner(42))
val reader = TlsReader(writer.toByteArray())
val result = reader.readOptional { Inner(it.readUint16()) }
assertEquals(42, result?.x)
}
@Test
fun testOptionalAbsent() {
val writer = TlsWriter()
writer.putOptional(null)
val reader = TlsReader(writer.toByteArray())
val result = reader.readOptional { it.readUint16() }
assertNull(result)
}
@Test
fun testSubReader() {
val writer = TlsWriter()
writer.putUint8(1)
writer.putUint8(2)
writer.putUint8(3)
writer.putUint8(4)
val reader = TlsReader(writer.toByteArray())
assertEquals(1, reader.readUint8())
val sub = reader.subReader(2)
assertEquals(2, sub.readUint8())
assertEquals(3, sub.readUint8())
assertFalse(sub.hasRemaining)
assertEquals(4, reader.readUint8())
}
@Test
fun testWriterGrowsAutomatically() {
val writer = TlsWriter(initialCapacity = 4)
// Write more than initial capacity
for (i in 0 until 100) {
writer.putUint8(i)
}
assertEquals(100, writer.size)
val reader = TlsReader(writer.toByteArray())
for (i in 0 until 100) {
assertEquals(i, reader.readUint8())
}
}
}