Repository: JuulLabs/koap
Pull request #333: Add Oscore and Edhoc options
Author: login: zalenskivolt
Labels: name: minor
description: Changes that should bump the MINOR version number
Created: 2025-03-22T11:46:23Z | Merged: 2025-03-28T17:26:33Z

see [IANA CoAP Option Numbers](https://www.iana.org/assignments/core-parameters/core-parameters.xhtml#option-numbers)

\# | Option | RFC
-- | -- | --
9 | OSCORE | [[RFC8613](https://www.iana.org/go/rfc8613) OSCORE (using COSE)]
21 | EDHOC | [[RFC9668](https://www.iana.org/go/rfc9668) Ephemeral Diffie-Hellman Over COSE]




Diff:
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 <init> ([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 <init> (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 <init>(kotlin/ByteArray) // com.juul.koap/Message.Option.Oscore.<init>|<init>(kotlin.ByteArray){}[0]
+
+            final val value // com.juul.koap/Message.Option.Oscore.value|{}value[0]
+                final fun <get-value>(): kotlin/ByteArray // com.juul.koap/Message.Option.Oscore.value.<get-value>|<get-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 <init>(kotlin/String) // com.juul.koap/Message.Option.ProxyScheme.<init>|<init>(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<QBlock1>(readNumberOfLength(length))
         20 -> LocationQuery(readUtf8(length))
+        21 -> Edhoc
         23 -> blockOf<Block2>(readNumberOfLength(length))
         27 -> blockOf<Block1>(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<Message.Udp>()
+
+        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<Message.Udp>(),
+        )
+    }
+
+    // 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<Message.Udp>()
+
+        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<Message.Udp>(),
+        )
+    }
+
+    // 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<Message.Udp>()
+
+        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<Message.Udp>(),
+        )
+    }
+
+    // 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<Message.Udp>()
+
+        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<Message.Udp>(),
+        )
+    }
+
+    // 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<Message.Udp>()
+
+        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<Message.Udp>(),
+        )
+    }
+
+    @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<IllegalArgumentException> {


Reviews:
user: login: twyatt
body: Amazing job on this! 💯 

I've added a unit test for "Test Vector 8", it'd be great to replicate the test for the remaining test vectors. Feel free to take that on, or I'll get to it next weekend.
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-25T07:23:27Z
user: login: zalenski
state: COMMENTED
author_association: NONE
submitted_at: 2025-03-25T09:40:58Z
user: login: zalenski
state: COMMENTED
author_association: NONE
submitted_at: 2025-03-25T11:29:25Z
user: login: zalenski
state: COMMENTED
author_association: NONE
submitted_at: 2025-03-25T11:47:13Z
user: login: twyatt
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-25T19:36:51Z
user: login: zalenski
state: COMMENTED
author_association: NONE
submitted_at: 2025-03-25T19:51:08Z
user: login: twyatt
body: 💯 🚀 

I'll merge after we get another approval.
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-25T20:28:34Z
user: login: QuantumRand
body: Awesome to see expanded support for more of the CoAP spec! Thanks @zalenskivolt!
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-28T16:30:43Z
user: login: zalenskivolt
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-31T22:08:43Z

Review comments:
diff_hunk: @@ -384,6 +385,29 @@ sealed class Message {
             }
         }
 
+        /** RFC 8613 2. OSCORE */
+        data class Oscore(
+            val coseObject: ByteArray,
path: koap-core/src/commonMain/kotlin/Message.kt
user: login: twyatt
body: Reading through the spec, I can't decide if `value` or `coseObject` is more appropriate here.

![Screenshot 2025-03-25 at 12 13 50 AM](https://github.com/user-attachments/assets/077abdb2-d319-4f1d-ad33-16d942dfccb6)

What was your thinking for settling on `coseObject` naming?
created_at: 2025-03-25T07:16:55Z
author_association: MEMBER
diff_hunk: @@ -384,6 +385,29 @@ sealed class Message {
             }
         }
 
+        /** RFC 8613 2. OSCORE */
+        data class Oscore(
+            val coseObject: ByteArray,
path: koap-core/src/commonMain/kotlin/Message.kt
user: login: zalenski
body: > What was your thinking for settling on coseObject naming?

Hm. I thought in section [2](https://www.rfc-editor.org/rfc/rfc8613.html#section-2) it stated that the option "contains a compressed COSE object", and I just wanted a descriptive name if I could find one.

But I realize now I have probably misinterpreted this. The option just "indicates that the CoAP message is an OSCORE message", and that the **message** contains a COSE object.

> [2](https://www.rfc-editor.org/rfc/rfc8613.html#section-2).  The OSCORE Option
>
>   The OSCORE option defined in this section (see Figure 3, which
   extends "Table 4: Options" of [[RFC7252](https://www.rfc-editor.org/rfc/rfc7252)]) indicates that the CoAP
   message is an OSCORE **message** and that _it_ <b>contains a compressed COSE
   object</b> (see Sections [5](https://www.rfc-editor.org/rfc/rfc8613.html#section-5) and [6](https://www.rfc-editor.org/rfc/rfc8613.html#section-6)).  The OSCORE option is critical, safe
   to forward, part of the cache key, and not repeatable.

What is actually in the option value described in section 6.1:

> [6.1](https://www.rfc-editor.org/rfc/rfc8613.html#section-6.1).  Encoding of the OSCORE Option Value
>
>   The value of the OSCORE option SHALL contain the OSCORE flag bits,
   the 'Partial IV' parameter, the 'kid context' parameter (length and
   value), and the 'kid' parameter as follows:

So I think it should be renamed, maybe just to value.

For the block options I did some handling of the uint value to expand it to the block number, more flag, and block size. I guess a semantically aware OSCORE option implementation should also extract all those flags, 'Partial IV' parameter and 'kid context' parameter things. But maybe that could be left out for now, with just an opaque value?
created_at: 2025-03-25T09:40:58Z
author_association: NONE
diff_hunk: @@ -384,6 +385,29 @@ sealed class Message {
             }
         }
 
+        /** RFC 8613 2. OSCORE */
+        data class Oscore(
+            val coseObject: ByteArray,
path: koap-core/src/commonMain/kotlin/Message.kt
user: login: zalenski
body: I did a take on actually handling `partialIv`, `kidContext` and `kid` in
https://github.com/zalenskivolt/koap/pull/3
learning a lot about Kotlin nullables (since both `kidContext` and `kid` can have the flag set but be empty) :)

There's reserved values for partialIv length 6 and 7, and three flags reserved for future use, so there's some potential for future updates to the standard rendering the option unparseable, and such flags/values should be handled in specific ways as outlined in [8.2](https://www.rfc-editor.org/rfc/rfc8613.html#section-8.2).  Verifying the Request …

It could have a `malformed` flag in it, or maybe a nullable partialIv and use null there to indicate the option didn't parse? I guess these complex options are a bit trickier than the simple uint/string ones in basic CoAP.
created_at: 2025-03-25T11:29:25Z
author_association: NONE
diff_hunk: @@ -384,6 +385,29 @@ sealed class Message {
             }
         }
 
+        /** RFC 8613 2. OSCORE */
+        data class Oscore(
+            val coseObject: ByteArray,
path: koap-core/src/commonMain/kotlin/Message.kt
user: login: zalenski
body: For upgradability/backwards compatibility, kotlin just says "Avoid using data classes in your API﻿"
https://kotlinlang.org/docs/api-guidelines-backward-compatibility.html#avoid-using-data-classes-in-your-api
and here's one suggestion for mitigation: No data:
https://jakewharton.com/public-api-challenges-in-kotlin/#mitigation-no-data

but I'll leave these considerations for the library maintainers 😉
created_at: 2025-03-25T11:47:13Z
author_association: NONE
diff_hunk: @@ -384,6 +385,29 @@ sealed class Message {
             }
         }
 
+        /** RFC 8613 2. OSCORE */
+        data class Oscore(
+            val coseObject: ByteArray,
path: koap-core/src/commonMain/kotlin/Message.kt
user: login: twyatt
body: https://github.com/zalenskivolt/koap/pull/3 looks great, but let's leave that for review after we get this PR in. So ya, I appreciate you keeping it as a separate PR for now. 👍 
created_at: 2025-03-25T19:36:50Z
author_association: MEMBER
diff_hunk: @@ -384,6 +385,29 @@ sealed class Message {
             }
         }
 
+        /** RFC 8613 2. OSCORE */
+        data class Oscore(
+            val coseObject: ByteArray,
path: koap-core/src/commonMain/kotlin/Message.kt
user: login: zalenski
body: ok, np.
created_at: 2025-03-25T19:51:08Z
author_association: NONE
diff_hunk: @@ -384,6 +385,29 @@ sealed class Message {
             }
         }
 
+        /** RFC 8613 2. OSCORE */
+        data class Oscore(
+            val coseObject: ByteArray,
path: koap-core/src/commonMain/kotlin/Message.kt
user: login: zalenskivolt
body: > [zalenskivolt#3](https://github.com/zalenskivolt/koap/pull/3) looks great, but let's leave that for review after we get this PR in. So ya, I appreciate you keeping it as a separate PR for now. 👍

-> #343 
created_at: 2025-03-31T22:08:42Z
author_association: CONTRIBUTOR

PR comments:
user: login: twyatt
created_at: 2025-03-22T21:36:05Z
body: This is great!
I'll follow up on this after #329 and #332 are merged.
author_association: MEMBER
user: login: twyatt
created_at: 2025-03-24T19:06:07Z
body: @zalenskivolt when you have a chance, can you rebase/fix conflicts? TY!
author_association: MEMBER
user: login: codecov[bot]
created_at: 2025-03-24T22:05:56Z
body: ## [Codecov](https://app.codecov.io/gh/JuulLabs/koap/pull/333?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=JuulLabs) Report
Attention: Patch coverage is `55.55556%` with `8 lines` in your changes missing coverage. Please review.
> Project coverage is 64.81%. Comparing base [(`3e15cd8`)](https://app.codecov.io/gh/JuulLabs/koap/commit/3e15cd82e6aa17833687cc4c2a93626fab6119c0?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=JuulLabs) to head [(`292722d`)](https://app.codecov.io/gh/JuulLabs/koap/commit/292722d1ed7ed9ec143847cddf671caa4e824f97?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=JuulLabs).
> Report is 1 commits behind head on main.

| [Files with missing lines](https://app.codecov.io/gh/JuulLabs/koap/pull/333?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=JuulLabs) | Patch % | Lines |
|---|---|---|
| [koap-core/src/commonMain/kotlin/Message.kt](https://app.codecov.io/gh/JuulLabs/koap/pull/333?src=pr&el=tree&filepath=koap-core%2Fsrc%2FcommonMain%2Fkotlin%2FMessage.kt&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=JuulLabs#diff-a29hcC1jb3JlL3NyYy9jb21tb25NYWluL2tvdGxpbi9NZXNzYWdlLmt0) | 42.85% | [6 Missing and 2 partials :warning: ](https://app.codecov.io/gh/JuulLabs/koap/pull/333?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=JuulLabs) |

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main     #333      +/-   ##
==========================================
+ Coverage   63.95%   64.81%   +0.85%     
==========================================
  Files           9        9              
  Lines         774      790      +16     
  Branches      172      176       +4     
==========================================
+ Hits          495      512      +17     
+ Misses        220      218       -2     
- Partials       59       60       +1     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/JuulLabs/koap/pull/333?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=JuulLabs).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=JuulLabs).

<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
- :package: [JS Bundle Analysis](https://docs.codecov.com/docs/javascript-bundle-analysis): Save yourself from yourself by tracking and limiting bundle sizes in JS merges.
</details>
author_association: NONE
user: login: zalenski
created_at: 2025-03-24T22:10:24Z
body: > when you have a chance, can you rebase/fix conflicts? TY!

@twyatt rebased & fixed
author_association: NONE
user: login: twyatt
created_at: 2025-03-25T19:36:00Z
body: @zalenskivolt could you rebase/resolve conflicts on this PR again, please?
author_association: MEMBER
user: login: zalenski
created_at: 2025-03-25T20:26:32Z
body: > Amazing job on this! 💯
> 
> I've added a unit test for "Test Vector 8", it'd be great to replicate the test for the remaining test vectors. Feel free to take that on, or I'll get to it next weekend.

Added test vectors 4-7. (test vector 1-3 is just key derivation, no OSCORE options)
author_association: NONE
user: login: zalenski
created_at: 2025-03-25T20:45:55Z
body: > Added test vectors 4-7.

…and found a bug in https://github.com/zalenskivolt/koap/pull/3 because of them! :)
author_association: NONE
user: login: zalenski
created_at: 2025-03-26T13:02:54Z
body: > 💯 🚀
> 
> I'll merge after we get another approval.

might be good to merge the block-wise ones first, if they are getting ready now. they all conflict a lot, in all the imports, and in tests if they are added in the same location… I think this one is easier to rebase! 😄
author_association: NONE
user: login: twyatt
created_at: 2025-03-26T19:47:01Z
body: > might be good to merge the block-wise ones first

Good thinking. I'll wait to merge this PR until after #335 is merged. 👍 
author_association: MEMBER
user: login: twyatt
created_at: 2025-03-27T20:23:07Z
body: @zalenskivolt please rebase/fix conflicts, when you have a chance. TY!
author_association: MEMBER
user: login: zalenski
created_at: 2025-03-27T22:11:22Z
body: > @zalenskivolt please rebase/fix conflicts, when you have a chance. TY!

rebased & fixed
author_association: NONE