Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature/constistent api #130

Merged
merged 20 commits into from
Sep 20, 2024
Merged
Show file tree
Hide file tree
Changes from 16 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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`
JesusMcCloud marked this conversation as resolved.
Show resolved Hide resolved
* Variant 2 takes a `ByteArray`
JesusMcCloud marked this conversation as resolved.
Show resolved Hide resolved
* 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.

Any parsed ASN.1 element can be re-encoded (this is a true re-encoding, since the original bytes are discarded after decoding) by
accessing the lazily evaluated `.derEncoded` property, just as manually constructed ones can.
JesusMcCloud marked this conversation as resolved.
Show resolved Hide resolved

**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
iaik-jheher marked this conversation as resolved.
Show resolved Hide resolved

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,
iaik-jheher marked this conversation as resolved.
Show resolved Hide resolved
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,
iaik-jheher marked this conversation as resolved.
Show resolved Hide resolved
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
2 changes: 2 additions & 0 deletions indispensable/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,7 @@ kotlin {
commonTest {
dependencies {
implementation(kotest("property"))
api("io.arrow-kt:arrow-core:1.2.4")
iaik-jheher marked this conversation as resolved.
Show resolved Hide resolved
}
}

Expand All @@ -197,6 +198,7 @@ kotlin {

exportIosFramework(
"Indispensable",
transitiveExports=false,
iaik-jheher marked this conversation as resolved.
Show resolved Hide resolved
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
Loading