diff --git a/koap-core/api/koap-core.api b/koap-core/api/koap-core.api index 5e4d5238..c44cec48 100644 --- a/koap-core/api/koap-core.api +++ b/koap-core/api/koap-core.api @@ -309,6 +309,11 @@ public final class com/juul/koap/Message$Option$Echo : com/juul/koap/Message$Opt public fun toString ()Ljava/lang/String; } +public final class com/juul/koap/Message$Option$Edhoc : com/juul/koap/Message$Option { + public static final field INSTANCE Lcom/juul/koap/Message$Option$Edhoc; + public fun toString ()Ljava/lang/String; +} + public abstract class com/juul/koap/Message$Option$Format : com/juul/koap/Message$Option { public abstract fun getNumber ()I } @@ -475,6 +480,17 @@ public final class com/juul/koap/Message$Option$Observe$Registration$Register : public fun toString ()Ljava/lang/String; } +public final class com/juul/koap/Message$Option$Oscore : com/juul/koap/Message$Option { + public fun ([B)V + public final fun component1 ()[B + public final fun copy ([B)Lcom/juul/koap/Message$Option$Oscore; + public static synthetic fun copy$default (Lcom/juul/koap/Message$Option$Oscore;[BILjava/lang/Object;)Lcom/juul/koap/Message$Option$Oscore; + public fun equals (Ljava/lang/Object;)Z + public final fun getValue ()[B + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + public final class com/juul/koap/Message$Option$ProxyScheme : com/juul/koap/Message$Option { public fun (Ljava/lang/String;)V public final fun component1 ()Ljava/lang/String; diff --git a/koap-core/api/koap-core.klib.api b/koap-core/api/koap-core.klib.api index 617ba6fe..c2764019 100644 --- a/koap-core/api/koap-core.klib.api +++ b/koap-core/api/koap-core.klib.api @@ -483,6 +483,19 @@ sealed class com.juul.koap/Message { // com.juul.koap/Message|null[0] } } + final class Oscore : com.juul.koap/Message.Option { // com.juul.koap/Message.Option.Oscore|null[0] + constructor (kotlin/ByteArray) // com.juul.koap/Message.Option.Oscore.|(kotlin.ByteArray){}[0] + + final val value // com.juul.koap/Message.Option.Oscore.value|{}value[0] + final fun (): kotlin/ByteArray // com.juul.koap/Message.Option.Oscore.value.|(){}[0] + + final fun component1(): kotlin/ByteArray // com.juul.koap/Message.Option.Oscore.component1|component1(){}[0] + final fun copy(kotlin/ByteArray = ...): com.juul.koap/Message.Option.Oscore // com.juul.koap/Message.Option.Oscore.copy|copy(kotlin.ByteArray){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // com.juul.koap/Message.Option.Oscore.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // com.juul.koap/Message.Option.Oscore.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // com.juul.koap/Message.Option.Oscore.toString|toString(){}[0] + } + final class ProxyScheme : com.juul.koap/Message.Option { // com.juul.koap/Message.Option.ProxyScheme|null[0] constructor (kotlin/String) // com.juul.koap/Message.Option.ProxyScheme.|(kotlin.String){}[0] @@ -704,6 +717,10 @@ sealed class com.juul.koap/Message { // com.juul.koap/Message|null[0] } } + final object Edhoc : com.juul.koap/Message.Option { // com.juul.koap/Message.Option.Edhoc|null[0] + final fun toString(): kotlin/String // com.juul.koap/Message.Option.Edhoc.toString|toString(){}[0] + } + final object IfNoneMatch : com.juul.koap/Message.Option { // com.juul.koap/Message.Option.IfNoneMatch|null[0] final fun toString(): kotlin/String // com.juul.koap/Message.Option.IfNoneMatch.toString|toString(){}[0] } diff --git a/koap-core/src/commonMain/kotlin/Decoder.kt b/koap-core/src/commonMain/kotlin/Decoder.kt index 28d84c86..8aa21f10 100644 --- a/koap-core/src/commonMain/kotlin/Decoder.kt +++ b/koap-core/src/commonMain/kotlin/Decoder.kt @@ -32,6 +32,7 @@ import com.juul.koap.Message.Option.Block2 import com.juul.koap.Message.Option.ContentFormat import com.juul.koap.Message.Option.ETag import com.juul.koap.Message.Option.Echo +import com.juul.koap.Message.Option.Edhoc import com.juul.koap.Message.Option.Format import com.juul.koap.Message.Option.HopLimit import com.juul.koap.Message.Option.IfMatch @@ -41,6 +42,7 @@ import com.juul.koap.Message.Option.LocationQuery import com.juul.koap.Message.Option.MaxAge import com.juul.koap.Message.Option.NoResponse import com.juul.koap.Message.Option.Observe +import com.juul.koap.Message.Option.Oscore import com.juul.koap.Message.Option.ProxyScheme import com.juul.koap.Message.Option.ProxyUri import com.juul.koap.Message.Option.QBlock1 @@ -406,6 +408,7 @@ internal fun ByteArrayReader.readOption(preceding: Format?): Option? { 6 -> Observe(readNumberOfLength(length)) 7 -> UriPort(readNumberOfLength(length)) 8 -> LocationPath(readUtf8(length)) + 9 -> Oscore(readByteArray(length)) 11 -> UriPath(readUtf8(length)) 12 -> ContentFormat(readNumberOfLength(length)) 14 -> MaxAge(readNumberOfLength(length)) @@ -414,6 +417,7 @@ internal fun ByteArrayReader.readOption(preceding: Format?): Option? { 17 -> Accept(readNumberOfLength(length)) 19 -> blockOf(readNumberOfLength(length)) 20 -> LocationQuery(readUtf8(length)) + 21 -> Edhoc 23 -> blockOf(readNumberOfLength(length)) 27 -> blockOf(readNumberOfLength(length)) 28 -> Size2(readNumberOfLength(length)) diff --git a/koap-core/src/commonMain/kotlin/Message.kt b/koap-core/src/commonMain/kotlin/Message.kt index 503c0a6d..4b3595a7 100644 --- a/koap-core/src/commonMain/kotlin/Message.kt +++ b/koap-core/src/commonMain/kotlin/Message.kt @@ -44,6 +44,7 @@ private val PROXY_SCHEME_LENGTH_RANGE = 1..255 private val SIZE_RANGE = UINT_RANGE // Size1, Size2 private val OBSERVE_RANGE = 0..16_777_215 // 3-byte unsigned int internal val BLOCK_NUMBER_RANGE = 0..0xF_FF_FF +private val OSCORE_LENGTH_RANGE = 0..255 private val HOP_LIMIT_RANGE = 1..255 private val ECHO_SIZE_RANGE = 1..40 private val REQUEST_TAG_SIZE_RANGE = 0..8 @@ -88,7 +89,7 @@ sealed class Message { * | 6 | [Observe][Observe] | uint | 0-3 | [RFC 7641](https://tools.ietf.org/html/rfc7641#section-2) Observing Resources 2 | * | 7 | [Uri-Port][UriPort] | uint | 0-2 | [RFC 7252](https://tools.ietf.org/html/rfc7252#section-5.10.1) CoAP 5.10.1 | * | 8 | [Location-Path][LocationPath] | string | 0-255 | [RFC 7252](https://tools.ietf.org/html/rfc7252#section-5.10.7) CoAP 5.10.7 | - * | 9 | OSCORE | opaque | 0-255 | [RFC 8613](https://tools.ietf.org/html/rfc8613#section-2) OSCORE 2 | + * | 9 | [OSCORE][Oscore] | opaque | 0-255 | [RFC 8613](https://tools.ietf.org/html/rfc8613#section-2) OSCORE 2 | * | 11 | [Uri-Path][UriPath] | string | 0-255 | [RFC 7252](https://tools.ietf.org/html/rfc7252#section-5.10.1) CoAP 5.10.1 | * | 12 | [Content-Format][ContentFormat] | uint | 0-2 | [RFC 7252](https://tools.ietf.org/html/rfc7252#section-5.10.3) CoAP 5.10.3 | * | 14 | [Max-Age][MaxAge] | uint | 0-4 | [RFC 7252](https://tools.ietf.org/html/rfc7252#section-5.10.5) CoAP 5.10.5 | @@ -97,7 +98,7 @@ sealed class Message { * | 17 | [Accept][Accept] | uint | 0-2 | [RFC 7252](https://tools.ietf.org/html/rfc7252#section-5.10.4) CoAP 5.10.4 | * | 19 | [Q-Block1][QBlock1] | uint | 0-3 | [RFC 9177](https://tools.ietf.org/html/rfc9177#section-4) Block-Wise Robust 4 | * | 20 | [Location-Query][LocationQuery] | string | 0-255 | [RFC 7252](https://tools.ietf.org/html/rfc7252#section-5.10.7) CoAP 5.10.7 | - * | 21 | EDHOC | empty | 0 | [RFC 9668](https://tools.ietf.org/html/rfc9668#section-3.1) EDHOC 3.1 | + * | 21 | [EDHOC][Edhoc] | empty | 0 | [RFC 9668](https://tools.ietf.org/html/rfc9668#section-3.1) EDHOC 3.1 | * | 23 | [Block2] | uint | 0-3 | [RFC 7959](https://tools.ietf.org/html/rfc7959#section-2.1) Block-Wise 2.1 | * | 27 | [Block1] | uint | 0-3 | [RFC 7959](https://tools.ietf.org/html/rfc7959#section-2.1) Block-Wise 2.1 | * | 28 | [Size2] | uint | 0-4 | [RFC 7959](https://tools.ietf.org/html/rfc7959#section-4) Block-Wise 4 | @@ -296,7 +297,7 @@ sealed class Message { override fun hashCode(): Int = etag.contentHashCode() - override fun toString(): String = "ETag(etag=${etag.toHexString()}" + override fun toString(): String = "ETag(etag=${etag.toHexString()})" } /** RFC 7252 5.10.7. Location-Path and Location-Query */ @@ -336,7 +337,7 @@ sealed class Message { override fun hashCode(): Int = etag.contentHashCode() - override fun toString(): String = "IfMatch(etag=${etag.toHexString()}" + override fun toString(): String = "IfMatch(etag=${etag.toHexString()})" } /** RFC 7252 5.10.8.2. If-None-Match */ @@ -514,6 +515,29 @@ sealed class Message { } } + /** RFC 8613 2. OSCORE */ + data class Oscore( + val value: ByteArray, + ) : Option() { + init { + require(value.size in OSCORE_LENGTH_RANGE) { + "Oscore length of ${value.size} is outside allowable range of $OSCORE_LENGTH_RANGE" + } + } + + override fun equals(other: Any?): Boolean = + this === other || (other is Oscore && value.contentEquals(other.value)) + + override fun hashCode(): Int = value.contentHashCode() + + override fun toString(): String = "Oscore(value=${value.toHexString()})" + } + + /** RFC 9668 3.1. EDHOC */ + object Edhoc : Option() { + override fun toString(): String = "Edhoc" + } + /** RFC 8768 3. Hop-Limit */ data class HopLimit( val hops: Long, diff --git a/koap-core/src/commonMain/kotlin/ToFormat.kt b/koap-core/src/commonMain/kotlin/ToFormat.kt index a2a59166..2b863d82 100644 --- a/koap-core/src/commonMain/kotlin/ToFormat.kt +++ b/koap-core/src/commonMain/kotlin/ToFormat.kt @@ -7,6 +7,7 @@ import com.juul.koap.Message.Option.Block2 import com.juul.koap.Message.Option.ContentFormat import com.juul.koap.Message.Option.ETag import com.juul.koap.Message.Option.Echo +import com.juul.koap.Message.Option.Edhoc import com.juul.koap.Message.Option.Format import com.juul.koap.Message.Option.Format.empty import com.juul.koap.Message.Option.Format.opaque @@ -20,6 +21,7 @@ import com.juul.koap.Message.Option.LocationQuery import com.juul.koap.Message.Option.MaxAge import com.juul.koap.Message.Option.NoResponse import com.juul.koap.Message.Option.Observe +import com.juul.koap.Message.Option.Oscore import com.juul.koap.Message.Option.ProxyScheme import com.juul.koap.Message.Option.ProxyUri import com.juul.koap.Message.Option.QBlock1 @@ -43,6 +45,7 @@ internal fun Option.toFormat(): Format = is Observe -> uint(6, option.value) is UriPort -> uint(7, option.port) is LocationPath -> string(8, option.uri) + is Oscore -> opaque(9, option.value) is UriPath -> string(11, option.uri) is ContentFormat -> uint(12, option.format) is MaxAge -> uint(14, option.seconds) @@ -51,6 +54,7 @@ internal fun Option.toFormat(): Format = is Accept -> uint(17, option.format) is QBlock1 -> uint(19, option.intValue.toLong()) is LocationQuery -> string(20, option.uri) + is Edhoc -> empty(21) is Block2 -> uint(23, option.intValue.toLong()) is Block1 -> uint(27, option.intValue.toLong()) is Size2 -> uint(28, option.bytes) diff --git a/koap-core/src/commonTest/kotlin/DecoderTest.kt b/koap-core/src/commonTest/kotlin/DecoderTest.kt index 02e976cc..a8c6f5a5 100644 --- a/koap-core/src/commonTest/kotlin/DecoderTest.kt +++ b/koap-core/src/commonTest/kotlin/DecoderTest.kt @@ -2,10 +2,13 @@ package com.juul.koap.test import com.juul.koap.Message import com.juul.koap.Message.Code.Method.GET +import com.juul.koap.Message.Code.Method.POST +import com.juul.koap.Message.Code.Response.Changed import com.juul.koap.Message.Option.Block.Size.Bert import com.juul.koap.Message.Option.Block1 import com.juul.koap.Message.Option.Block2 import com.juul.koap.Message.Option.Echo +import com.juul.koap.Message.Option.Edhoc import com.juul.koap.Message.Option.HopLimit import com.juul.koap.Message.Option.NoResponse import com.juul.koap.Message.Option.NoResponse.NotInterestedIn.Response4xx @@ -13,6 +16,7 @@ import com.juul.koap.Message.Option.NoResponse.NotInterestedIn.Response5xx import com.juul.koap.Message.Option.Observe import com.juul.koap.Message.Option.Observe.Registration.Deregister import com.juul.koap.Message.Option.Observe.Registration.Register +import com.juul.koap.Message.Option.Oscore import com.juul.koap.Message.Option.QBlock1 import com.juul.koap.Message.Option.QBlock2 import com.juul.koap.Message.Option.RequestTag @@ -253,6 +257,175 @@ class DecoderTest { ) } + @Test + fun decodeOscoreOptionWithEmptyValue() { + testReadOption( + encoded = """ + 90 # Option Delta: 9, Option Length: 0 + """, + expected = Oscore(byteArrayOf()), + ) + } + + // Test Vector 4: OSCORE Request, Client + // https://datatracker.ietf.org/doc/html/rfc8613#appendix-C.4 + @Test + fun decodeOscoreOptionTestVector4() { + val unprotectedCoapRequest = "44015d1f00003974396c6f63616c686f737483747631".decodeHex().toByteArray().decode() + + val oscoreOptionValue = "0914".decodeHex().toByteArray() + val ciphertext = "612f1092f1776f1c1668b3825e".decodeHex().toByteArray() + val protectedCoapRequest = "44025d1f00003974396c6f63616c686f7374620914ff612f1092f1776f1c1668b3825e".decodeHex().toByteArray() + + assertEquals( + expected = Message.Udp( + type = unprotectedCoapRequest.type, + + // "the Outer Code SHALL be set to 0.02 (POST) for requests [..]" + // https://datatracker.ietf.org/doc/html/rfc8613#section-4.2 + code = POST, + + id = unprotectedCoapRequest.id, + token = unprotectedCoapRequest.token, + options = listOf( + UriHost("localhost"), + Oscore(oscoreOptionValue), + ), + payload = ciphertext, + ), + actual = protectedCoapRequest.decode(), + ) + } + + // Test Vector 5: OSCORE Request, Client + // https://datatracker.ietf.org/doc/html/rfc8613#appendix-C.5 + @Test + fun decodeOscoreOptionTestVector5() { + val unprotectedCoapRequest = "440171c30000b932396c6f63616c686f737483747631".decodeHex().toByteArray().decode() + + val oscoreOptionValue = "091400".decodeHex().toByteArray() + val ciphertext = "4ed339a5a379b0b8bc731fffb0".decodeHex().toByteArray() + val protectedCoapRequest = "440271c30000b932396c6f63616c686f737463091400ff4ed339a5a379b0b8bc731fffb0".decodeHex().toByteArray() + + assertEquals( + expected = Message.Udp( + type = unprotectedCoapRequest.type, + + // "the Outer Code SHALL be set to 0.02 (POST) for requests [..]" + // https://datatracker.ietf.org/doc/html/rfc8613#section-4.2 + code = POST, + + id = unprotectedCoapRequest.id, + token = unprotectedCoapRequest.token, + options = listOf( + UriHost("localhost"), + Oscore(oscoreOptionValue), + ), + payload = ciphertext, + ), + actual = protectedCoapRequest.decode(), + ) + } + + // Test Vector 6: OSCORE Request, Client + // https://datatracker.ietf.org/doc/html/rfc8613#appendix-C.6 + @Test + fun decodeOscoreOptionTestVector6() { + val unprotectedCoapRequest = "44012f8eef9bbf7a396c6f63616c686f737483747631".decodeHex().toByteArray().decode() + + val oscoreOptionValue = "19140837cbf3210017a2d3".decodeHex().toByteArray() + val ciphertext = "72cd7273fd331ac45cffbe55c3".decodeHex().toByteArray() + val protectedCoapRequest = + "44022f8eef9bbf7a396c6f63616c686f73746b19140837cbf3210017a2d3ff72cd7273fd331ac45cffbe55c3".decodeHex().toByteArray() + + assertEquals( + expected = Message.Udp( + type = unprotectedCoapRequest.type, + + // "the Outer Code SHALL be set to 0.02 (POST) for requests [..]" + // https://datatracker.ietf.org/doc/html/rfc8613#section-4.2 + code = POST, + + id = unprotectedCoapRequest.id, + token = unprotectedCoapRequest.token, + options = listOf( + UriHost("localhost"), + Oscore(oscoreOptionValue), + ), + payload = ciphertext, + ), + actual = protectedCoapRequest.decode(), + ) + } + + // Test Vector 7: OSCORE Response, Server + // https://datatracker.ietf.org/doc/html/rfc8613#appendix-C.7 + @Test + fun decodeOscoreOptionTestVector7() { + val unprotectedCoapResponse = "64455d1f00003974ff48656c6c6f20576f726c6421".decodeHex().toByteArray().decode() + + val oscoreOptionValue = "".decodeHex().toByteArray() + val ciphertext = "dbaad1e9a7e7b2a813d3c31524378303cdafae119106".decodeHex().toByteArray() + val protectedCoapResponse = "64445d1f0000397490ffdbaad1e9a7e7b2a813d3c31524378303cdafae119106".decodeHex().toByteArray() + + assertEquals( + expected = Message.Udp( + type = unprotectedCoapResponse.type, + + // "the Outer Code SHALL be set to [..] 2.04 (Changed) for responses" + // https://datatracker.ietf.org/doc/html/rfc8613#section-4.2 + code = Changed, + + id = unprotectedCoapResponse.id, + token = unprotectedCoapResponse.token, + options = listOf( + Oscore(oscoreOptionValue), + ), + payload = ciphertext, + ), + actual = protectedCoapResponse.decode(), + ) + } + + // Test Vector 8: OSCORE Response with Partial IV, Server + // https://datatracker.ietf.org/doc/html/rfc8613#appendix-C.8 + @Test + fun decodeOscoreOptionTestVector8() { + val unprotectedCoapResponse = "64455d1f00003974ff48656c6c6f20576f726c6421".decodeHex().toByteArray().decode() + + val oscoreOptionValue = "0100".decodeHex().toByteArray() + val ciphertext = "4d4c13669384b67354b2b6175ff4b8658c666a6cf88e".decodeHex().toByteArray() + val protectedCoapResponse = "64445d1f00003974920100ff4d4c13669384b67354b2b6175ff4b8658c666a6cf88e".decodeHex().toByteArray() + + assertEquals( + expected = Message.Udp( + type = unprotectedCoapResponse.type, + + // "the Outer Code SHALL be set to [..] 2.04 (Changed) for responses" + // https://datatracker.ietf.org/doc/html/rfc8613#section-4.2 + code = Changed, + + id = unprotectedCoapResponse.id, + token = unprotectedCoapResponse.token, + options = listOf( + Oscore(oscoreOptionValue), + ), + payload = ciphertext, + ), + actual = protectedCoapResponse.decode(), + ) + } + + @Test + fun decodeEdhocOption() { + testReadOption( + encoded = """ + D0 08 # Option Delta: 21, Option Length: 0 + """, + expected = Edhoc, + ) + } + @Test fun decodeHopLimit() { testReadOption( diff --git a/koap-core/src/commonTest/kotlin/EncoderTest.kt b/koap-core/src/commonTest/kotlin/EncoderTest.kt index d19ded62..5be1f907 100644 --- a/koap-core/src/commonTest/kotlin/EncoderTest.kt +++ b/koap-core/src/commonTest/kotlin/EncoderTest.kt @@ -7,6 +7,7 @@ import com.juul.koap.Message.Option.Block.Size.Bert import com.juul.koap.Message.Option.Block1 import com.juul.koap.Message.Option.Block2 import com.juul.koap.Message.Option.Echo +import com.juul.koap.Message.Option.Edhoc import com.juul.koap.Message.Option.HopLimit import com.juul.koap.Message.Option.NoResponse import com.juul.koap.Message.Option.NoResponse.NotInterestedIn.Response2xx @@ -15,6 +16,7 @@ import com.juul.koap.Message.Option.NoResponse.NotInterestedIn.Response5xx import com.juul.koap.Message.Option.Observe import com.juul.koap.Message.Option.Observe.Registration.Deregister import com.juul.koap.Message.Option.Observe.Registration.Register +import com.juul.koap.Message.Option.Oscore import com.juul.koap.Message.Option.QBlock1 import com.juul.koap.Message.Option.QBlock2 import com.juul.koap.Message.Option.RequestTag @@ -366,6 +368,38 @@ class EncoderTest { ) } + @Test + fun writeOscoreOptionWithEmptyValue() { + testWriteOption( + option = Oscore(byteArrayOf()), + expected = """ + 90 # Option Delta: 9, Option Length: 0 + """, + ) + } + + @Test + fun writeOscoreOptionTestVector4() { + // https://datatracker.ietf.org/doc/html/rfc8613#appendix-C.4 Test Vector 4 + testWriteOption( + option = Oscore(byteArrayOf(0x09, 0x14)), + expected = """ + 92 # Option Delta: 9, Option Length: 2 + 09 14 # Option Value: 09 14 + """, + ) + } + + @Test + fun writeEdhocOption() { + testWriteOption( + option = Edhoc, + expected = """ + D0 08 # Option Delta: 21, Option Length: 0 + """, + ) + } + @Test fun observeOptionWithValueOutsideOfAllowableRangeThrowsIllegalArgumentException() { assertFailsWith {