diff --git a/cryptography-serialization/jose/build.gradle.kts b/cryptography-serialization/jose/build.gradle.kts new file mode 100644 index 00000000..c1792fdd --- /dev/null +++ b/cryptography-serialization/jose/build.gradle.kts @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2023-2025 Oleg Yukhnevich. Use of this source code is governed by the Apache 2.0 license. + */ + +import ckbuild.* + +plugins { + id("ckbuild.multiplatform-library") + alias(libs.plugins.kotlin.plugin.serialization) +} + +description = "cryptography-kotlin JOSE API" + +kotlin { + jvmTarget() + jsTarget() + nativeTargets() + wasmTargets() + + sourceSets.commonMain.dependencies { + api(projects.cryptographyCore) + implementation(libs.kotlinx.serialization.json) + } + + sourceSets.commonTest.dependencies { + implementation(libs.kotlin.test) + } +} \ No newline at end of file diff --git a/cryptography-serialization/jose/src/commonMain/kotlin/dev/whyoleg/cryptography/serialization/jose/JsonWebAlgorithms.kt b/cryptography-serialization/jose/src/commonMain/kotlin/dev/whyoleg/cryptography/serialization/jose/JsonWebAlgorithms.kt new file mode 100644 index 00000000..839b2d13 --- /dev/null +++ b/cryptography-serialization/jose/src/commonMain/kotlin/dev/whyoleg/cryptography/serialization/jose/JsonWebAlgorithms.kt @@ -0,0 +1,114 @@ +/* + * Copyright (c) 2023-2025 Oleg Yukhnevich. Use of this source code is governed by the Apache 2.0 license. + */ + +package dev.whyoleg.cryptography.serialization.jose + +import kotlinx.serialization.Serializable +import kotlin.jvm.JvmInline + +/** + * JSON Web Algorithms (JWA) as defined in RFC 7518. + */ + +/** + * Digital Signature or MAC Algorithms for JWS (Section 3.1) + */ +@Serializable +@JvmInline +public value class JwsAlgorithm(public val value: String) { + public companion object { + /** HMAC using SHA-256 */ + public val HS256: JwsAlgorithm = JwsAlgorithm("HS256") + /** HMAC using SHA-384 */ + public val HS384: JwsAlgorithm = JwsAlgorithm("HS384") + /** HMAC using SHA-512 */ + public val HS512: JwsAlgorithm = JwsAlgorithm("HS512") + /** RSASSA-PKCS1-v1_5 using SHA-256 */ + public val RS256: JwsAlgorithm = JwsAlgorithm("RS256") + /** RSASSA-PKCS1-v1_5 using SHA-384 */ + public val RS384: JwsAlgorithm = JwsAlgorithm("RS384") + /** RSASSA-PKCS1-v1_5 using SHA-512 */ + public val RS512: JwsAlgorithm = JwsAlgorithm("RS512") + /** ECDSA using P-256 and SHA-256 */ + public val ES256: JwsAlgorithm = JwsAlgorithm("ES256") + /** ECDSA using P-384 and SHA-384 */ + public val ES384: JwsAlgorithm = JwsAlgorithm("ES384") + /** ECDSA using P-521 and SHA-512 */ + public val ES512: JwsAlgorithm = JwsAlgorithm("ES512") + /** RSASSA-PSS using SHA-256 and MGF1 with SHA-256 */ + public val PS256: JwsAlgorithm = JwsAlgorithm("PS256") + /** RSASSA-PSS using SHA-384 and MGF1 with SHA-384 */ + public val PS384: JwsAlgorithm = JwsAlgorithm("PS384") + /** RSASSA-PSS using SHA-512 and MGF1 with SHA-512 */ + public val PS512: JwsAlgorithm = JwsAlgorithm("PS512") + /** No digital signature or MAC performed */ + public val NONE: JwsAlgorithm = JwsAlgorithm("none") + } +} + +/** + * Key Management Algorithms for JWE (Section 4.1) + */ +@Serializable +@JvmInline +public value class JweKeyManagementAlgorithm(public val value: String) { + public companion object { + /** RSAES-PKCS1-v1_5 */ + public val RSA1_5: JweKeyManagementAlgorithm = JweKeyManagementAlgorithm("RSA1_5") + /** RSAES OAEP using default parameters */ + public val RSA_OAEP: JweKeyManagementAlgorithm = JweKeyManagementAlgorithm("RSA-OAEP") + /** RSAES OAEP using SHA-256 and MGF1 with SHA-256 */ + public val RSA_OAEP_256: JweKeyManagementAlgorithm = JweKeyManagementAlgorithm("RSA-OAEP-256") + /** AES Key Wrap with default initial value using 128-bit key */ + public val A128KW: JweKeyManagementAlgorithm = JweKeyManagementAlgorithm("A128KW") + /** AES Key Wrap with default initial value using 192-bit key */ + public val A192KW: JweKeyManagementAlgorithm = JweKeyManagementAlgorithm("A192KW") + /** AES Key Wrap with default initial value using 256-bit key */ + public val A256KW: JweKeyManagementAlgorithm = JweKeyManagementAlgorithm("A256KW") + /** Direct use of a shared symmetric key as the CEK */ + public val DIR: JweKeyManagementAlgorithm = JweKeyManagementAlgorithm("dir") + /** Elliptic Curve Diffie-Hellman Ephemeral Static key agreement using Concat KDF */ + public val ECDH_ES: JweKeyManagementAlgorithm = JweKeyManagementAlgorithm("ECDH-ES") + /** ECDH Ephemeral Static key agreement using Concat KDF and CEK wrapped with AES Key Wrap using a 128-bit key */ + public val ECDH_ES_A128KW: JweKeyManagementAlgorithm = JweKeyManagementAlgorithm("ECDH-ES+A128KW") + /** ECDH Ephemeral Static key agreement using Concat KDF and CEK wrapped with AES Key Wrap using a 192-bit key */ + public val ECDH_ES_A192KW: JweKeyManagementAlgorithm = JweKeyManagementAlgorithm("ECDH-ES+A192KW") + /** ECDH Ephemeral Static key agreement using Concat KDF and CEK wrapped with AES Key Wrap using a 256-bit key */ + public val ECDH_ES_A256KW: JweKeyManagementAlgorithm = JweKeyManagementAlgorithm("ECDH-ES+A256KW") + /** AES GCM key encryption with a 128-bit key */ + public val A128GCMKW: JweKeyManagementAlgorithm = JweKeyManagementAlgorithm("A128GCMKW") + /** AES GCM key encryption with a 192-bit key */ + public val A192GCMKW: JweKeyManagementAlgorithm = JweKeyManagementAlgorithm("A192GCMKW") + /** AES GCM key encryption with a 256-bit key */ + public val A256GCMKW: JweKeyManagementAlgorithm = JweKeyManagementAlgorithm("A256GCMKW") + /** PBES2 with HMAC SHA-256 and AES Key Wrap with 128-bit key */ + public val PBES2_HS256_A128KW: JweKeyManagementAlgorithm = JweKeyManagementAlgorithm("PBES2-HS256+A128KW") + /** PBES2 with HMAC SHA-384 and AES Key Wrap with 192-bit key */ + public val PBES2_HS384_A192KW: JweKeyManagementAlgorithm = JweKeyManagementAlgorithm("PBES2-HS384+A192KW") + /** PBES2 with HMAC SHA-512 and AES Key Wrap with 256-bit key */ + public val PBES2_HS512_A256KW: JweKeyManagementAlgorithm = JweKeyManagementAlgorithm("PBES2-HS512+A256KW") + } +} + +/** + * Content Encryption Algorithms for JWE (Section 5.1) + */ +@Serializable +@JvmInline +public value class JweContentEncryptionAlgorithm(public val value: String) { + public companion object { + /** AES_128_CBC_HMAC_SHA_256 authenticated encryption algorithm */ + public val A128CBC_HS256: JweContentEncryptionAlgorithm = JweContentEncryptionAlgorithm("A128CBC-HS256") + /** AES_192_CBC_HMAC_SHA_384 authenticated encryption algorithm */ + public val A192CBC_HS384: JweContentEncryptionAlgorithm = JweContentEncryptionAlgorithm("A192CBC-HS384") + /** AES_256_CBC_HMAC_SHA_512 authenticated encryption algorithm */ + public val A256CBC_HS512: JweContentEncryptionAlgorithm = JweContentEncryptionAlgorithm("A256CBC-HS512") + /** AES GCM using 128-bit key */ + public val A128GCM: JweContentEncryptionAlgorithm = JweContentEncryptionAlgorithm("A128GCM") + /** AES GCM using 192-bit key */ + public val A192GCM: JweContentEncryptionAlgorithm = JweContentEncryptionAlgorithm("A192GCM") + /** AES GCM using 256-bit key */ + public val A256GCM: JweContentEncryptionAlgorithm = JweContentEncryptionAlgorithm("A256GCM") + } +} \ No newline at end of file diff --git a/cryptography-serialization/jose/src/commonMain/kotlin/dev/whyoleg/cryptography/serialization/jose/JsonWebEncryption.kt b/cryptography-serialization/jose/src/commonMain/kotlin/dev/whyoleg/cryptography/serialization/jose/JsonWebEncryption.kt new file mode 100644 index 00000000..47319b9a --- /dev/null +++ b/cryptography-serialization/jose/src/commonMain/kotlin/dev/whyoleg/cryptography/serialization/jose/JsonWebEncryption.kt @@ -0,0 +1,278 @@ +/* + * Copyright (c) 2023-2025 Oleg Yukhnevich. Use of this source code is governed by the Apache 2.0 license. + */ + +package dev.whyoleg.cryptography.serialization.jose + +import dev.whyoleg.cryptography.serialization.jose.internal.Base64UrlUtils +import dev.whyoleg.cryptography.serialization.jose.internal.CommonJoseHeader +import dev.whyoleg.cryptography.serialization.jose.internal.JoseCompactSerialization +import dev.whyoleg.cryptography.serialization.jose.internal.JoseCompactUtils +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement + +/** + * JSON Web Encryption (JWE) as defined in RFC 7516. + * + * JWE represents encrypted content using JSON-based data structures. + */ + +/** + * JWE Header parameters as defined in RFC 7516 Section 4.1. + */ +@Serializable +public data class JweHeader( + /** Algorithm used for key management */ + @SerialName("alg") + val algorithm: JweKeyManagementAlgorithm, + /** Content encryption algorithm */ + @SerialName("enc") + val encryptionAlgorithm: JweContentEncryptionAlgorithm, + /** JWE Type parameter */ + @SerialName("typ") + override val type: String? = null, + /** Content Type parameter */ + @SerialName("cty") + override val contentType: String? = null, + /** Key ID hint indicating which key was used to encrypt the JWE */ + @SerialName("kid") + override val keyId: String? = null, + /** JSON Web Key parameter */ + @SerialName("jwk") + override val jsonWebKey: JsonWebKey? = null, + /** X.509 URL parameter */ + @SerialName("x5u") + override val x509Url: String? = null, + /** X.509 Certificate Chain parameter */ + @SerialName("x5c") + override val x509CertificateChain: List? = null, + /** X.509 Certificate SHA-1 Thumbprint parameter */ + @SerialName("x5t") + override val x509CertificateSha1Thumbprint: String? = null, + /** X.509 Certificate SHA-256 Thumbprint parameter */ + @SerialName("x5t#S256") + override val x509CertificateSha256Thumbprint: String? = null, + /** Critical parameter - identifies which extensions are critical */ + @SerialName("crit") + override val critical: List? = null, + /** Ephemeral Public Key parameter (for ECDH-ES key agreement) */ + @SerialName("epk") + val ephemeralPublicKey: JsonWebKey? = null, + /** Agreement PartyUInfo parameter (for ECDH-ES key agreement) */ + @SerialName("apu") + val agreementPartyUInfo: String? = null, + /** Agreement PartyVInfo parameter (for ECDH-ES key agreement) */ + @SerialName("apv") + val agreementPartyVInfo: String? = null, + /** Key Mgmt Alg Initialization Vector parameter (for AES GCM key encryption) */ + @SerialName("iv") + val initializationVector: String? = null, + /** Key Mgmt Alg Authentication Tag parameter (for AES GCM key encryption) */ + @SerialName("tag") + val authenticationTag: String? = null, + /** PBES2 Salt Input parameter (for PBES2 key encryption) */ + @SerialName("p2s") + val pbes2SaltInput: String? = null, + /** PBES2 Count parameter (for PBES2 key encryption) */ + @SerialName("p2c") + val pbes2Count: Long? = null, + /** Additional header parameters */ + override val additionalParameters: Map = emptyMap() +) : CommonJoseHeader + +/** + * JSON Web Encryption Compact Serialization format. + * + * Represents a JWE in the Compact Serialization format: + * BASE64URL(UTF8(JWE Protected Header)) || '.' || + * BASE64URL(JWE Encrypted Key) || '.' || + * BASE64URL(JWE Initialization Vector) || '.' || + * BASE64URL(JWE Ciphertext) || '.' || + * BASE64URL(JWE Authentication Tag) + */ +@Serializable +public data class JweCompact( + val header: JweHeader, + val encryptedKey: ByteArray, + val initializationVector: ByteArray, + val ciphertext: ByteArray, + val authenticationTag: ByteArray +) : JoseCompactSerialization { + /** + * Encodes the JWE as a compact serialization string. + */ + override fun encode(): String { + val headerEncoded = Base64UrlUtils.encode(getHeaderJson()) + val encryptedKeyEncoded = Base64UrlUtils.encode(encryptedKey) + val ivEncoded = Base64UrlUtils.encode(initializationVector) + val ciphertextEncoded = Base64UrlUtils.encode(ciphertext) + val authTagEncoded = Base64UrlUtils.encode(authenticationTag) + + return JoseCompactUtils.createCompactString( + headerEncoded, encryptedKeyEncoded, ivEncoded, ciphertextEncoded, authTagEncoded + ) + } + + override fun getHeaderJson(): String = Json.encodeToString(JweHeader.serializer(), header) + + /** + * Returns the Additional Authenticated Data (AAD) for decryption. + */ + public fun getAdditionalAuthenticatedData(): ByteArray = Base64UrlUtils.encode(getHeaderJson()).encodeToByteArray() + + public companion object { + /** + * Decodes a JWE from its compact serialization string. + */ + public fun decode(jweString: String): JweCompact { + val parts = JoseCompactUtils.parseCompactString(jweString, 5) + + val headerJson = Base64UrlUtils.decodeToString(parts[0]) + val encryptedKey = Base64UrlUtils.decode(parts[1]) + val initializationVector = Base64UrlUtils.decode(parts[2]) + val ciphertext = Base64UrlUtils.decode(parts[3]) + val authenticationTag = Base64UrlUtils.decode(parts[4]) + + val header = Json.decodeFromString(JweHeader.serializer(), headerJson) + + return JweCompact(header, encryptedKey, initializationVector, ciphertext, authenticationTag) + } + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other == null || this::class != other::class) return false + + other as JweCompact + + if (header != other.header) return false + if (!encryptedKey.contentEquals(other.encryptedKey)) return false + if (!initializationVector.contentEquals(other.initializationVector)) return false + if (!ciphertext.contentEquals(other.ciphertext)) return false + if (!authenticationTag.contentEquals(other.authenticationTag)) return false + + return true + } + + override fun hashCode(): Int { + var result = header.hashCode() + result = 31 * result + encryptedKey.contentHashCode() + result = 31 * result + initializationVector.contentHashCode() + result = 31 * result + ciphertext.contentHashCode() + result = 31 * result + authenticationTag.contentHashCode() + return result + } +} + +/** + * JWE Recipient for JSON Serialization format. + */ +@Serializable +public data class JweRecipient( + /** Protected header parameters (base64url encoded) */ + @SerialName("header") + val header: JweHeader? = null, + /** The encrypted key value for this recipient */ + @SerialName("encrypted_key") + val encryptedKey: String +) + +/** + * JSON Web Encryption JSON Serialization format. + * + * Represents a JWE in the JSON Serialization format as defined in RFC 7516 Section 7.2. + */ +@Serializable +public data class JweJson( + /** Protected header parameters (base64url encoded) */ + @SerialName("protected") + val protectedHeader: String? = null, + /** Unprotected header parameters */ + @SerialName("unprotected") + val unprotectedHeader: JweHeader? = null, + /** Array of recipient objects for General JSON Serialization */ + @SerialName("recipients") + val recipients: List? = null, + /** For Flattened JSON Serialization - recipient header */ + @SerialName("header") + val header: JweHeader? = null, + /** For Flattened JSON Serialization - encrypted key */ + @SerialName("encrypted_key") + val encryptedKey: String? = null, + /** The initialization vector */ + @SerialName("iv") + val initializationVector: String, + /** The ciphertext */ + @SerialName("ciphertext") + val ciphertext: String, + /** The authentication tag */ + @SerialName("tag") + val authenticationTag: String +) { + /** + * Converts this JSON serialization to compact serialization if it contains exactly one recipient. + */ + public fun toCompact(): JweCompact? { + // Handle Flattened JSON Serialization + if (recipients == null && encryptedKey != null && protectedHeader != null) { + val headerJson = Base64UrlUtils.decodeToString(protectedHeader) + val header = Json.decodeFromString(JweHeader.serializer(), headerJson) + val encryptedKeyBytes = Base64UrlUtils.decode(encryptedKey) + val ivBytes = Base64UrlUtils.decode(initializationVector) + val ciphertextBytes = Base64UrlUtils.decode(ciphertext) + val authTagBytes = Base64UrlUtils.decode(authenticationTag) + + return JweCompact(header, encryptedKeyBytes, ivBytes, ciphertextBytes, authTagBytes) + } + + // Handle General JSON Serialization with single recipient + if (recipients?.size == 1 && protectedHeader != null) { + val recipient = recipients.first() + val headerJson = Base64UrlUtils.decodeToString(protectedHeader) + val header = Json.decodeFromString(JweHeader.serializer(), headerJson) + val encryptedKeyBytes = Base64UrlUtils.decode(recipient.encryptedKey) + val ivBytes = Base64UrlUtils.decode(initializationVector) + val ciphertextBytes = Base64UrlUtils.decode(ciphertext) + val authTagBytes = Base64UrlUtils.decode(authenticationTag) + + return JweCompact(header, encryptedKeyBytes, ivBytes, ciphertextBytes, authTagBytes) + } + + return null + } + + /** + * Checks if this is a flattened JSON serialization. + */ + public val isFlattened: Boolean + get() = recipients == null && encryptedKey != null + + /** + * Checks if this is a general JSON serialization. + */ + public val isGeneral: Boolean + get() = recipients != null + + public companion object { + /** + * Creates a flattened JSON serialization from a compact JWE. + */ + public fun fromCompact(compact: JweCompact): JweJson { + val protectedHeader = Base64UrlUtils.encode(compact.getHeaderJson()) + val encryptedKey = Base64UrlUtils.encode(compact.encryptedKey) + val iv = Base64UrlUtils.encode(compact.initializationVector) + val ciphertext = Base64UrlUtils.encode(compact.ciphertext) + val authTag = Base64UrlUtils.encode(compact.authenticationTag) + + return JweJson( + protectedHeader = protectedHeader, + encryptedKey = encryptedKey, + initializationVector = iv, + ciphertext = ciphertext, + authenticationTag = authTag + ) + } + } +} \ No newline at end of file diff --git a/cryptography-serialization/jose/src/commonMain/kotlin/dev/whyoleg/cryptography/serialization/jose/JsonWebKey.kt b/cryptography-serialization/jose/src/commonMain/kotlin/dev/whyoleg/cryptography/serialization/jose/JsonWebKey.kt new file mode 100644 index 00000000..03b75e12 --- /dev/null +++ b/cryptography-serialization/jose/src/commonMain/kotlin/dev/whyoleg/cryptography/serialization/jose/JsonWebKey.kt @@ -0,0 +1,506 @@ +/* + * Copyright (c) 2023-2025 Oleg Yukhnevich. Use of this source code is governed by the Apache 2.0 license. + */ + +package dev.whyoleg.cryptography.serialization.jose + +import dev.whyoleg.cryptography.serialization.jose.internal.JwkSerializationUtils +import kotlinx.serialization.* +import kotlinx.serialization.descriptors.* +import kotlinx.serialization.encoding.* +import kotlinx.serialization.json.* +import kotlin.jvm.JvmInline + +/** + * Key Types as defined in RFC 7518 + */ +@Serializable +@JvmInline +public value class JwkKeyType(public val value: String) { + public companion object { + /** RSA Key Type */ + public val RSA: JwkKeyType = JwkKeyType("RSA") + /** Elliptic Curve Key Type */ + public val EC: JwkKeyType = JwkKeyType("EC") + /** Symmetric Key Type */ + public val SYMMETRIC: JwkKeyType = JwkKeyType("oct") + } +} + +/** + * Public Key Use values + */ +@Serializable +@JvmInline +public value class JwkKeyUse(public val value: String) { + public companion object { + /** Signature Use */ + public val SIGNATURE: JwkKeyUse = JwkKeyUse("sig") + /** Encryption Use */ + public val ENCRYPTION: JwkKeyUse = JwkKeyUse("enc") + } +} + +/** + * Key Operations + */ +@Serializable +@JvmInline +public value class JwkKeyOperation(public val value: String) { + public companion object { + /** Sign Operation */ + public val SIGN: JwkKeyOperation = JwkKeyOperation("sign") + /** Verify Operation */ + public val VERIFY: JwkKeyOperation = JwkKeyOperation("verify") + /** Encrypt Operation */ + public val ENCRYPT: JwkKeyOperation = JwkKeyOperation("encrypt") + /** Decrypt Operation */ + public val DECRYPT: JwkKeyOperation = JwkKeyOperation("decrypt") + /** Wrap Key Operation */ + public val WRAP_KEY: JwkKeyOperation = JwkKeyOperation("wrapKey") + /** Unwrap Key Operation */ + public val UNWRAP_KEY: JwkKeyOperation = JwkKeyOperation("unwrapKey") + /** Derive Key Operation */ + public val DERIVE_KEY: JwkKeyOperation = JwkKeyOperation("deriveKey") + /** Derive Bits Operation */ + public val DERIVE_BITS: JwkKeyOperation = JwkKeyOperation("deriveBits") + } +} + +/** + * Elliptic Curve identifiers + */ +@Serializable +@JvmInline +public value class JwkEllipticCurve(public val value: String) { + public companion object { + /** P-256 curve */ + public val P256: JwkEllipticCurve = JwkEllipticCurve("P-256") + /** P-384 curve */ + public val P384: JwkEllipticCurve = JwkEllipticCurve("P-384") + /** P-521 curve */ + public val P521: JwkEllipticCurve = JwkEllipticCurve("P-521") + } +} + +/** + * JSON Web Key (JWK) as defined in RFC 7517. + * + * A JWK is a JSON object that represents a cryptographic key. + * This is a sealed interface that provides type-safe access to different key types. + */ +@Serializable(with = JsonWebKeySerializer::class) +public sealed interface JsonWebKey { + /** Key Type - identifies the cryptographic algorithm family used with the key */ + val keyType: JwkKeyType + /** Public Key Use - identifies the intended use of the public key */ + val keyUse: JwkKeyUse? + /** Key Operations - identifies the operation(s) for which the key is intended to be used */ + val keyOperations: List? + /** Algorithm - identifies the algorithm intended for use with the key */ + val algorithm: JwsAlgorithm? + /** Key ID - used to match a specific key among multiple keys */ + val keyId: String? + /** X.509 URL - URI that refers to a resource for an X.509 public key certificate or certificate chain */ + val x509Url: String? + /** X.509 Certificate Chain - chain of one or more PKIX certificates */ + val x509CertificateChain: List? + /** X.509 Certificate SHA-1 Thumbprint */ + val x509CertificateSha1Thumbprint: String? + /** X.509 Certificate SHA-256 Thumbprint */ + val x509CertificateSha256Thumbprint: String? + /** Additional key-specific parameters */ + val additionalParameters: Map +} + +/** + * RSA JSON Web Key base interface. + */ +public sealed interface RsaJsonWebKey : JsonWebKey { + override val keyType: JwkKeyType get() = JwkKeyType.RSA + /** Modulus */ + val modulus: String + /** Exponent */ + val exponent: String +} + +/** + * RSA Public JSON Web Key. + */ +@Serializable +public data class RsaPublicJsonWebKey( + override val modulus: String, + override val exponent: String, + override val keyUse: JwkKeyUse? = null, + override val keyOperations: List? = null, + override val algorithm: JwsAlgorithm? = null, + override val keyId: String? = null, + override val x509Url: String? = null, + override val x509CertificateChain: List? = null, + override val x509CertificateSha1Thumbprint: String? = null, + override val x509CertificateSha256Thumbprint: String? = null, + override val additionalParameters: Map = emptyMap() +) : RsaJsonWebKey + +/** + * RSA Private JSON Web Key. + */ +@Serializable +public data class RsaPrivateJsonWebKey( + override val modulus: String, + override val exponent: String, + val privateExponent: String, + val firstPrimeFactor: String? = null, + val secondPrimeFactor: String? = null, + val firstFactorCrtExponent: String? = null, + val secondFactorCrtExponent: String? = null, + val firstCrtCoefficient: String? = null, + override val keyUse: JwkKeyUse? = null, + override val keyOperations: List? = null, + override val algorithm: JwsAlgorithm? = null, + override val keyId: String? = null, + override val x509Url: String? = null, + override val x509CertificateChain: List? = null, + override val x509CertificateSha1Thumbprint: String? = null, + override val x509CertificateSha256Thumbprint: String? = null, + override val additionalParameters: Map = emptyMap() +) : RsaJsonWebKey + +/** + * Elliptic Curve JSON Web Key base interface. + */ +public sealed interface EcJsonWebKey : JsonWebKey { + override val keyType: JwkKeyType get() = JwkKeyType.EC + /** Curve */ + val curve: JwkEllipticCurve + /** X Coordinate */ + val xCoordinate: String + /** Y Coordinate */ + val yCoordinate: String +} + +/** + * Elliptic Curve Public JSON Web Key. + */ +@Serializable +public data class EcPublicJsonWebKey( + override val curve: JwkEllipticCurve, + override val xCoordinate: String, + override val yCoordinate: String, + override val keyUse: JwkKeyUse? = null, + override val keyOperations: List? = null, + override val algorithm: JwsAlgorithm? = null, + override val keyId: String? = null, + override val x509Url: String? = null, + override val x509CertificateChain: List? = null, + override val x509CertificateSha1Thumbprint: String? = null, + override val x509CertificateSha256Thumbprint: String? = null, + override val additionalParameters: Map = emptyMap() +) : EcJsonWebKey + +/** + * Elliptic Curve Private JSON Web Key. + */ +@Serializable +public data class EcPrivateJsonWebKey( + override val curve: JwkEllipticCurve, + override val xCoordinate: String, + override val yCoordinate: String, + val privateKey: String, + override val keyUse: JwkKeyUse? = null, + override val keyOperations: List? = null, + override val algorithm: JwsAlgorithm? = null, + override val keyId: String? = null, + override val x509Url: String? = null, + override val x509CertificateChain: List? = null, + override val x509CertificateSha1Thumbprint: String? = null, + override val x509CertificateSha256Thumbprint: String? = null, + override val additionalParameters: Map = emptyMap() +) : EcJsonWebKey + +/** + * Symmetric JSON Web Key. + */ +@Serializable +public data class SymmetricJsonWebKey( + val keyValue: String, + override val keyUse: JwkKeyUse? = null, + override val keyOperations: List? = null, + override val algorithm: JwsAlgorithm? = null, + override val keyId: String? = null, + override val x509Url: String? = null, + override val x509CertificateChain: List? = null, + override val x509CertificateSha1Thumbprint: String? = null, + override val x509CertificateSha256Thumbprint: String? = null, + override val additionalParameters: Map = emptyMap() +) : JsonWebKey { + override val keyType: JwkKeyType get() = JwkKeyType.SYMMETRIC +} + +/** + * Custom serializer for JsonWebKey that handles discrimination based on key type and private key presence. + */ +public object JsonWebKeySerializer : KSerializer { + override val descriptor: SerialDescriptor = buildClassSerialDescriptor("JsonWebKey") { + element("kty") + element("use", isOptional = true) + element?>("key_ops", isOptional = true) + element("alg", isOptional = true) + element("kid", isOptional = true) + element("x5u", isOptional = true) + element?>("x5c", isOptional = true) + element("x5t", isOptional = true) + element("x5t#S256", isOptional = true) + // RSA parameters + element("n", isOptional = true) + element("e", isOptional = true) + element("d", isOptional = true) + element("p", isOptional = true) + element("q", isOptional = true) + element("dp", isOptional = true) + element("dq", isOptional = true) + element("qi", isOptional = true) + // EC parameters + element("crv", isOptional = true) + element("x", isOptional = true) + element("y", isOptional = true) + // Symmetric parameter + element("k", isOptional = true) + } + + override fun serialize(encoder: Encoder, value: JsonWebKey) { + val jsonEncoder = encoder as JsonEncoder + val element = when (value) { + is RsaPublicJsonWebKey -> buildJsonObject { + put("kty", JsonPrimitive("RSA")) + put("n", JsonPrimitive(value.modulus)) + put("e", JsonPrimitive(value.exponent)) + JwkSerializationUtils.run { addCommonFields(value) } + } + is RsaPrivateJsonWebKey -> buildJsonObject { + put("kty", JsonPrimitive("RSA")) + put("n", JsonPrimitive(value.modulus)) + put("e", JsonPrimitive(value.exponent)) + put("d", JsonPrimitive(value.privateExponent)) + value.firstPrimeFactor?.let { put("p", JsonPrimitive(it)) } + value.secondPrimeFactor?.let { put("q", JsonPrimitive(it)) } + value.firstFactorCrtExponent?.let { put("dp", JsonPrimitive(it)) } + value.secondFactorCrtExponent?.let { put("dq", JsonPrimitive(it)) } + value.firstCrtCoefficient?.let { put("qi", JsonPrimitive(it)) } + JwkSerializationUtils.run { addCommonFields(value) } + } + is EcPublicJsonWebKey -> buildJsonObject { + put("kty", JsonPrimitive("EC")) + put("crv", JsonPrimitive(value.curve.value)) + put("x", JsonPrimitive(value.xCoordinate)) + put("y", JsonPrimitive(value.yCoordinate)) + JwkSerializationUtils.run { addCommonFields(value) } + } + is EcPrivateJsonWebKey -> buildJsonObject { + put("kty", JsonPrimitive("EC")) + put("crv", JsonPrimitive(value.curve.value)) + put("x", JsonPrimitive(value.xCoordinate)) + put("y", JsonPrimitive(value.yCoordinate)) + put("d", JsonPrimitive(value.privateKey)) + JwkSerializationUtils.run { addCommonFields(value) } + } + is SymmetricJsonWebKey -> buildJsonObject { + put("kty", JsonPrimitive("oct")) + put("k", JsonPrimitive(value.keyValue)) + JwkSerializationUtils.run { addCommonFields(value) } + } + else -> error("Unknown JsonWebKey type: ${value::class}") + } + jsonEncoder.encodeJsonElement(element) + } + + override fun deserialize(decoder: Decoder): JsonWebKey { + val jsonDecoder = decoder as JsonDecoder + val element = jsonDecoder.decodeJsonElement().jsonObject + + val kty = element["kty"]?.jsonPrimitive?.content ?: error("Missing 'kty' field") + val keyType = JwkKeyType(kty) + val common = JwkSerializationUtils.extractCommonParameters(element) + + return when (keyType) { + JwkKeyType.RSA -> { + val modulus = element["n"]?.jsonPrimitive?.content ?: error("Missing 'n' field for RSA key") + val exponent = element["e"]?.jsonPrimitive?.content ?: error("Missing 'e' field for RSA key") + val privateExponent = element["d"]?.jsonPrimitive?.content + + if (privateExponent != null) { + RsaPrivateJsonWebKey( + modulus = modulus, + exponent = exponent, + privateExponent = privateExponent, + firstPrimeFactor = element["p"]?.jsonPrimitive?.content, + secondPrimeFactor = element["q"]?.jsonPrimitive?.content, + firstFactorCrtExponent = element["dp"]?.jsonPrimitive?.content, + secondFactorCrtExponent = element["dq"]?.jsonPrimitive?.content, + firstCrtCoefficient = element["qi"]?.jsonPrimitive?.content, + keyUse = common.keyUse, + keyOperations = common.keyOperations, + algorithm = common.algorithm, + keyId = common.keyId, + x509Url = common.x509Url, + x509CertificateChain = common.x509CertificateChain, + x509CertificateSha1Thumbprint = common.x509CertificateSha1Thumbprint, + x509CertificateSha256Thumbprint = common.x509CertificateSha256Thumbprint, + additionalParameters = common.additionalParameters + ) + } else { + RsaPublicJsonWebKey( + modulus = modulus, + exponent = exponent, + keyUse = common.keyUse, + keyOperations = common.keyOperations, + algorithm = common.algorithm, + keyId = common.keyId, + x509Url = common.x509Url, + x509CertificateChain = common.x509CertificateChain, + x509CertificateSha1Thumbprint = common.x509CertificateSha1Thumbprint, + x509CertificateSha256Thumbprint = common.x509CertificateSha256Thumbprint, + additionalParameters = common.additionalParameters + ) + } + } + JwkKeyType.EC -> { + val curve = element["crv"]?.jsonPrimitive?.content?.let { JwkEllipticCurve(it) } ?: error("Missing 'crv' field for EC key") + val xCoordinate = element["x"]?.jsonPrimitive?.content ?: error("Missing 'x' field for EC key") + val yCoordinate = element["y"]?.jsonPrimitive?.content ?: error("Missing 'y' field for EC key") + val privateKey = element["d"]?.jsonPrimitive?.content + + if (privateKey != null) { + EcPrivateJsonWebKey( + curve = curve, + xCoordinate = xCoordinate, + yCoordinate = yCoordinate, + privateKey = privateKey, + keyUse = common.keyUse, + keyOperations = common.keyOperations, + algorithm = common.algorithm, + keyId = common.keyId, + x509Url = common.x509Url, + x509CertificateChain = common.x509CertificateChain, + x509CertificateSha1Thumbprint = common.x509CertificateSha1Thumbprint, + x509CertificateSha256Thumbprint = common.x509CertificateSha256Thumbprint, + additionalParameters = common.additionalParameters + ) + } else { + EcPublicJsonWebKey( + curve = curve, + xCoordinate = xCoordinate, + yCoordinate = yCoordinate, + keyUse = common.keyUse, + keyOperations = common.keyOperations, + algorithm = common.algorithm, + keyId = common.keyId, + x509Url = common.x509Url, + x509CertificateChain = common.x509CertificateChain, + x509CertificateSha1Thumbprint = common.x509CertificateSha1Thumbprint, + x509CertificateSha256Thumbprint = common.x509CertificateSha256Thumbprint, + additionalParameters = common.additionalParameters + ) + } + } + JwkKeyType.SYMMETRIC -> { + val keyValue = element["k"]?.jsonPrimitive?.content ?: error("Missing 'k' field for symmetric key") + SymmetricJsonWebKey( + keyValue = keyValue, + keyUse = common.keyUse, + keyOperations = common.keyOperations, + algorithm = common.algorithm, + keyId = common.keyId, + x509Url = common.x509Url, + x509CertificateChain = common.x509CertificateChain, + x509CertificateSha1Thumbprint = common.x509CertificateSha1Thumbprint, + x509CertificateSha256Thumbprint = common.x509CertificateSha256Thumbprint, + additionalParameters = common.additionalParameters + ) + } + else -> error("Unsupported key type: $kty") + } + } +} + +/** + * Extension properties and functions for JsonWebKey type checking and convenience. + */ + +/** + * Determines if this is a private key based on the key type. + */ +public val JsonWebKey.isPrivateKey: Boolean + get() = when (this) { + is RsaPrivateJsonWebKey -> true + is EcPrivateJsonWebKey -> true + is SymmetricJsonWebKey -> true // Symmetric keys contain the secret + else -> false + } + +/** + * Determines if this is a public key based on the key type. + */ +public val JsonWebKey.isPublicKey: Boolean + get() = when (this) { + is RsaPublicJsonWebKey -> true + is EcPublicJsonWebKey -> true + is SymmetricJsonWebKey -> false // Symmetric keys are neither public nor private in the traditional sense + else -> false + } + +/** + * JSON Web Key Set (JWK Set) as defined in RFC 7517. + * + * A JWK Set is a JSON object that represents a set of JWKs. + */ +@Serializable +public data class JsonWebKeySet( + /** Array of JWK values */ + val keys: List +) { + /** + * Finds a key by its Key ID (kid). + */ + public fun findByKeyId(keyId: String): JsonWebKey? = keys.find { it.keyId == keyId } + + /** + * Finds keys by their intended use. + */ + public fun findByUse(keyUse: JwkKeyUse): List = keys.filter { it.keyUse == keyUse } + + /** + * Finds keys by their algorithm. + */ + public fun findByAlgorithm(algorithm: JwsAlgorithm): List = keys.filter { it.algorithm == algorithm } + + /** + * Finds keys by their key type. + */ + public fun findByKeyType(keyType: JwkKeyType): List = keys.filter { it.keyType == keyType } + + /** + * Finds all public keys in the set. + */ + public fun findPublicKeys(): List = keys.filter { it.isPublicKey } + + /** + * Finds all private keys in the set. + */ + public fun findPrivateKeys(): List = keys.filter { it.isPrivateKey } + + /** + * Finds all RSA keys in the set. + */ + public fun findRsaKeys(): List = keys.filterIsInstance() + + /** + * Finds all EC keys in the set. + */ + public fun findEcKeys(): List = keys.filterIsInstance() + + /** + * Finds all symmetric keys in the set. + */ + public fun findSymmetricKeys(): List = keys.filterIsInstance() +} \ No newline at end of file diff --git a/cryptography-serialization/jose/src/commonMain/kotlin/dev/whyoleg/cryptography/serialization/jose/JsonWebSignature.kt b/cryptography-serialization/jose/src/commonMain/kotlin/dev/whyoleg/cryptography/serialization/jose/JsonWebSignature.kt new file mode 100644 index 00000000..56c6fc13 --- /dev/null +++ b/cryptography-serialization/jose/src/commonMain/kotlin/dev/whyoleg/cryptography/serialization/jose/JsonWebSignature.kt @@ -0,0 +1,234 @@ +/* + * Copyright (c) 2023-2025 Oleg Yukhnevich. Use of this source code is governed by the Apache 2.0 license. + */ + +package dev.whyoleg.cryptography.serialization.jose + +import dev.whyoleg.cryptography.serialization.jose.internal.Base64UrlUtils +import dev.whyoleg.cryptography.serialization.jose.internal.CommonJoseHeader +import dev.whyoleg.cryptography.serialization.jose.internal.JoseCompactSerialization +import dev.whyoleg.cryptography.serialization.jose.internal.JoseCompactUtils +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement + +/** + * JSON Web Signature (JWS) as defined in RFC 7515. + * + * JWS represents content secured with digital signatures or Message Authentication Codes (MACs) + * using JSON-based data structures. + */ + +/** + * JWS Header parameters as defined in RFC 7515 Section 4.1. + */ +@Serializable +public data class JwsHeader( + /** Algorithm used for signing/encrypting the JWS */ + @SerialName("alg") + val algorithm: JwsAlgorithm, + /** JWS and JWE Type parameter */ + @SerialName("typ") + override val type: String? = null, + /** Content Type parameter */ + @SerialName("cty") + override val contentType: String? = null, + /** Key ID hint indicating which key was used to secure the JWS */ + @SerialName("kid") + override val keyId: String? = null, + /** JSON Web Key parameter */ + @SerialName("jwk") + override val jsonWebKey: JsonWebKey? = null, + /** X.509 URL parameter */ + @SerialName("x5u") + override val x509Url: String? = null, + /** X.509 Certificate Chain parameter */ + @SerialName("x5c") + override val x509CertificateChain: List? = null, + /** X.509 Certificate SHA-1 Thumbprint parameter */ + @SerialName("x5t") + override val x509CertificateSha1Thumbprint: String? = null, + /** X.509 Certificate SHA-256 Thumbprint parameter */ + @SerialName("x5t#S256") + override val x509CertificateSha256Thumbprint: String? = null, + /** Critical parameter - identifies which extensions are critical */ + @SerialName("crit") + override val critical: List? = null, + /** Additional header parameters */ + override val additionalParameters: Map = emptyMap() +) : CommonJoseHeader + +/** + * JSON Web Signature Compact Serialization format. + * + * Represents a JWS in the Compact Serialization format: + * BASE64URL(UTF8(JWS Protected Header)) || '.' || + * BASE64URL(JWS Payload) || '.' || + * BASE64URL(JWS Signature) + */ +@Serializable +public data class JwsCompact( + val header: JwsHeader, + val payload: ByteArray, + val signature: ByteArray +) : JoseCompactSerialization { + /** + * Encodes the JWS as a compact serialization string. + */ + override fun encode(): String { + val headerEncoded = Base64UrlUtils.encode(getHeaderJson()) + val payloadEncoded = Base64UrlUtils.encode(payload) + val signatureEncoded = Base64UrlUtils.encode(signature) + + return JoseCompactUtils.createCompactString(headerEncoded, payloadEncoded, signatureEncoded) + } + + override fun getHeaderJson(): String = Json.encodeToString(JwsHeader.serializer(), header) + + /** + * Returns the signing input (header.payload) for signature verification. + */ + public fun getSigningInput(): ByteArray { + val headerEncoded = Base64UrlUtils.encode(getHeaderJson()) + val payloadEncoded = Base64UrlUtils.encode(payload) + + return "$headerEncoded.$payloadEncoded".encodeToByteArray() + } + + public companion object { + /** + * Decodes a JWS from its compact serialization string. + */ + public fun decode(jwsString: String): JwsCompact { + val parts = JoseCompactUtils.parseCompactString(jwsString, 3) + + val headerJson = Base64UrlUtils.decodeToString(parts[0]) + val payload = Base64UrlUtils.decode(parts[1]) + val signature = Base64UrlUtils.decode(parts[2]) + + val header = Json.decodeFromString(JwsHeader.serializer(), headerJson) + + return JwsCompact(header, payload, signature) + } + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other == null || this::class != other::class) return false + + other as JwsCompact + + if (header != other.header) return false + if (!payload.contentEquals(other.payload)) return false + if (!signature.contentEquals(other.signature)) return false + + return true + } + + override fun hashCode(): Int { + var result = header.hashCode() + result = 31 * result + payload.contentHashCode() + result = 31 * result + signature.contentHashCode() + return result + } +} + +/** + * JWS Signature for JSON Serialization format. + */ +@Serializable +public data class JwsSignature( + /** Protected header parameters (base64url encoded) */ + @SerialName("protected") + val protectedHeader: String? = null, + /** Unprotected header parameters */ + @SerialName("header") + val unprotectedHeader: JwsHeader? = null, + /** The signature value */ + @SerialName("signature") + val signature: String +) + +/** + * JSON Web Signature JSON Serialization format. + * + * Represents a JWS in the JSON Serialization format as defined in RFC 7515 Section 7.2. + */ +@Serializable +public data class JwsJson( + /** The JWS payload */ + @SerialName("payload") + val payload: String, + /** Array of signature objects for General JSON Serialization, single object for Flattened */ + @SerialName("signatures") + val signatures: List? = null, + /** For Flattened JSON Serialization - protected header */ + @SerialName("protected") + val protectedHeader: String? = null, + /** For Flattened JSON Serialization - unprotected header */ + @SerialName("header") + val unprotectedHeader: JwsHeader? = null, + /** For Flattened JSON Serialization - signature */ + @SerialName("signature") + val signature: String? = null +) { + /** + * Converts this JSON serialization to compact serialization if it contains exactly one signature. + */ + public fun toCompact(): JwsCompact? { + // Handle Flattened JSON Serialization + if (signatures == null && signature != null && protectedHeader != null) { + val headerJson = Base64UrlUtils.decodeToString(protectedHeader) + val header = Json.decodeFromString(JwsHeader.serializer(), headerJson) + val payloadBytes = Base64UrlUtils.decode(payload) + val signatureBytes = Base64UrlUtils.decode(signature) + + return JwsCompact(header, payloadBytes, signatureBytes) + } + + // Handle General JSON Serialization with single signature + if (signatures?.size == 1) { + val sig = signatures.first() + if (sig.protectedHeader != null) { + val headerJson = Base64UrlUtils.decodeToString(sig.protectedHeader) + val header = Json.decodeFromString(JwsHeader.serializer(), headerJson) + val payloadBytes = Base64UrlUtils.decode(payload) + val signatureBytes = Base64UrlUtils.decode(sig.signature) + + return JwsCompact(header, payloadBytes, signatureBytes) + } + } + + return null + } + + /** + * Checks if this is a flattened JSON serialization. + */ + public val isFlattened: Boolean + get() = signatures == null && signature != null + + /** + * Checks if this is a general JSON serialization. + */ + public val isGeneral: Boolean + get() = signatures != null + + public companion object { + /** + * Creates a flattened JSON serialization from a compact JWS. + */ + public fun fromCompact(compact: JwsCompact): JwsJson { + val protectedHeader = Base64UrlUtils.encode(compact.getHeaderJson()) + val payload = Base64UrlUtils.encode(compact.payload) + val signature = Base64UrlUtils.encode(compact.signature) + + return JwsJson( + payload = payload, + protectedHeader = protectedHeader, + signature = signature + ) + } + } +} \ No newline at end of file diff --git a/cryptography-serialization/jose/src/commonMain/kotlin/dev/whyoleg/cryptography/serialization/jose/JsonWebToken.kt b/cryptography-serialization/jose/src/commonMain/kotlin/dev/whyoleg/cryptography/serialization/jose/JsonWebToken.kt new file mode 100644 index 00000000..e67edf00 --- /dev/null +++ b/cryptography-serialization/jose/src/commonMain/kotlin/dev/whyoleg/cryptography/serialization/jose/JsonWebToken.kt @@ -0,0 +1,141 @@ +/* + * Copyright (c) 2023-2025 Oleg Yukhnevich. Use of this source code is governed by the Apache 2.0 license. + */ + +package dev.whyoleg.cryptography.serialization.jose + +import dev.whyoleg.cryptography.serialization.jose.internal.Base64UrlUtils +import dev.whyoleg.cryptography.serialization.jose.internal.JoseCompactSerialization +import dev.whyoleg.cryptography.serialization.jose.internal.JoseCompactUtils +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement + +/** + * JSON Web Token (JWT) representation as defined in RFC 7519. + * + * A JWT consists of three parts separated by dots: + * - Header: contains metadata about the token + * - Payload: contains the claims + * - Signature: ensures the token hasn't been tampered with + */ +@Serializable +public data class JsonWebToken( + val header: JwtHeader, + val payload: JwtPayload, + val signature: String? = null +) : JoseCompactSerialization { + /** + * Encodes the JWT as a compact serialization string. + * Format: base64url(header).base64url(payload).base64url(signature) + */ + override fun encode(): String { + val headerEncoded = Base64UrlUtils.encode(getHeaderJson()) + val payloadEncoded = Base64UrlUtils.encode(getPayloadJson()) + + return if (signature != null) { + JoseCompactUtils.createCompactString(headerEncoded, payloadEncoded, signature) + } else { + JoseCompactUtils.createCompactString(headerEncoded, payloadEncoded, "") + } + } + + override fun getHeaderJson(): String = Json.encodeToString(JwtHeader.serializer(), header) + + /** + * Gets the payload as a JSON string for encoding. + */ + public fun getPayloadJson(): String = Json.encodeToString(JwtPayload.serializer(), payload) + + public companion object { + /** + * Decodes a JWT from its compact serialization string. + */ + public fun decode(token: String): JsonWebToken { + val parts = JoseCompactUtils.parseCompactString(token, 3) + + val headerJson = Base64UrlUtils.decodeToString(parts[0]) + val payloadJson = Base64UrlUtils.decodeToString(parts[1]) + val signature = parts[2].takeIf { it.isNotEmpty() } + + val header = Json.decodeFromString(JwtHeader.serializer(), headerJson) + val payload = Json.decodeFromString(JwtPayload.serializer(), payloadJson) + + return JsonWebToken(header, payload, signature) + } + } +} + +/** + * JWT Header as defined in RFC 7515. + */ +@Serializable +public data class JwtHeader( + /** Algorithm used for signing/encrypting the JWT */ + @SerialName("alg") + val algorithm: JwsAlgorithm, + /** Type of the token, typically "JWT" */ + @SerialName("typ") + val type: String? = "JWT", + /** Key ID hint indicating which key was used to secure the JWT */ + @SerialName("kid") + val keyId: String? = null +) + +/** + * JWT Payload containing claims as defined in RFC 7519. + */ +@Serializable +public data class JwtPayload( + /** Issuer - identifies the principal that issued the JWT */ + @SerialName("iss") + val issuer: String? = null, + /** Subject - identifies the principal that is the subject of the JWT */ + @SerialName("sub") + val subject: String? = null, + /** Audience - identifies the recipients that the JWT is intended for */ + @SerialName("aud") + val audience: List? = null, + /** Expiration Time - identifies the expiration time on or after which the JWT MUST NOT be accepted */ + @SerialName("exp") + val expirationTime: Long? = null, + /** Not Before - identifies the time before which the JWT MUST NOT be accepted */ + @SerialName("nbf") + val notBefore: Long? = null, + /** Issued At - identifies the time at which the JWT was issued */ + @SerialName("iat") + val issuedAt: Long? = null, + /** JWT ID - provides a unique identifier for the JWT */ + @SerialName("jti") + val jwtId: String? = null, + /** Additional custom claims */ + val customClaims: Map = emptyMap() +) { + /** + * Convenience property for accessing single audience value. + */ + val singleAudience: String? + get() = audience?.singleOrNull() + + /** + * Checks if the JWT is expired at the given time (in seconds since epoch). + */ + public fun isExpired(currentTime: Long = System.currentTimeMillis() / 1000): Boolean { + return expirationTime != null && currentTime >= expirationTime + } + + /** + * Checks if the JWT is not yet valid at the given time (in seconds since epoch). + */ + public fun isNotYetValid(currentTime: Long = System.currentTimeMillis() / 1000): Boolean { + return notBefore != null && currentTime < notBefore + } + + /** + * Checks if the JWT is currently valid (not expired and not before current time). + */ + public fun isValid(currentTime: Long = System.currentTimeMillis() / 1000): Boolean { + return !isExpired(currentTime) && !isNotYetValid(currentTime) + } +} \ No newline at end of file diff --git a/cryptography-serialization/jose/src/commonMain/kotlin/dev/whyoleg/cryptography/serialization/jose/internal/Base64UrlUtils.kt b/cryptography-serialization/jose/src/commonMain/kotlin/dev/whyoleg/cryptography/serialization/jose/internal/Base64UrlUtils.kt new file mode 100644 index 00000000..a1807849 --- /dev/null +++ b/cryptography-serialization/jose/src/commonMain/kotlin/dev/whyoleg/cryptography/serialization/jose/internal/Base64UrlUtils.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2023-2025 Oleg Yukhnevich. Use of this source code is governed by the Apache 2.0 license. + */ + +package dev.whyoleg.cryptography.serialization.jose.internal + +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi + +/** + * Internal utilities for Base64 URL-safe encoding/decoding used across JOSE implementations. + */ +@OptIn(ExperimentalEncodingApi::class) +internal object Base64UrlUtils { + + /** + * Encodes byte array to Base64 URL-safe string without padding. + */ + fun encode(data: ByteArray): String = Base64.UrlSafe.encode(data).trimEnd('=') + + /** + * Encodes string to Base64 URL-safe string without padding. + */ + fun encode(data: String): String = encode(data.encodeToByteArray()) + + /** + * Decodes Base64 URL-safe string to byte array, adding padding if needed. + */ + fun decode(encoded: String): ByteArray = Base64.UrlSafe.decode(encoded.padBase64()) + + /** + * Decodes Base64 URL-safe string to string, adding padding if needed. + */ + fun decodeToString(encoded: String): String = decode(encoded).decodeToString() + + /** + * Adds Base64 padding to a string if needed. + */ + private fun String.padBase64(): String { + val padding = (4 - length % 4) % 4 + return this + "=".repeat(padding) + } +} \ No newline at end of file diff --git a/cryptography-serialization/jose/src/commonMain/kotlin/dev/whyoleg/cryptography/serialization/jose/internal/CommonJoseHeader.kt b/cryptography-serialization/jose/src/commonMain/kotlin/dev/whyoleg/cryptography/serialization/jose/internal/CommonJoseHeader.kt new file mode 100644 index 00000000..1fd6e901 --- /dev/null +++ b/cryptography-serialization/jose/src/commonMain/kotlin/dev/whyoleg/cryptography/serialization/jose/internal/CommonJoseHeader.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2023-2025 Oleg Yukhnevich. Use of this source code is governed by the Apache 2.0 license. + */ + +package dev.whyoleg.cryptography.serialization.jose.internal + +import dev.whyoleg.cryptography.serialization.jose.JsonWebKey +import kotlinx.serialization.json.JsonElement + +/** + * Common header parameters shared across JOSE specifications. + */ +internal interface CommonJoseHeader { + /** JWS and JWE Type parameter */ + val type: String? + /** Content Type parameter */ + val contentType: String? + /** Key ID hint indicating which key was used to secure the token */ + val keyId: String? + /** JSON Web Key parameter */ + val jsonWebKey: JsonWebKey? + /** X.509 URL parameter */ + val x509Url: String? + /** X.509 Certificate Chain parameter */ + val x509CertificateChain: List? + /** X.509 Certificate SHA-1 Thumbprint parameter */ + val x509CertificateSha1Thumbprint: String? + /** X.509 Certificate SHA-256 Thumbprint parameter */ + val x509CertificateSha256Thumbprint: String? + /** Critical parameter - identifies which extensions are critical */ + val critical: List? + /** Additional header parameters */ + val additionalParameters: Map +} \ No newline at end of file diff --git a/cryptography-serialization/jose/src/commonMain/kotlin/dev/whyoleg/cryptography/serialization/jose/internal/JoseCompactSerialization.kt b/cryptography-serialization/jose/src/commonMain/kotlin/dev/whyoleg/cryptography/serialization/jose/internal/JoseCompactSerialization.kt new file mode 100644 index 00000000..8bdc5105 --- /dev/null +++ b/cryptography-serialization/jose/src/commonMain/kotlin/dev/whyoleg/cryptography/serialization/jose/internal/JoseCompactSerialization.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2023-2025 Oleg Yukhnevich. Use of this source code is governed by the Apache 2.0 license. + */ + +package dev.whyoleg.cryptography.serialization.jose.internal + +import kotlinx.serialization.json.Json + +/** + * Common interface for JOSE compact serialization formats. + */ +internal interface JoseCompactSerialization { + + /** + * Encodes this object as a compact serialization string. + */ + fun encode(): String + + /** + * Gets the header as a JSON string for encoding. + */ + fun getHeaderJson(): String +} + +/** + * Common utilities for JOSE compact serialization. + */ +internal object JoseCompactUtils { + + /** + * Creates a compact serialization string from the given parts. + */ + fun createCompactString(vararg parts: String): String = parts.joinToString(".") + + /** + * Parses a compact serialization string into its parts. + */ + fun parseCompactString(compact: String, expectedParts: Int): List { + val parts = compact.split('.') + require(parts.size == expectedParts) { + "Invalid compact format: expected $expectedParts parts separated by dots, got ${parts.size}" + } + return parts + } +} \ No newline at end of file diff --git a/cryptography-serialization/jose/src/commonMain/kotlin/dev/whyoleg/cryptography/serialization/jose/internal/JwkSerializationUtils.kt b/cryptography-serialization/jose/src/commonMain/kotlin/dev/whyoleg/cryptography/serialization/jose/internal/JwkSerializationUtils.kt new file mode 100644 index 00000000..e46f908a --- /dev/null +++ b/cryptography-serialization/jose/src/commonMain/kotlin/dev/whyoleg/cryptography/serialization/jose/internal/JwkSerializationUtils.kt @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2023-2025 Oleg Yukhnevich. Use of this source code is governed by the Apache 2.0 license. + */ + +package dev.whyoleg.cryptography.serialization.jose.internal + +import dev.whyoleg.cryptography.serialization.jose.* +import kotlinx.serialization.json.* + +/** + * Common utilities for JsonWebKey serialization. + */ +internal object JwkSerializationUtils { + + /** + * Standard JWK field names that should not be included in additional parameters. + */ + val standardFields = setOf( + "kty", "use", "key_ops", "alg", "kid", "x5u", "x5c", "x5t", "x5t#S256", + "n", "e", "d", "p", "q", "dp", "dq", "qi", // RSA + "crv", "x", "y", // EC + "k" // Symmetric + ) + + /** + * Extracts common JWK parameters from a JSON object. + */ + fun extractCommonParameters(element: JsonObject): CommonJwkParameters { + return CommonJwkParameters( + keyUse = element["use"]?.jsonPrimitive?.content?.let { JwkKeyUse(it) }, + keyOperations = element["key_ops"]?.jsonArray?.map { JwkKeyOperation(it.jsonPrimitive.content) }, + algorithm = element["alg"]?.jsonPrimitive?.content?.let { JwsAlgorithm(it) }, + keyId = element["kid"]?.jsonPrimitive?.content, + x509Url = element["x5u"]?.jsonPrimitive?.content, + x509CertificateChain = element["x5c"]?.jsonArray?.map { it.jsonPrimitive.content }, + x509CertificateSha1Thumbprint = element["x5t"]?.jsonPrimitive?.content, + x509CertificateSha256Thumbprint = element["x5t#S256"]?.jsonPrimitive?.content, + additionalParameters = element.filterKeys { it !in standardFields } + ) + } + + /** + * Adds common JWK fields to a JSON object builder. + */ + fun JsonObjectBuilder.addCommonFields(value: JsonWebKey) { + value.keyUse?.let { put("use", JsonPrimitive(it.value)) } + value.keyOperations?.let { ops -> put("key_ops", JsonArray(ops.map { JsonPrimitive(it.value) })) } + value.algorithm?.let { put("alg", JsonPrimitive(it.value)) } + value.keyId?.let { put("kid", JsonPrimitive(it)) } + value.x509Url?.let { put("x5u", JsonPrimitive(it)) } + value.x509CertificateChain?.let { chain -> put("x5c", JsonArray(chain.map { JsonPrimitive(it) })) } + value.x509CertificateSha1Thumbprint?.let { put("x5t", JsonPrimitive(it)) } + value.x509CertificateSha256Thumbprint?.let { put("x5t#S256", JsonPrimitive(it)) } + value.additionalParameters.forEach { (key, value) -> put(key, value) } + } +} + +/** + * Common JWK parameters extracted from JSON. + */ +internal data class CommonJwkParameters( + val keyUse: JwkKeyUse?, + val keyOperations: List?, + val algorithm: JwsAlgorithm?, + val keyId: String?, + val x509Url: String?, + val x509CertificateChain: List?, + val x509CertificateSha1Thumbprint: String?, + val x509CertificateSha256Thumbprint: String?, + val additionalParameters: Map +) \ No newline at end of file diff --git a/cryptography-serialization/jose/src/commonTest/kotlin/dev/whyoleg/cryptography/serialization/jose/JoseRfcExamplesTest.kt b/cryptography-serialization/jose/src/commonTest/kotlin/dev/whyoleg/cryptography/serialization/jose/JoseRfcExamplesTest.kt new file mode 100644 index 00000000..c597723a --- /dev/null +++ b/cryptography-serialization/jose/src/commonTest/kotlin/dev/whyoleg/cryptography/serialization/jose/JoseRfcExamplesTest.kt @@ -0,0 +1,374 @@ +/* + * Copyright (c) 2023-2025 Oleg Yukhnevich. Use of this source code is governed by the Apache 2.0 license. + */ + +package dev.whyoleg.cryptography.serialization.jose + +import kotlinx.serialization.json.Json +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** + * Tests with examples from RFC 7520 - Examples of Protecting Content Using JSON Object Signing and Encryption (JOSE) + */ +class JoseRfcExamplesTest { + + /** + * RFC 7520 Section 3.1 - Example JWS Using RSASSA-PKCS1-v1_5 SHA-256 + */ + @Test + fun testRfc7520Example3_1_JwsRsaSha256() { + // Example RSA key from RFC 7520 Appendix A.2 + val rsaKey = RsaPrivateJsonWebKey( + modulus = "sRJjz4mXHlhtPAy_DC86yXEM_VWBuXU9yTNNLJMT-LBP4I5CtMq_-LRj-pLnLxBn2v8BqIlwRp8C0fEH8Lq3K0WBfN2v9aAFRK9lCGE2aRQMM2F-JR_9Q8KhNPrK-IB5g6x9-GF-ACLcBGAsS1JZVEt-L8k9Q5RhZFQiw8Af-4r3q6l9h-wK0gfmF7m1S3QrNKo1H2M-cTFuD4OLdlg5YKNLqKHKNS0QNjBzX-3DL8ysBQGaJ7g3-lN_Bw", + exponent = "AQAB", + privateExponent = "kVdKcDhYLFOWjsGZKWfEsZNQGH1pNOcYkNJPdpzKfK-kCvR_HDNQNFg5VmRpQ3k1k9HLJgqqGY-HDNsT6k2Y-jF3X_J1R9K-g5F8ZK5jg5K_gQ" + ) + + // JWS Header from the example + val header = JwsHeader( + algorithm = JwsAlgorithm.RS256, + type = "JWT" + ) + + // The compact JWS from RFC 7520 Section 3.1 + val compactJws = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJqb2UiLA0KICJleHAiOjEzMDA4MTkzODAsDQogImh0dHA6Ly9leGFtcGxlLmNvbS9pc19yb290Ijp0cnVlfQ.cC4hiUPoj9Eetdgtv3hF80EGrhuB__dzERat0XF9g2VtQgr9PiuMmIRjS9XKwg-uT5nQ-K2dQJQw-K1e9EAg2X9LoXEpxJy1ByUiIzgd3d5K-wX2VKgp-Zn-lzYnpVmm-JhKqr_Y5B8k5XlnKCbYVrTyEHm2VXhP8HGt3lEI98K8BRCfG-0U9gY8SjQsKmL4WfOv7DaK3LFRQ" + + // Decode and verify structure + val decoded = JwsCompact.decode(compactJws) + assertEquals(JwsAlgorithm.RS256, decoded.header.algorithm) + assertEquals("JWT", decoded.header.type) + + // Verify the signing input can be generated + val signingInput = decoded.getSigningInput() + assertTrue(signingInput.isNotEmpty()) + + // Verify round trip + val reencoded = decoded.encode() + assertEquals(compactJws, reencoded) + } + + /** + * RFC 7520 Section 3.2 - Example JWS Using ECDSA P-256 SHA-256 + */ + @Test + fun testRfc7520Example3_2_JwsEcdsaSha256() { + // Example EC key from RFC 7520 Appendix A.3 + val ecKey = EcPrivateJsonWebKey( + curve = JwkEllipticCurve.P256, + xCoordinate = "f83OJ3D2xF1Bg8vub9tLe1gHMzV76e8Tus9uPHvRVEU", + yCoordinate = "x_FEzRu9m36HLN_tue659LNpXW6pCyStikYjKIWI5a0", + privateKey = "jpsQnnGQmL-YBIffH1136cspYG6-0iY7X1fCE9-E9LI" + ) + + val header = JwsHeader( + algorithm = JwsAlgorithm.ES256, + type = "JWT" + ) + + // Test that we can create a proper JWS structure + val payload = "eyJpc3MiOiJqb2UiLA0KICJleHAiOjEzMDA4MTkzODAsDQogImh0dHA6Ly9leGFtcGxlLmNvbS9pc19yb290Ijp0cnVlfQ".toByteArray() + val signature = "DtEhU3ljbEg8L38VWAfUAqOyKAM6-Xx-F4GawxaepmXFCgfTjDxw5djxLa8ISlSApmWQxfKTUJqPP3-Kg6NU1Q".toByteArray() + + val jws = JwsCompact(header, payload, signature) + val encoded = jws.encode() + + // Verify it can be decoded back + val decoded = JwsCompact.decode(encoded) + assertEquals(header.algorithm, decoded.header.algorithm) + assertEquals(header.type, decoded.header.type) + } + + /** + * RFC 7520 Section 3.3 - Example JWS Using HMAC SHA-256 + */ + @Test + fun testRfc7520Example3_3_JwsHmacSha256() { + // Example symmetric key from RFC 7520 Appendix A.1 + val symmetricKey = SymmetricJsonWebKey( + keyValue = "GawgguFyGrWKav7AX4VKUg" + ) + + val header = JwsHeader( + algorithm = JwsAlgorithm.HS256, + type = "JWT" + ) + + // The compact JWS from RFC 7520 Section 3.3 + val compactJws = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJqb2UiLA0KICJleHAiOjEzMDA4MTkzODAsDQogImh0dHA6Ly9leGFtcGxlLmNvbS9pc19yb290Ijp0cnVlfQ.Hm_9tn_lTOyTdOOZ_z70zfPBg-z-o-IQs1pPfGrFAmg" + + val decoded = JwsCompact.decode(compactJws) + assertEquals(JwsAlgorithm.HS256, decoded.header.algorithm) + assertEquals("JWT", decoded.header.type) + + // Test round trip + val reencoded = decoded.encode() + assertEquals(compactJws, reencoded) + } + + /** + * RFC 7520 Section 3.4 - Example Unsecured JWS + */ + @Test + fun testRfc7520Example3_4_UnsecuredJws() { + val header = JwsHeader( + algorithm = JwsAlgorithm.NONE + ) + + // Unsecured JWS has empty signature + val payload = "eyJpc3MiOiJqb2UiLA0KICJleHAiOjEzMDA4MTkzODAsDQogImh0dHA6Ly9leGFtcGxlLmNvbS9pc19yb290Ijp0cnVlfQ".toByteArray() + val signature = byteArrayOf() + + val jws = JwsCompact(header, payload, signature) + val encoded = jws.encode() + + // Unsecured JWS should end with a dot and no signature + assertTrue(encoded.endsWith(".")) + + val decoded = JwsCompact.decode(encoded) + assertEquals(JwsAlgorithm.NONE, decoded.header.algorithm) + assertTrue(decoded.signature.isEmpty()) + } + + /** + * RFC 7520 Section 4.1 - Example JWE using RSAES-OAEP and AES GCM + */ + @Test + fun testRfc7520Example4_1_JweRsaOaepAesGcm() { + val header = JweHeader( + algorithm = JweKeyManagementAlgorithm.RSA_OAEP, + encryptionAlgorithm = JweContentEncryptionAlgorithm.A256GCM + ) + + // Example JWE components + val encryptedKey = "OKOawDo13gRp2ojaHV7LFpZcgV7T6DVZKTyKOMTYUmKoTCVJRgckCL9kiMT03JGeipsEdY3mx_etLbbWSrFr05kLzcSr4qKAq7YN7e9jwQRb23nfa6c9d-StnImGyFDbSv04uVuxIp5Zms1gNxKKK2Da14B8S4rzVRltdYwam_lDp5XnZAYpQdb76FdIKLaVmqgfwX7XWRxv2322i-vDxRfqNzo_tETKzpVLzfiwQyeyPGLBIO56YJ7eObdv0je81860ppamavo35UgoRdbYaBcoh9QcfylQr66oc6vFWXRcZ_ZT2LawVCWTIy3brGPi6UklfCpIMfIjf7iGdXKHzg".toByteArray() + val iv = "48V1_ALb6US04U3b".toByteArray() + val ciphertext = "5eym8TW_c8SuK0ltJ3rpYIzOeDQz7TALvtu6UG9oMo4vpzs9tX_EFShS8iB7j6jiSdiwkIr3ajwQzaBtQD_A".toByteArray() + val authTag = "XFBoMYUZodetZdvTiFvSkQ".toByteArray() + + val jwe = JweCompact(header, encryptedKey, iv, ciphertext, authTag) + val encoded = jwe.encode() + + // Verify structure + val parts = encoded.split('.') + assertEquals(5, parts.size, "JWE should have 5 parts") + + // Verify round trip + val decoded = JweCompact.decode(encoded) + assertEquals(header.algorithm, decoded.header.algorithm) + assertEquals(header.encryptionAlgorithm, decoded.header.encryptionAlgorithm) + } + + /** + * RFC 7520 Section 4.2 - Example JWE using RSAES-PKCS1-v1_5 and AES_128_CBC_HMAC_SHA_256 + */ + @Test + fun testRfc7520Example4_2_JweRsaPkcs1AesCbc() { + val header = JweHeader( + algorithm = JweKeyManagementAlgorithm.RSA1_5, + encryptionAlgorithm = JweContentEncryptionAlgorithm.A128CBC_HS256 + ) + + val encryptedKey = "UGhIOguC7IuEvf_NPVaXsGMoLOmwvc1GyqlIKOK1nN94nHPoltGRhWhw7Zx0-kFm1NJn8LE9XShH59_i8J0PH5ZZyNfGy2xGdULU7sHNF6Gp2vPLgNZ__deLKxGHZ7PcHALUzoOegEI-8E66jX2E4zyJKx-YxzZIItRzC5hlRirb6Y5Cl_p-ko3YvkkysZIFNPccxRU7qve1WYPxqbb2Yw8kZqa2rMWI5ng8OtvzlV7elprCbuPhcCdZ6XDP0_F8rkXds2vE4X-ncOIM8hAYHHi29NX0mcKiRaD0-D-ljQTP-cFPgwCp6X-nZZd9OHBv-B3oWh2TbqmScqXMR4gp_A".toByteArray() + val iv = "AxY8DCtDaGlsbGljb3RoZQ".toByteArray() + val ciphertext = "KDlTtXchhZTGufMYmOYGS4HffxPSUrfmqCHXaI9wOGY".toByteArray() + val authTag = "Mz-VPPyU4RlcuYv1IwIvzw".toByteArray() + + val jwe = JweCompact(header, encryptedKey, iv, ciphertext, authTag) + val encoded = jwe.encode() + + val decoded = JweCompact.decode(encoded) + assertEquals(header.algorithm, decoded.header.algorithm) + assertEquals(header.encryptionAlgorithm, decoded.header.encryptionAlgorithm) + } + + /** + * RFC 7520 Section 4.3 - Example JWE using AES Key Wrap and AES_128_CBC_HMAC_SHA_256 + */ + @Test + fun testRfc7520Example4_3_JweAesKeyWrap() { + val header = JweHeader( + algorithm = JweKeyManagementAlgorithm.A128KW, + encryptionAlgorithm = JweContentEncryptionAlgorithm.A128CBC_HS256 + ) + + val encryptedKey = "6KB707dM9YTIgHtLvtgWQ8mKwboJW3of9locizkDTHzBC2IlrT1oOQ".toByteArray() + val iv = "AxY8DCtDaGlsbGljb3RoZQ".toByteArray() + val ciphertext = "KDlTtXchhZTGufMYmOYGS4HffxPSUrfmqCHXaI9wOGY".toByteArray() + val authTag = "U0m_YmjN04DJvceFICbCVQ".toByteArray() + + val jwe = JweCompact(header, encryptedKey, iv, ciphertext, authTag) + + // Test round trip + val encoded = jwe.encode() + val decoded = JweCompact.decode(encoded) + assertEquals(jwe, decoded) + } + + /** + * RFC 7520 Section 4.4 - Example JWE using General JWE JSON Serialization + */ + @Test + fun testRfc7520Example4_4_JweJsonGeneral() { + val jweJson = """{ + "protected": "eyJlbmMiOiJBMTI4Q0JDLUhTMjU2In0", + "unprotected": {"jku": "https://server.example.com/keys.jwks"}, + "recipients": [ + { + "header": {"alg": "RSA1_5", "kid": "2010-12-29"}, + "encrypted_key": "UGhIOguC7IuEvf_NPVaXsGMoLOmwvc1GyqlIKOK1nN94nHPoltGRhWhw7Zx0-kFm1NJn8LE9XShH59_i8J0PH5ZZyNfGy2xGdULU7sHNF6Gp2vPLgNZ__deLKxGHZ7PcHALUzoOegEI-8E66jX2E4zyJKx-YxzZIItRzC5hlRirb6Y5Cl_p-ko3YvkkysZIFNPccxRU7qve1WYPxqbb2Yw8kZqa2rMWI5ng8OtvzlV7elprCbuPhcCdZ6XDP0_F8rkXds2vE4X-ncOIM8hAYHHi29NX0mcKiRaD0-D-ljQTP-cFPgwCp6X-nZZd9OHBv-B3oWh2TbqmScqXMR4gp_A" + }, + { + "header": {"alg": "A128KW", "kid": "7"}, + "encrypted_key": "6KB707dM9YTIgHtLvtgWQ8mKwboJW3of9locizkDTHzBC2IlrT1oOQ" + } + ], + "iv": "AxY8DCtDaGlsbGljb3RoZQ", + "ciphertext": "KDlTtXchhZTGufMYmOYGS4HffxPSUrfmqCHXaI9wOGY", + "tag": "Mz-VPPyU4RlcuYv1IwIvzw" + }""".trimIndent() + + val parsed = Json.decodeFromString(jweJson) + + assertTrue(parsed.isGeneral) + assertEquals(2, parsed.recipients?.size) + assertNotNull(parsed.protectedHeader) + assertNotNull(parsed.unprotectedHeader) + + // Test that we can access recipients + val recipients = parsed.recipients!! + assertEquals("RSA1_5", recipients[0].header?.algorithm?.value) + assertEquals("A128KW", recipients[1].header?.algorithm?.value) + } + + /** + * RFC 7520 Section 4.5 - Example JWE using Flattened JWE JSON Serialization + */ + @Test + fun testRfc7520Example4_5_JweJsonFlattened() { + val jweJson = """{ + "protected": "eyJlbmMiOiJBMTI4Q0JDLUhTMjU2In0", + "unprotected": {"jku": "https://server.example.com/keys.jwks"}, + "header": {"alg": "A128KW", "kid": "7"}, + "encrypted_key": "6KB707dM9YTIgHtLvtgWQ8mKwboJW3of9locizkDTHzBC2IlrT1oOQ", + "iv": "AxY8DCtDaGlsbGljb3RoZQ", + "ciphertext": "KDlTtXchhZTGufMYmOYGS4HffxPSUrfmqCHXaI9wOGY", + "tag": "U0m_YmjN04DJvceFICbCVQ" + }""".trimIndent() + + val parsed = Json.decodeFromString(jweJson) + + assertTrue(parsed.isFlattened) + assertNotNull(parsed.header) + assertEquals("A128KW", parsed.header?.algorithm?.value) + assertNotNull(parsed.encryptedKey) + } + + /** + * RFC 7520 Section 5.1 - Example Key (EC Public Key) + */ + @Test + fun testRfc7520Example5_1_EcPublicKey() { + val keyJson = """{ + "kty": "EC", + "kid": "1", + "use": "sig", + "crv": "P-256", + "x": "f83OJ3D2xF1Bg8vub9tLe1gHMzV76e8Tus9uPHvRVEU", + "y": "x_FEzRu9m36HLN_tue659LNpXW6pCyStikYjKIWI5a0" + }""".trimIndent() + + val key = Json.decodeFromString(keyJson) + + assertTrue(key is EcPublicJsonWebKey) + assertEquals("1", key.keyId) + assertEquals(JwkKeyUse.SIGNATURE, key.keyUse) + assertEquals(JwkEllipticCurve.P256, key.curve) + } + + /** + * RFC 7520 Section 5.2 - Example Key (RSA Public Key) + */ + @Test + fun testRfc7520Example5_2_RsaPublicKey() { + val keyJson = """{ + "kty": "RSA", + "kid": "2010-12-29", + "use": "enc", + "n": "sRJjz4mXHlhtPAy_DC86yXEM_VWBuXU9yTNNLJMT-LBP4I5CtMq_-LRj-pLnLxBn2v8BqIlwRp8C0fEH8Lq3K0WBfN2v9aAFRK9lCGE2aRQMM2F-JR_9Q8KhNPrK-IB5g6x9-GF-ACLcBGAsS1JZVEt-L8k9Q5RhZFQiw8Af-4r3q6l9h-wK0gfmF7m1S3QrNKo1H2M-cTFuD4OLdlg5YKNLqKHKNS0QNjBzX-3DL8ysBQGaJ7g3-lN_Bw", + "e": "AQAB" + }""".trimIndent() + + val key = Json.decodeFromString(keyJson) + + assertTrue(key is RsaPublicJsonWebKey) + assertEquals("2010-12-29", key.keyId) + assertEquals(JwkKeyUse.ENCRYPTION, key.keyUse) + assertEquals("AQAB", key.exponent) + } + + /** + * RFC 7520 Section 5.3 - Example Key (Symmetric Key) + */ + @Test + fun testRfc7520Example5_3_SymmetricKey() { + val keyJson = """{ + "kty": "oct", + "kid": "018c0ae5-4d9b-471b-bfd6-eef314bc7037", + "use": "sig", + "alg": "HS256", + "k": "hJtXIZ2uSN5kbQfbtTNWbpdmhkV8FJG-Onbc6mxCcYg" + }""".trimIndent() + + val key = Json.decodeFromString(keyJson) + + assertTrue(key is SymmetricJsonWebKey) + assertEquals("018c0ae5-4d9b-471b-bfd6-eef314bc7037", key.keyId) + assertEquals(JwkKeyUse.SIGNATURE, key.keyUse) + assertEquals(JwsAlgorithm.HS256, key.algorithm) + } + + /** + * RFC 7520 Section 5.4 - Example Public Keys + */ + @Test + fun testRfc7520Example5_4_PublicKeys() { + val keysJson = """{ + "keys": [ + { + "kty": "EC", + "kid": "1", + "use": "sig", + "crv": "P-256", + "x": "f83OJ3D2xF1Bg8vub9tLe1gHMzV76e8Tus9uPHvRVEU", + "y": "x_FEzRu9m36HLN_tue659LNpXW6pCyStikYjKIWI5a0" + }, + { + "kty": "RSA", + "kid": "2010-12-29", + "use": "enc", + "n": "sRJjz4mXHlhtPAy_DC86yXEM_VWBuXU9yTNNLJMT-LBP4I5CtMq_-LRj-pLnLxBn2v8BqIlwRp8C0fEH8Lq3K0WBfN2v9aAFRK9lCGE2aRQMM2F-JR_9Q8KhNPrK-IB5g6x9-GF-ACLcBGAsS1JZVEt-L8k9Q5RhZFQiw8Af-4r3q6l9h-wK0gfmF7m1S3QrNKo1H2M-cTFuD4OLdlg5YKNLqKHKNS0QNjBzX-3DL8ysBQGaJ7g3-lN_Bw", + "e": "AQAB" + } + ] + }""".trimIndent() + + val keySet = Json.decodeFromString(keysJson) + + assertEquals(2, keySet.keys.size) + + val ecKey = keySet.findByKeyId("1") + assertTrue(ecKey is EcPublicJsonWebKey) + + val rsaKey = keySet.findByKeyId("2010-12-29") + assertTrue(rsaKey is RsaPublicJsonWebKey) + + val sigKeys = keySet.findByUse(JwkKeyUse.SIGNATURE) + assertEquals(1, sigKeys.size) + + val encKeys = keySet.findByUse(JwkKeyUse.ENCRYPTION) + assertEquals(1, encKeys.size) + } +} \ No newline at end of file diff --git a/cryptography-serialization/jose/src/commonTest/kotlin/dev/whyoleg/cryptography/serialization/jose/JsonWebEncryptionTest.kt b/cryptography-serialization/jose/src/commonTest/kotlin/dev/whyoleg/cryptography/serialization/jose/JsonWebEncryptionTest.kt new file mode 100644 index 00000000..008cfff9 --- /dev/null +++ b/cryptography-serialization/jose/src/commonTest/kotlin/dev/whyoleg/cryptography/serialization/jose/JsonWebEncryptionTest.kt @@ -0,0 +1,194 @@ +/* + * Copyright (c) 2023-2025 Oleg Yukhnevich. Use of this source code is governed by the Apache 2.0 license. + */ + +package dev.whyoleg.cryptography.serialization.jose + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class JsonWebEncryptionTest { + + @Test + fun testJweHeaderSerialization() { + val header = JweHeader( + algorithm = JweKeyManagementAlgorithm.RSA_OAEP, + encryptionAlgorithm = JweContentEncryptionAlgorithm.A256GCM, + keyId = "test-key-id" + ) + + assertEquals(JweKeyManagementAlgorithm.RSA_OAEP, header.algorithm) + assertEquals(JweContentEncryptionAlgorithm.A256GCM, header.encryptionAlgorithm) + assertEquals("test-key-id", header.keyId) + } + + @Test + fun testJweCompactEncodeDecodeRoundTrip() { + val header = JweHeader( + algorithm = JweKeyManagementAlgorithm.A256KW, + encryptionAlgorithm = JweContentEncryptionAlgorithm.A256GCM + ) + val encryptedKey = "encrypted-key".toByteArray() + val iv = "initialization-vector".toByteArray() + val ciphertext = "ciphertext-data".toByteArray() + val authTag = "auth-tag".toByteArray() + + val original = JweCompact(header, encryptedKey, iv, ciphertext, authTag) + val encoded = original.encode() + val decoded = JweCompact.decode(encoded) + + assertEquals(original, decoded) + assertEquals(original.header.algorithm, decoded.header.algorithm) + assertEquals(original.header.encryptionAlgorithm, decoded.header.encryptionAlgorithm) + assertTrue(original.encryptedKey.contentEquals(decoded.encryptedKey)) + assertTrue(original.initializationVector.contentEquals(decoded.initializationVector)) + assertTrue(original.ciphertext.contentEquals(decoded.ciphertext)) + assertTrue(original.authenticationTag.contentEquals(decoded.authenticationTag)) + } + + @Test + fun testJweCompactStructure() { + val header = JweHeader( + algorithm = JweKeyManagementAlgorithm.DIR, + encryptionAlgorithm = JweContentEncryptionAlgorithm.A128GCM + ) + val encryptedKey = byteArrayOf() // Empty for direct encryption + val iv = "test-iv".toByteArray() + val ciphertext = "test-ciphertext".toByteArray() + val authTag = "test-tag".toByteArray() + + val jwe = JweCompact(header, encryptedKey, iv, ciphertext, authTag) + val encoded = jwe.encode() + + // JWE should have 5 parts separated by dots + val parts = encoded.split('.') + assertEquals(5, parts.size) + + // For direct encryption, encrypted key part should be empty + assertTrue(parts[1].isEmpty() || parts[1] == "") + } + + @Test + fun testJweAdditionalAuthenticatedData() { + val header = JweHeader( + algorithm = JweKeyManagementAlgorithm.RSA1_5, + encryptionAlgorithm = JweContentEncryptionAlgorithm.A128CBC_HS256 + ) + val encryptedKey = "key".toByteArray() + val iv = "iv".toByteArray() + val ciphertext = "ciphertext".toByteArray() + val authTag = "tag".toByteArray() + + val jwe = JweCompact(header, encryptedKey, iv, ciphertext, authTag) + val aad = jwe.getAdditionalAuthenticatedData() + + assertTrue(aad.isNotEmpty()) + // AAD should be the base64url encoded header + val aadString = aad.decodeToString() + assertFalse(aadString.contains(".")) + } + + @Test + fun testJweJsonFlattened() { + val header = JweHeader( + algorithm = JweKeyManagementAlgorithm.A128KW, + encryptionAlgorithm = JweContentEncryptionAlgorithm.A128GCM + ) + val encryptedKey = "encrypted-key".toByteArray() + val iv = "iv".toByteArray() + val ciphertext = "ciphertext".toByteArray() + val authTag = "tag".toByteArray() + + val compact = JweCompact(header, encryptedKey, iv, ciphertext, authTag) + val jsonFlattened = JweJson.fromCompact(compact) + + assertTrue(jsonFlattened.isFlattened) + assertFalse(jsonFlattened.isGeneral) + + // Test conversion back to compact + val convertedBack = jsonFlattened.toCompact() + assertEquals(compact, convertedBack) + } + + @Test + fun testJweJsonGeneral() { + val jweJson = JweJson( + protectedHeader = "eyJlbmMiOiJBMTI4R0NNIn0", + recipients = listOf( + JweRecipient( + header = JweHeader( + algorithm = JweKeyManagementAlgorithm.RSA_OAEP, + encryptionAlgorithm = JweContentEncryptionAlgorithm.A128GCM + ), + encryptedKey = "key1" + ), + JweRecipient( + header = JweHeader( + algorithm = JweKeyManagementAlgorithm.A256KW, + encryptionAlgorithm = JweContentEncryptionAlgorithm.A128GCM + ), + encryptedKey = "key2" + ) + ), + initializationVector = "iv", + ciphertext = "ciphertext", + authenticationTag = "tag" + ) + + assertFalse(jweJson.isFlattened) + assertTrue(jweJson.isGeneral) + assertEquals(2, jweJson.recipients?.size) + } + + @Test + fun testJweEcdhParameters() { + val ephemeralKey = EcPublicJsonWebKey( + curve = JwkEllipticCurve.P256, + xCoordinate = "test-x", + yCoordinate = "test-y" + ) + + val header = JweHeader( + algorithm = JweKeyManagementAlgorithm.ECDH_ES, + encryptionAlgorithm = JweContentEncryptionAlgorithm.A256GCM, + ephemeralPublicKey = ephemeralKey, + agreementPartyUInfo = "Alice", + agreementPartyVInfo = "Bob" + ) + + assertEquals(JweKeyManagementAlgorithm.ECDH_ES, header.algorithm) + assertEquals(ephemeralKey, header.ephemeralPublicKey) + assertEquals("Alice", header.agreementPartyUInfo) + assertEquals("Bob", header.agreementPartyVInfo) + } + + @Test + fun testJwePbes2Parameters() { + val header = JweHeader( + algorithm = JweKeyManagementAlgorithm.PBES2_HS256_A128KW, + encryptionAlgorithm = JweContentEncryptionAlgorithm.A128GCM, + pbes2SaltInput = "salt-input", + pbes2Count = 4096L + ) + + assertEquals(JweKeyManagementAlgorithm.PBES2_HS256_A128KW, header.algorithm) + assertEquals("salt-input", header.pbes2SaltInput) + assertEquals(4096L, header.pbes2Count) + } + + @Test + fun testJweAesGcmParameters() { + val header = JweHeader( + algorithm = JweKeyManagementAlgorithm.A256GCMKW, + encryptionAlgorithm = JweContentEncryptionAlgorithm.A256GCM, + initializationVector = "iv-value", + authenticationTag = "tag-value" + ) + + assertEquals(JweKeyManagementAlgorithm.A256GCMKW, header.algorithm) + assertEquals("iv-value", header.initializationVector) + assertEquals("tag-value", header.authenticationTag) + } +} \ No newline at end of file diff --git a/cryptography-serialization/jose/src/commonTest/kotlin/dev/whyoleg/cryptography/serialization/jose/JsonWebKeyTest.kt b/cryptography-serialization/jose/src/commonTest/kotlin/dev/whyoleg/cryptography/serialization/jose/JsonWebKeyTest.kt new file mode 100644 index 00000000..91515958 --- /dev/null +++ b/cryptography-serialization/jose/src/commonTest/kotlin/dev/whyoleg/cryptography/serialization/jose/JsonWebKeyTest.kt @@ -0,0 +1,209 @@ +/* + * Copyright (c) 2023-2025 Oleg Yukhnevich. Use of this source code is governed by the Apache 2.0 license. + */ + +package dev.whyoleg.cryptography.serialization.jose + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.test.assertFalse + +class JsonWebKeyTest { + + @Test + fun testRsaPublicKey() { + val jwk = RsaPublicJsonWebKey( + modulus = "test-modulus", + exponent = "AQAB", + keyUse = JwkKeyUse.SIGNATURE, + algorithm = JwsAlgorithm.RS256, + keyId = "rsa-public" + ) + + assertEquals(JwkKeyType.RSA, jwk.keyType) + assertEquals(JwkKeyUse.SIGNATURE, jwk.keyUse) + assertEquals(JwsAlgorithm.RS256, jwk.algorithm) + assertEquals("rsa-public", jwk.keyId) + assertTrue(jwk.isPublicKey) + assertFalse(jwk.isPrivateKey) + assertEquals("test-modulus", jwk.modulus) + assertEquals("AQAB", jwk.exponent) + } + + @Test + fun testRsaPrivateKey() { + val jwk = RsaPrivateJsonWebKey( + modulus = "test-modulus", + exponent = "AQAB", + privateExponent = "test-private-exponent", + keyUse = JwkKeyUse.SIGNATURE, + algorithm = JwsAlgorithm.RS256, + keyId = "rsa-private" + ) + + assertEquals(JwkKeyType.RSA, jwk.keyType) + assertFalse(jwk.isPublicKey) + assertTrue(jwk.isPrivateKey) + assertEquals("test-private-exponent", jwk.privateExponent) + assertEquals("test-modulus", jwk.modulus) + assertEquals("AQAB", jwk.exponent) + } + + @Test + fun testEcPublicKey() { + val jwk = EcPublicJsonWebKey( + curve = JwkEllipticCurve.P256, + xCoordinate = "test-x", + yCoordinate = "test-y", + keyUse = JwkKeyUse.SIGNATURE, + algorithm = JwsAlgorithm.ES256, + keyId = "ec-public" + ) + + assertEquals(JwkKeyType.EC, jwk.keyType) + assertTrue(jwk.isPublicKey) + assertFalse(jwk.isPrivateKey) + assertEquals(JwkEllipticCurve.P256, jwk.curve) + assertEquals("test-x", jwk.xCoordinate) + assertEquals("test-y", jwk.yCoordinate) + } + + @Test + fun testEcPrivateKey() { + val jwk = EcPrivateJsonWebKey( + curve = JwkEllipticCurve.P256, + xCoordinate = "test-x", + yCoordinate = "test-y", + privateKey = "test-private-key", + keyUse = JwkKeyUse.SIGNATURE, + algorithm = JwsAlgorithm.ES256, + keyId = "ec-private" + ) + + assertEquals(JwkKeyType.EC, jwk.keyType) + assertFalse(jwk.isPublicKey) + assertTrue(jwk.isPrivateKey) + assertEquals("test-private-key", jwk.privateKey) + } + + @Test + fun testSymmetricKey() { + val jwk = SymmetricJsonWebKey( + keyValue = "test-key-value", + keyUse = JwkKeyUse.SIGNATURE, + algorithm = JwsAlgorithm.HS256, + keyId = "symmetric" + ) + + assertEquals(JwkKeyType.SYMMETRIC, jwk.keyType) + assertFalse(jwk.isPublicKey) + assertTrue(jwk.isPrivateKey) + assertEquals("test-key-value", jwk.keyValue) + } + + @Test + fun testTypeChecking() { + val rsaPublic: JsonWebKey = RsaPublicJsonWebKey( + modulus = "test-modulus", + exponent = "AQAB" + ) + + val rsaPrivate: JsonWebKey = RsaPrivateJsonWebKey( + modulus = "test-modulus", + exponent = "AQAB", + privateExponent = "test-private" + ) + + val ecPublic: JsonWebKey = EcPublicJsonWebKey( + curve = JwkEllipticCurve.P256, + xCoordinate = "test-x", + yCoordinate = "test-y" + ) + + val symmetric: JsonWebKey = SymmetricJsonWebKey( + keyValue = "test-key" + ) + + // Test RSA type checking + assertTrue(rsaPublic is RsaJsonWebKey) + assertTrue(rsaPublic is RsaPublicJsonWebKey) + assertFalse(rsaPublic is RsaPrivateJsonWebKey) + + assertTrue(rsaPrivate is RsaJsonWebKey) + assertTrue(rsaPrivate is RsaPrivateJsonWebKey) + assertFalse(rsaPrivate is RsaPublicJsonWebKey) + + // Test EC type checking + assertTrue(ecPublic is EcJsonWebKey) + assertTrue(ecPublic is EcPublicJsonWebKey) + + // Test symmetric type checking + assertTrue(symmetric is SymmetricJsonWebKey) + assertFalse(symmetric is RsaJsonWebKey) + assertFalse(symmetric is EcJsonWebKey) + } + + @Test + fun testJwkSetOperations() { + val rsaKey = RsaPublicJsonWebKey( + modulus = "test-modulus", + exponent = "AQAB", + keyUse = JwkKeyUse.SIGNATURE, + algorithm = JwsAlgorithm.RS256, + keyId = "key-1" + ) + + val ecKey = EcPublicJsonWebKey( + curve = JwkEllipticCurve.P256, + xCoordinate = "test-x", + yCoordinate = "test-y", + keyUse = JwkKeyUse.ENCRYPTION, + algorithm = JwsAlgorithm.ES256, + keyId = "key-2" + ) + + val jwkSet = JsonWebKeySet(keys = listOf(rsaKey, ecKey)) + + // Test finding by key ID + val foundKey1 = jwkSet.findByKeyId("key-1") + assertNotNull(foundKey1) + assertEquals("key-1", foundKey1.keyId) + + val notFoundKey = jwkSet.findByKeyId("non-existent") + assertNull(notFoundKey) + + // Test finding by use + val signatureKeys = jwkSet.findByUse(JwkKeyUse.SIGNATURE) + assertEquals(1, signatureKeys.size) + assertEquals("key-1", signatureKeys.first().keyId) + + // Test finding by algorithm + val rs256Keys = jwkSet.findByAlgorithm(JwsAlgorithm.RS256) + assertEquals(1, rs256Keys.size) + assertEquals("key-1", rs256Keys.first().keyId) + + // Test finding by key type + val rsaKeys = jwkSet.findByKeyType(JwkKeyType.RSA) + assertEquals(1, rsaKeys.size) + assertEquals("key-1", rsaKeys.first().keyId) + + // Test finding public keys + val publicKeys = jwkSet.findPublicKeys() + assertEquals(2, publicKeys.size) + + // Test type-specific finders + val rsaKeysTyped = jwkSet.findRsaKeys() + assertEquals(1, rsaKeysTyped.size) + assertTrue(rsaKeysTyped.first() is RsaPublicJsonWebKey) + + val ecKeysTyped = jwkSet.findEcKeys() + assertEquals(1, ecKeysTyped.size) + assertTrue(ecKeysTyped.first() is EcPublicJsonWebKey) + + val symmetricKeys = jwkSet.findSymmetricKeys() + assertEquals(0, symmetricKeys.size) + } +} \ No newline at end of file diff --git a/cryptography-serialization/jose/src/commonTest/kotlin/dev/whyoleg/cryptography/serialization/jose/JsonWebSignatureTest.kt b/cryptography-serialization/jose/src/commonTest/kotlin/dev/whyoleg/cryptography/serialization/jose/JsonWebSignatureTest.kt new file mode 100644 index 00000000..90e1aff6 --- /dev/null +++ b/cryptography-serialization/jose/src/commonTest/kotlin/dev/whyoleg/cryptography/serialization/jose/JsonWebSignatureTest.kt @@ -0,0 +1,117 @@ +/* + * Copyright (c) 2023-2025 Oleg Yukhnevich. Use of this source code is governed by the Apache 2.0 license. + */ + +package dev.whyoleg.cryptography.serialization.jose + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class JsonWebSignatureTest { + + @Test + fun testJwsHeaderSerialization() { + val header = JwsHeader( + algorithm = JwsAlgorithm.RS256, + type = "JWT", + keyId = "test-key-id" + ) + + assertEquals(JwsAlgorithm.RS256, header.algorithm) + assertEquals("JWT", header.type) + assertEquals("test-key-id", header.keyId) + } + + @Test + fun testJwsCompactEncodeDecodeRoundTrip() { + val header = JwsHeader( + algorithm = JwsAlgorithm.HS256, + type = "JWT" + ) + val payload = "Test payload".toByteArray() + val signature = "Test signature".toByteArray() + + val original = JwsCompact(header, payload, signature) + val encoded = original.encode() + val decoded = JwsCompact.decode(encoded) + + assertEquals(original, decoded) + assertEquals(original.header.algorithm, decoded.header.algorithm) + assertEquals(original.header.type, decoded.header.type) + assertTrue(original.payload.contentEquals(decoded.payload)) + assertTrue(original.signature.contentEquals(decoded.signature)) + } + + @Test + fun testJwsCompactSigningInput() { + val header = JwsHeader(algorithm = JwsAlgorithm.HS256) + val payload = "Test payload".toByteArray() + val signature = "Test signature".toByteArray() + + val jws = JwsCompact(header, payload, signature) + val signingInput = jws.getSigningInput() + + assertTrue(signingInput.isNotEmpty()) + // Should contain header.payload without signature + val stringInput = signingInput.decodeToString() + assertTrue(stringInput.contains(".")) + assertFalse(stringInput.contains("..")) // Should not have double dots + } + + @Test + fun testJwsJsonFlattened() { + val header = JwsHeader(algorithm = JwsAlgorithm.RS256) + val payload = "Test payload".toByteArray() + val signature = "Test signature".toByteArray() + + val compact = JwsCompact(header, payload, signature) + val jsonFlattened = JwsJson.fromCompact(compact) + + assertTrue(jsonFlattened.isFlattened) + assertFalse(jsonFlattened.isGeneral) + + // Test conversion back to compact + val convertedBack = jsonFlattened.toCompact() + assertEquals(compact, convertedBack) + } + + @Test + fun testJwsJsonGeneral() { + val jwsJson = JwsJson( + payload = "dGVzdA", + signatures = listOf( + JwsSignature( + protectedHeader = "eyJhbGciOiJSUzI1NiJ9", + signature = "signature1" + ), + JwsSignature( + protectedHeader = "eyJhbGciOiJFUzI1NiJ9", + signature = "signature2" + ) + ) + ) + + assertFalse(jwsJson.isFlattened) + assertTrue(jwsJson.isGeneral) + assertEquals(2, jwsJson.signatures?.size) + } + + @Test + fun testJwsUnsigned() { + val header = JwsHeader(algorithm = JwsAlgorithm.NONE) + val payload = "Test payload".toByteArray() + val signature = byteArrayOf() // Empty signature for unsigned JWS + + val jws = JwsCompact(header, payload, signature) + val encoded = jws.encode() + + // Should end with a dot and empty signature part + assertTrue(encoded.endsWith(".")) + + val decoded = JwsCompact.decode(encoded) + assertEquals(JwsAlgorithm.NONE, decoded.header.algorithm) + assertTrue(decoded.signature.isEmpty()) + } +} \ No newline at end of file diff --git a/cryptography-serialization/jose/src/commonTest/kotlin/dev/whyoleg/cryptography/serialization/jose/JsonWebTokenTest.kt b/cryptography-serialization/jose/src/commonTest/kotlin/dev/whyoleg/cryptography/serialization/jose/JsonWebTokenTest.kt new file mode 100644 index 00000000..a5d288dc --- /dev/null +++ b/cryptography-serialization/jose/src/commonTest/kotlin/dev/whyoleg/cryptography/serialization/jose/JsonWebTokenTest.kt @@ -0,0 +1,110 @@ +/* + * Copyright (c) 2023-2025 Oleg Yukhnevich. Use of this source code is governed by the Apache 2.0 license. + */ + +package dev.whyoleg.cryptography.serialization.jose + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull + +class JsonWebTokenTest { + + @Test + fun testJwtCreation() { + val header = JwtHeader(algorithm = JwsAlgorithm.HS256) + val payload = JwtPayload( + issuer = "test-issuer", + subject = "test-subject", + audience = listOf("test-audience"), + expirationTime = System.currentTimeMillis() / 1000 + 3600 // 1 hour from now + ) + + val jwt = JsonWebToken(header = header, payload = payload) + + assertEquals(JwsAlgorithm.HS256, jwt.header.algorithm) + assertEquals("JWT", jwt.header.type) + assertEquals("test-issuer", jwt.payload.issuer) + assertEquals("test-subject", jwt.payload.subject) + assertEquals(listOf("test-audience"), jwt.payload.audience) + assertEquals("test-audience", jwt.payload.singleAudience) + assertNotNull(jwt.payload.expirationTime) + } + + @Test + fun testJwtHeaderDefaults() { + val header = JwtHeader(algorithm = JwsAlgorithm.RS256) + assertEquals("JWT", header.type) + } + + @Test + fun testJwtEncodeDecodeRoundTrip() { + val header = JwtHeader(algorithm = JwsAlgorithm.HS256, keyId = "test-key") + val payload = JwtPayload( + issuer = "test-issuer", + subject = "test-subject", + audience = listOf("test-audience"), + issuedAt = 1234567890, + expirationTime = 1234567890 + 3600, + jwtId = "test-jwt-id" + ) + + val originalJwt = JsonWebToken(header = header, payload = payload) + val encoded = originalJwt.encode() + val decoded = JsonWebToken.decode(encoded) + + assertEquals(originalJwt.header.algorithm, decoded.header.algorithm) + assertEquals(originalJwt.header.type, decoded.header.type) + assertEquals(originalJwt.header.keyId, decoded.header.keyId) + assertEquals(originalJwt.payload.issuer, decoded.payload.issuer) + assertEquals(originalJwt.payload.subject, decoded.payload.subject) + assertEquals(originalJwt.payload.audience, decoded.payload.audience) + assertEquals(originalJwt.payload.issuedAt, decoded.payload.issuedAt) + assertEquals(originalJwt.payload.expirationTime, decoded.payload.expirationTime) + assertEquals(originalJwt.payload.jwtId, decoded.payload.jwtId) + } + + @Test + fun testJwtPayloadValidation() { + val currentTime = System.currentTimeMillis() / 1000 + + // Test expired JWT + val expiredPayload = JwtPayload( + expirationTime = currentTime - 3600 // 1 hour ago + ) + assertTrue(expiredPayload.isExpired(currentTime)) + assertFalse(expiredPayload.isValid(currentTime)) + + // Test not yet valid JWT + val futurePayload = JwtPayload( + notBefore = currentTime + 3600 // 1 hour from now + ) + assertTrue(futurePayload.isNotYetValid(currentTime)) + assertFalse(futurePayload.isValid(currentTime)) + + // Test valid JWT + val validPayload = JwtPayload( + issuedAt = currentTime, + notBefore = currentTime - 60, // 1 minute ago + expirationTime = currentTime + 3600 // 1 hour from now + ) + assertFalse(validPayload.isExpired(currentTime)) + assertFalse(validPayload.isNotYetValid(currentTime)) + assertTrue(validPayload.isValid(currentTime)) + } + + @Test + fun testJwtPayloadMultipleAudiences() { + val payload = JwtPayload( + audience = listOf("audience1", "audience2", "audience3") + ) + + assertEquals(3, payload.audience?.size) + assertEquals(null, payload.singleAudience) // Should be null for multiple audiences + + val singleAudiencePayload = JwtPayload( + audience = listOf("single-audience") + ) + assertEquals("single-audience", singleAudiencePayload.singleAudience) + } +} \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index 083548fd..b611ff8e 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -41,6 +41,7 @@ projects("cryptography-kotlin") { module("asn1") { module("modules") } + module("jose") } // providers API, high-level API