Skip to content

Commit

Permalink
Feature/consistent api (#130)
Browse files Browse the repository at this point in the history
* Revamp `Asn1Element.parse()`, introducing new variants. This yields:
  * `Asn1Element.parse()` with the same semantics as before
  * `Asn1Element.parse()` alternative introduced, which takes a `ByteIterator` instead of a `ByteArray`
  * `Asn1Element.parseAll()` introduced, which consumes all bytes and returns a list of all ASN.1 elements (if parsing works)
    * Variant 1 takes a `ByteIterator`
    * Variant 2 takes a `ByteArray`
  * `Asn1Element.parseFirst()` introduced, which tries to only parse a single ASN.1 element from the input and leaves the rest untouched.
    * Variant 1 takes a `ByteIterator` and returns the element; the `ByteIterator` is advanced accordingly
    * Variant 2 takes a `ByteArray` and returns a `Pair` of `(element, remainingBytes)`
* More consistent low-level encoding and decoding function names:
  * `encodeToAsn1Primitive` to produce an `Asn1Primitive` that can directly be DER-encoded
  * `encodeToAsn1ContentBytes` to produce the content bytes of a TLV primitive (the _V_ in TLV)
  * `decodeToXXX` to be invoked on an `Asn1Primitive` to decode a DER-encoded primitive into the target type
  * `decodeFromAsn1ContentBytes` to be invoked on the companion of the target type to decode the content bytes of a TLV primitive (the _V_ in TLV)
* Update conventions -> Coroutines 1.0.9

---------

Co-authored-by: Jakob Heher <[email protected]>
  • Loading branch information
JesusMcCloud and iaik-jheher committed Sep 20, 2024
1 parent ab24c5e commit 1d03fce
Show file tree
Hide file tree
Showing 53 changed files with 1,461 additions and 1,190 deletions.
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,21 @@
* `Iterable<Byte>.decodeAsn1VarUInt()`
* `ByteArray.decodeAsn1VarUInt()`
* Revamp implicit tagging
* Revamp `Asn1Element.parse()`, introducing new variants. This yields:
* `Asn1Element.parse()` with the same semantics as before
* `Asn1Element.parse()` alternative introduced, which takes a `ByteIterator` instead of a `ByteArray`
* `Asn1Element.parseAll()` introduced, which consumes all bytes and returns a list of all ASN.1 elements (if parsing works)
* Variant 1 takes a `ByteIterator`
* Variant 2 takes a `ByteArray`
* `Asn1Element.parseFirst()` introduced, which tries to only parse a single ASN.1 element from the input and leaves the rest untouched.
* Variant 1 takes a `ByteIterator` and returns the element; the `ByteIterator` is advanced accordingly
* Variant 2 takes a `ByteArray` and returns a `Pair` of `(element, remainingBytes)`
* More consistent low-level encoding and decoding function names:
* `encodeToAsn1Primitive` to produce an `Asn1Primitive` that can directly be DER-encoded
* `encodeToAsn1ContentBytes` to produce the content bytes of a TLV primitive (the _V_ in TLV)
* `decodeToXXX` to be invoked on an `Asn1Primitive` to decode a DER-encoded primitive into the target type
* `decodeFromAsn1ContentBytes` to be invoked on the companion of the target type to decode the content bytes of a TLV primitive (the _V_ in TLV)
* Update conventions -> Coroutines 1.0.9

## 3.0

Expand Down
70 changes: 51 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,11 @@
[![Kotlin](https://img.shields.io/badge/kotlin-multiplatform-orange.svg?logo=kotlin)](http://kotlinlang.org)
[![Kotlin](https://img.shields.io/badge/kotlin-2.0.20-blue.svg?logo=kotlin)](http://kotlinlang.org)
[![Java](https://img.shields.io/badge/java-17+-blue.svg?logo=OPENJDK)](https://www.oracle.com/java/technologies/downloads/#java11)

[![Maven Central (indispensable)](https://img.shields.io/maven-central/v/at.asitplus.signum/indispensable?label=maven-central%20%28indispensable%29)](https://mvnrepository.com/artifact/at.asitplus.signum/)
[![Maven SNAPSHOT (indispensable)](https://img.shields.io/nexus/snapshots/https/s01.oss.sonatype.org/at.asitplus.signum/indispensable?label=SNAPSHOT%20%28indispensable%29)](https://s01.oss.sonatype.org/content/repositories/snapshots/at/asitplus/signum/indispensable/)
[![Maven Central (Supreme)](https://img.shields.io/maven-central/v/at.asitplus.signum/supreme?label=maven-central%20%28Supreme%29)](https://mvnrepository.com/artifact/at.asitplus.signum/supreme)
[![Maven SNAPSHOT (Supreme)](https://img.shields.io/nexus/snapshots/https/s01.oss.sonatype.org/at.asitplus.signum/supreme?label=SNAPSHOT%20%28Supreme%29)](https://s01.oss.sonatype.org/content/repositories/snapshots/at/asitplus/signum/supreme/)

</div>

Expand Down Expand Up @@ -383,47 +386,76 @@ Which results in the following output:
### Working with Generic ASN.1 Structures

The magic shown above is based on a from-scratch 100% KMP implementation of an ASN.1 encoder and parser.
To parse any DER-encoded ASN.1 structure, call `Asn1Element.parse(derBytes)`, which will result in exactly a single
`Asn1Element`.
It can be re-encoded (and yes, it is a true re-encoding, since the original bytes are discarded after decoding) by
accessing the lazily evaluated `.derEncoded` property.
To parse any DER-encoded ASN.1 structure, call either:

* `Asn1Element.parse()`, which will consume all bytes and return the first parsed ASN.1 element.
This method throws if parsing errors occur or any trailing bytes are left after parsing the first element.
* `Asn1Element.parseFirst()`, which will try to parse a single toplevel ASN.1 element.
Any remaining bytes can still be consumed from the iterator, as it will only be advanced to right after the first parsed element.
* `Asn1Element.parseAll()`, wich consumes all bytes, parses all toplevel ASN.1 elements, and returns them as list.
Throws on any parsing error.

`Asn1Element`s can encoded by accessing the lazily evaluated `.derEncoded` property.
Even for parsed elements, this is a true re-encoding. The original bytes are discarded after decoding.

**Note that decoding operations will throw exceptions if invalid data is provided!**

A parsed `Asn1Element` can either be a primitive (whose tag and value can be read) or a structure (like a set or
sequence) whose child
nodes can be processed as desired. Subclasses of `Asn1Element` reflect this:
sequence) whose child nodes can be processed as desired. Subclasses of `Asn1Element` reflect this:

* `Asn1Primitive`
* `Asn1BitString` (for convenience)
* `Asn1PrimitiveOctetString` (for convenience)
* `Asn1Structure`
* `Asn1Set`
* `Asn1Sequence`

* `Asn1Sequence` and `Asn1SequenceOf`
* `Asn1Set` and `Asn1SetOf` (sorting children by default)
* `Asn1EncapsulatingOctetString` (tagged as OCTET STRING, containing a valid ASN.1 structure or primitive)
* `Asn1ExplicitlyTagged` (user-specified tag + CONTEXT_SPECIFIC + CONSTRUCTED)
* `Asn1CustomStructure` (any other CONSTRUCTED tag not fitting the above options. CONSTRUCTED bit may be overridden)

Convenience wrappers exist, to cast to any subtype (e.g. `.asSequence()`). These shorthand functions throw an `Asn1Exception`
if a cast is not possible.
Any complex data structure (such as CSR, public key, certificate, …) implements `Asn1Encodable`, which means you can:

* encapsulate it into an ASN.1 Tree by calling `.encodeToTlv()`
* directly get a DER-encoded byte array through the `.encodetoDer()` function

To also suport going the other way, the companion objects of these complex classes implement `Asn1Decodable`, which
allows for
A tandem of helper functions is available for primitives (numbers, booleans, string, bigints):

* `encodeToAsn1Primitive` to produce an `Asn1Primitive` that can directly be DER-encoded
* `encodeToAsn1ContentBytes` to produce the content bytes of a TLV primitive (the _V_ in TLV)

* directly parsing DER-encoded byte arrays by calling `.decodeFromDer(bytes)`
* processing an `Asn1Element` by calling `.fromTlv(src)`
Variations of these exist for `Instant` and `ByteArray`.

Check out [Asn1Encoding.kt](indispensable/src/commonMain/kotlin/at/asitplus/signum/indispensable/asn1/encoding/Asn1Encoding.kt) for a full
list of helper functions.

#### Decoding Values

Various helper functions exist to facilitate decoding the values contained in `Asn1Primitives`, such as `decodeInt()`,
for example.
Various helper functions exist to facilitate decoding the values contained in `Asn1Primitives`, such as `readInt()`,
for example. To also support decoding more complex structures, the companion objects of complex classes (such as certificates, CSRs, …)
implement `Asn1Decodable`, which allows for:

* directly parsing DER-encoded byte arrays by calling `.decodeFromDer(bytes)` and `.decodeFromDerHexString`
* processing an `Asn1Element` by calling `.decodefromTlv(src)`

Both encoding and decoding functions come in two _safe_ (i.e. non-throwing) variants:
* `…Safe()` which returns a [KmmResult](https://github.com/a-sit-plus/kmmresult)
* `…orNull()` which returns null on error

Similarly to encoding, a tandem of decoding functions exists for primitives:
* `decodeToXXX` to be invoked on an `Asn1Primitive` to decode a DER-encoded primitive into the target type
* `decodeFromAsn1ContentBytes` to be invoked on the companion of the target type to decode the content bytes of a TLV primitive (the _V_ in TLV)

However, anything can be decoded and tagged at will. Therefore, a generic decoding function exists, which has the
following signature:

```kotlin
inline fun <reified T> Asn1Primitive.decode(tag: UByte, decode: (content: ByteArray) -> T)
inline fun <reified T> Asn1Primitive.decode(assertTag: Asn1Element.Tag, decode: (content: ByteArray) -> T)
```

Check out [Asn1Reader.kt](datatypes/src/commonMain/kotlin/at/asitplus/crypto/datatypes/asn1/Asn1Reader.kt) for a full
list
of helper functions.
Check out [Asn1Decoding.kt](indispensable/src/commonMain/kotlin/at/asitplus/signum/indispensable/asn1/encoding/Asn1Decoding.kt) for a full
list of helper functions.

#### ASN1 DSL for Creating ASN.1 Structures

Expand Down
9 changes: 7 additions & 2 deletions build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
import org.jetbrains.dokka.gradle.DokkaMultiModuleTask

plugins {
id("at.asitplus.gradle.conventions") version "2.0.20+20240829"
id("at.asitplus.gradle.conventions") version "2.0.20+20240920"
id("com.android.library") version "8.2.2" apply (false)
}
group = "at.asitplus.signum"

//work around nexus publish bug
val artifactVersion: String by extra
version = artifactVersion
//end work around nexus publish bug


//access dokka plugin from conventions plugin's classpath in root project → no need to specify version
apply(plugin = "org.jetbrains.dokka")
Expand All @@ -26,7 +31,7 @@ tasks.getByName("dokkaHtmlMultiModule") {
"josef-light.png",
"signum-light-large.png",
"signum-dark-large.png",
).files.forEach { it.copyTo(File("build/dokka/${it.name}"), overwrite = true) }
).files.forEach { it.copyTo(File("build/dokka/${it.name}"), overwrite = true) }
}
}

Expand Down
1 change: 1 addition & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ bignum = "0.3.10"
jose = "9.31"
kotlinpoet = "1.16.0"
runner = "1.5.2"
kotest-plugin = "20240918.002009-71"

[libraries]
bignum = { group = "com.ionspin.kotlin", name = "bignum", version.ref = "bignum" }
Expand Down
1 change: 1 addition & 0 deletions indispensable-cosef/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ kotlin {

exportIosFramework(
"IndispensableCosef",
transitiveExports=false,
serialization("cbor"),
datetime(),
kmmresult(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import at.asitplus.catching
import at.asitplus.signum.indispensable.CryptoPublicKey
import at.asitplus.signum.indispensable.SignatureAlgorithm
import at.asitplus.signum.indispensable.SpecializedCryptoPublicKey
import at.asitplus.signum.indispensable.asn1.toTwosComplementByteArray
import at.asitplus.signum.indispensable.asn1.encoding.toTwosComplementByteArray
import at.asitplus.signum.indispensable.cosef.CoseKey.Companion.deserialize
import at.asitplus.signum.indispensable.cosef.CoseKeySerializer.CompressedCompoundCoseKeySerialContainer
import at.asitplus.signum.indispensable.cosef.CoseKeySerializer.UncompressedCompoundCoseKeySerialContainer
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import at.asitplus.KmmResult.Companion.failure
import at.asitplus.catching
import at.asitplus.signum.indispensable.CryptoPublicKey
import at.asitplus.signum.indispensable.SpecializedCryptoPublicKey
import at.asitplus.signum.indispensable.asn1.decodeFromDerValue
import at.asitplus.signum.indispensable.asn1.encoding.decodeFromAsn1ContentBytes

/**
* Wrapper to handle parameters for different COSE public key types.
Expand Down Expand Up @@ -164,7 +164,7 @@ sealed class CoseKeyParams : SpecializedCryptoPublicKey {
override fun toCryptoPublicKey(): KmmResult<CryptoPublicKey> = catching {
CryptoPublicKey.Rsa(
n = n ?: throw IllegalArgumentException("Missing modulus n"),
e = Int.decodeFromDerValue(e ?:
e = Int.decodeFromAsn1ContentBytes(e ?:
throw IllegalArgumentException("Missing or invalid exponent e"))
)
}
Expand Down
1 change: 1 addition & 0 deletions indispensable-josef/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ kotlin {

exportIosFramework(
"IndispensableJosef",
transitiveExports=false,
serialization("json"),
datetime(),
kmmresult(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ import at.asitplus.signum.indispensable.CryptoPublicKey
import at.asitplus.signum.indispensable.CryptoPublicKey.EC.Companion.fromUncompressed
import at.asitplus.signum.indispensable.ECCurve
import at.asitplus.signum.indispensable.SpecializedCryptoPublicKey
import at.asitplus.signum.indispensable.asn1.decodeFromDerValue
import at.asitplus.signum.indispensable.asn1.toTwosComplementByteArray
import at.asitplus.signum.indispensable.asn1.encoding.decodeFromAsn1ContentBytes
import at.asitplus.signum.indispensable.asn1.encoding.toTwosComplementByteArray
import at.asitplus.signum.indispensable.io.Base64UrlStrict
import at.asitplus.signum.indispensable.io.ByteArrayBase64UrlSerializer
import at.asitplus.signum.indispensable.josef.io.JwsCertificateSerializer
Expand Down Expand Up @@ -303,7 +303,7 @@ data class JsonWebKey(
JwkType.RSA -> {
CryptoPublicKey.Rsa(
n = n ?: throw IllegalArgumentException("Missing modulus n"),
e = e?.let { bytes -> Int.decodeFromDerValue(bytes) }
e = e?.let { bytes -> Int.decodeFromAsn1ContentBytes(bytes) }
?: throw IllegalArgumentException("Missing or invalid exponent e")
).apply { jwkId = keyId }
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,20 +1,9 @@
package at.asitplus.signum.indispensable.josef

import at.asitplus.signum.indispensable.asn1.encodeTo4Bytes


//TODO a lot of this can now be streamlined thanks to our various helpers and ASN.1 Foo
import at.asitplus.signum.indispensable.asn1.encoding.encodeTo4Bytes

object JwsExtensions {

/**
* ASN.1 encoding about encoding of integers:
* Bits of first octet and bit 8 of the second octet
* shall not be all ones; and shall not be all zeros
*/
private fun ByteArray.toAsn1Integer() = if (this[0] < 0) byteArrayOf(0) + this else
if (this[0] == 0x00.toByte() && this[1] > 0) drop(1).toByteArray() else this

/**
* Prepend `this` with the size as four bytes
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,5 @@ import at.asitplus.signum.indispensable.pki.X509Certificate
object JwsCertificateSerializer : TransformingSerializerTemplate<X509Certificate, ByteArray>(
parent = ByteArrayBase64Serializer,
encodeAs = X509Certificate::encodeToDer,
decodeAs = X509Certificate::decodeFromDer
decodeAs = { X509Certificate.decodeFromDer(it) } //workaround iOS compilation bug KT-71498
)
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ package at.asitplus.signum.indispensable.josef
import at.asitplus.signum.indispensable.CryptoPublicKey
import at.asitplus.signum.indispensable.CryptoPublicKey.EC.Companion.fromUncompressed
import at.asitplus.signum.indispensable.ECCurve
import at.asitplus.signum.indispensable.asn1.ensureSize
import at.asitplus.signum.indispensable.asn1.toTwosComplementByteArray
import at.asitplus.signum.indispensable.io.ensureSize
import at.asitplus.signum.indispensable.asn1.encoding.toTwosComplementByteArray
import io.kotest.core.spec.style.FreeSpec
import io.kotest.matchers.nulls.shouldNotBeNull
import io.kotest.matchers.shouldBe
Expand Down
1 change: 1 addition & 0 deletions indispensable/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,7 @@ kotlin {

exportIosFramework(
"Indispensable",
transitiveExports=false,
serialization("json"),
datetime(),
kmmresult(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ package at.asitplus.signum.indispensable
import at.asitplus.KmmResult
import at.asitplus.catching
import at.asitplus.signum.indispensable.asn1.*
import at.asitplus.signum.indispensable.asn1.Asn1.BitString
import at.asitplus.signum.indispensable.asn1.Asn1.Null
import at.asitplus.signum.indispensable.asn1.encoding.Asn1.BitString
import at.asitplus.signum.indispensable.asn1.encoding.Asn1.Null
import at.asitplus.signum.indispensable.io.ByteArrayBase64Serializer
import at.asitplus.signum.indispensable.misc.ANSIECPrefix
import at.asitplus.signum.indispensable.misc.ANSIECPrefix.Companion.hasPrefix
Expand All @@ -13,6 +13,8 @@ import at.asitplus.io.MultiBase
import at.asitplus.io.UVarInt
import at.asitplus.io.multibaseDecode
import at.asitplus.io.multibaseEncode
import at.asitplus.signum.indispensable.asn1.encoding.*
import at.asitplus.signum.indispensable.io.ensureSize
import com.ionspin.kotlin.bignum.integer.BigInteger
import com.ionspin.kotlin.bignum.integer.Sign
import kotlinx.serialization.SerialName
Expand Down Expand Up @@ -115,7 +117,7 @@ sealed class CryptoPublicKey : Asn1Encodable<Asn1Sequence>, Identifiable {
val curve = ECCurve.entries.find { it.oid == curveOid }
?: throw Asn1Exception("Curve not supported: $curveOid")

val bitString = (src.nextChild() as Asn1Primitive).readBitString()
val bitString = (src.nextChild() as Asn1Primitive).asAsn1BitString()
if (!bitString.rawBytes.hasPrefix(ANSIECPrefix.UNCOMPRESSED)) throw Asn1Exception("EC key not prefixed with 0x04")
val xAndY = bitString.rawBytes.drop(1)
val coordLen = curve.coordinateLength.bytes.toInt()
Expand All @@ -126,10 +128,10 @@ sealed class CryptoPublicKey : Asn1Encodable<Asn1Sequence>, Identifiable {

Rsa.oid -> {
(keyInfo.nextChild() as Asn1Primitive).readNull()
val bitString = (src.nextChild() as Asn1Primitive).readBitString()
val bitString = (src.nextChild() as Asn1Primitive).asAsn1BitString()
val rsaSequence = Asn1Element.parse(bitString.rawBytes) as Asn1Sequence
val n = (rsaSequence.nextChild() as Asn1Primitive).decode(Asn1Element.Tag.INT) { it }
val e = (rsaSequence.nextChild() as Asn1Primitive).readInt()
val e = (rsaSequence.nextChild() as Asn1Primitive).decodeToInt()
if (rsaSequence.hasMoreChildren()) throw Asn1StructuralException("Superfluous data in SPKI!")
return Rsa(n, e)
}
Expand Down Expand Up @@ -283,7 +285,7 @@ sealed class CryptoPublicKey : Asn1Encodable<Asn1Sequence>, Identifiable {
fun fromPKCS1encoded(input: ByteArray): Rsa = runRethrowing {
val conv = Asn1Element.parse(input) as Asn1Sequence
val n = (conv.nextChild() as Asn1Primitive).decode(Asn1Element.Tag.INT) { it }
val e = (conv.nextChild() as Asn1Primitive).readInt()
val e = (conv.nextChild() as Asn1Primitive).decodeToInt()
if (conv.hasMoreChildren()) throw Asn1StructuralException("Superfluous bytes")
return Rsa(Size.of(n), n, e)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package at.asitplus.signum.indispensable

import at.asitplus.signum.indispensable.asn1.*
import at.asitplus.signum.indispensable.asn1.encoding.*
import at.asitplus.signum.indispensable.io.Base64Strict
import at.asitplus.signum.indispensable.io.ensureSize
import at.asitplus.signum.indispensable.misc.BitLength
import at.asitplus.signum.indispensable.misc.max
import at.asitplus.signum.indispensable.pki.X509Certificate
Expand Down Expand Up @@ -91,9 +93,9 @@ sealed interface CryptoSignature : Asn1Encodable<Asn1Element> {
require(s.isPositive) { "s must be positive" }
}

override val signature: Asn1Element = Asn1.Sequence { +r.encodeToTlv(); +s.encodeToTlv() }
override val signature: Asn1Element = Asn1.Sequence { +r.encodeToAsn1Primitive(); +s.encodeToAsn1Primitive() }

override fun encodeToTlvBitString(): Asn1Element = encodeToDer().encodeToTlvBitString()
override fun encodeToTlvBitString(): Asn1Element = encodeToDer().encodeToAsn1BitStringPrimitive()

/**
* Two signatures are considered equal if `r` and `s` are equal.
Expand Down Expand Up @@ -209,13 +211,13 @@ sealed interface CryptoSignature : Asn1Encodable<Asn1Element> {

@Throws(Asn1Exception::class)
fun decodeFromTlvBitString(src: Asn1Primitive): EC.IndefiniteLength = runRethrowing {
decodeFromDer(src.readBitString().rawBytes)
decodeFromDer(src.asAsn1BitString().rawBytes)
}

override fun doDecode(src: Asn1Element): EC.IndefiniteLength {
src as Asn1Sequence
val r = (src.nextChild() as Asn1Primitive).readBigInteger()
val s = (src.nextChild() as Asn1Primitive).readBigInteger()
val r = (src.nextChild() as Asn1Primitive).decodeToBigInteger()
val s = (src.nextChild() as Asn1Primitive).decodeToBigInteger()
if (src.hasMoreChildren()) throw Asn1Exception("Illegal Signature Format")
return fromRS(r, s)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
package at.asitplus.signum.indispensable

import at.asitplus.signum.indispensable.asn1.ensureSize
import at.asitplus.signum.indispensable.io.ensureSize
import at.asitplus.signum.indispensable.io.ByteArrayBase64Serializer
import at.asitplus.signum.indispensable.misc.compressY
import at.asitplus.signum.indispensable.misc.decompressY
Expand Down
Loading

0 comments on commit 1d03fce

Please sign in to comment.