From dfbfe089e58e3b3eb6bad7ed9d8ccc201b9ac781 Mon Sep 17 00:00:00 2001 From: Tomas Kukol Date: Tue, 31 Mar 2026 10:15:41 +0200 Subject: [PATCH] Added support for ES256 (ECDSA with P-256 and SHA-256) JWT signing algorithm. --- .../JWAES256Test.class.st | 61 +++++++ source/JSONWebToken-Core/JWAES256.class.st | 157 ++++++++++++++++++ 2 files changed, 218 insertions(+) create mode 100644 source/JSONWebToken-Core-Tests/JWAES256Test.class.st create mode 100644 source/JSONWebToken-Core/JWAES256.class.st diff --git a/source/JSONWebToken-Core-Tests/JWAES256Test.class.st b/source/JSONWebToken-Core-Tests/JWAES256Test.class.st new file mode 100644 index 0000000..d228c30 --- /dev/null +++ b/source/JSONWebToken-Core-Tests/JWAES256Test.class.st @@ -0,0 +1,61 @@ +Class { + #name : 'JWAES256Test', + #superclass : 'TestCase', + #category : 'JSONWebToken-Core-Tests', + #package : 'JSONWebToken-Core-Tests' +} + +{ #category : 'tests' } +JWAES256Test >> testInvalidSignature [ + + | privPem pubPem message signature parts | + privPem := '-----BEGIN EC PRIVATE KEY----- +MHcCAQEEIJMAmRbBAEzALqgw+fnF1iPFRXfeQO/3kKzw0Fr0kiVGoAoGCCqGSM49 +AwEHoUQDQgAEAXizW3CKL9NeATTYgWuD7cE4s6F7Zsd10kEtEwclNb8dXqbx3x/O +x7uN34ptxuwIV4y6QmstfKDbIp9qlgDSfQ== +-----END EC PRIVATE KEY-----'. + pubPem := '-----BEGIN PUBLIC KEY----- +MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEAXizW3CKL9NeATTYgWuD7cE4s6F7 +Zsd10kEtEwclNb8dXqbx3x/Ox7uN34ptxuwIV4y6QmstfKDbIp9qlgDSfQ== +-----END PUBLIC KEY-----'. + + parts := { + (Base64UrlEncoder new encode: 'header' asByteArray). + (Base64UrlEncoder new encode: 'payload' asByteArray). + '' }. + message := parts first , '.' , parts second. + signature := LargoJWAES256 signMessage: message withKey: privPem. + + "Tamper with signature" + signature at: 1 put: (signature at: 1) + 1 \\ 256. + + parts at: 3 put: (Base64UrlEncoder new encode: signature). + + self should: [ LargoJWAES256 checkSignatureOfParts: parts withKey: pubPem ] raise: Error +] + +{ #category : 'tests' } +JWAES256Test >> testSignAndVerify [ + + | privPem pubPem message signature parts | + privPem := '-----BEGIN EC PRIVATE KEY----- +MHcCAQEEIJMAmRbBAEzALqgw+fnF1iPFRXfeQO/3kKzw0Fr0kiVGoAoGCCqGSM49 +AwEHoUQDQgAEAXizW3CKL9NeATTYgWuD7cE4s6F7Zsd10kEtEwclNb8dXqbx3x/O +x7uN34ptxuwIV4y6QmstfKDbIp9qlgDSfQ== +-----END EC PRIVATE KEY-----'. + pubPem := '-----BEGIN PUBLIC KEY----- +MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEAXizW3CKL9NeATTYgWuD7cE4s6F7 +Zsd10kEtEwclNb8dXqbx3x/Ox7uN34ptxuwIV4y6QmstfKDbIp9qlgDSfQ== +-----END PUBLIC KEY-----'. + + parts := { + (Base64UrlEncoder new encode: 'header' asByteArray). + (Base64UrlEncoder new encode: 'payload' asByteArray). + '' }. + message := parts first , '.' , parts second. + signature := LargoJWAES256 signMessage: message withKey: privPem. + self assert: signature size equals: 64. + + parts at: 3 put: (Base64UrlEncoder new encode: signature). + LargoJWAES256 checkSignatureOfParts: parts withKey: pubPem +] diff --git a/source/JSONWebToken-Core/JWAES256.class.st b/source/JSONWebToken-Core/JWAES256.class.st new file mode 100644 index 0000000..7cc5878 --- /dev/null +++ b/source/JSONWebToken-Core/JWAES256.class.st @@ -0,0 +1,157 @@ +" +I am an implementation of the ES256 (ECDSA with P-256 and SHA-256) JWT signing algorithm. + +I use the OpenSSL EVP interface through LcEvpPublicKey for signing and verification. +Since the ES256 standard requires the signature to be a 64-byte concatenation of R and S, and OpenSSL uses the DER format, I provide the necessary conversion logic. + +Example usage: +```pharo +signature := JWAES256 signMessage: 'message' withKey: ecPrivatePemKey. +JWAES256 checkSignatureOfParts: { header . payload . signature } withKey: ecPublicPemKey. +``` +" +Class { + #name : 'JWAES256', + #superclass : 'JsonWebAlgorithm', + #category : 'JSONWebToken-Core-Algorithms', + #package : 'JSONWebToken-Core', + #tag : 'Algorithms' +} + +{ #category : 'sign' } +JWAES256 class >> checkSignatureOfParts: parts withKey: key [ + + | jwtHeaderAndPayload signatureByteArray publicKey derSignature | + jwtHeaderAndPayload := $. join: { + parts first. + parts second }. + signatureByteArray := Base64UrlEncoder new decode: parts third base64Padded. + + "ES256 signature is 64 bytes (R | S). OpenSSL needs it in DER format." + derSignature := self rsToDer: signatureByteArray. + + publicKey := LcEvpPublicKey fromPublicKeyPemString: key. + + jwtHeaderAndPayload pinInMemory. + derSignature pinInMemory. + [ + (publicKey digestVerifyMessage: jwtHeaderAndPayload asByteArray with: derSignature) + ifFalse: [ Error signal: 'signature does not match' ] ] ensure: [ + jwtHeaderAndPayload unpinInMemory. + derSignature unpinInMemory ] +] + +{ #category : 'private' } +JWAES256 class >> copyInteger: source into: target startingAt: targetOffset [ + + | srcOffset len | + srcOffset := 1. + len := source size. + "Strip leading zeros if it makes it longer than 32" + [ len > 32 and: [ (source at: srcOffset) = 0 ] ] whileTrue: [ + srcOffset := srcOffset + 1. + len := len - 1 ]. + + "If still longer than 32, it's an error for ES256 (P-256)" + len > 32 ifTrue: [ Error signal: 'Integer too large for ES256' ]. + + "Copy and pad with leading zeros if needed" + target + replaceFrom: targetOffset + (32 - len) + to: targetOffset + 31 + with: source + startingAt: srcOffset +] + +{ #category : 'private' } +JWAES256 class >> derIntegerFor: aByteArray [ + + | firstByte srcOffset | + srcOffset := 1. + "Strip leading zeros" + [ srcOffset < aByteArray size and: [ (aByteArray at: srcOffset) = 0 ] ] whileTrue: [ + srcOffset := srcOffset + 1 ]. + + firstByte := aByteArray at: srcOffset. + firstByte > 127 ifTrue: [ + ^ #[ 0 ] , (aByteArray copyFrom: srcOffset to: aByteArray size) ]. + ^ aByteArray copyFrom: srcOffset to: aByteArray size +] + +{ #category : 'private' } +JWAES256 class >> derToRS: derSignature [ + + | r s offset lenR lenS rs | + "DER: 30 L 02 LR R 02 LS S" + (derSignature at: 1) = 16r30 ifFalse: [ Error signal: 'Invalid DER signature' ]. + offset := 3. + (derSignature at: offset) = 16r02 ifFalse: [ Error signal: 'Invalid DER signature (R)' ]. + lenR := derSignature at: offset + 1. + r := derSignature copyFrom: offset + 2 to: offset + 1 + lenR. + + offset := offset + 2 + lenR. + (derSignature at: offset) = 16r02 ifFalse: [ Error signal: 'Invalid DER signature (S)' ]. + lenS := derSignature at: offset + 1. + s := derSignature copyFrom: offset + 2 to: offset + 1 + lenS. + + rs := ByteArray new: 64. + "If R is > 32 bytes (leading zero), strip it. If < 32 bytes, pad it." + self copyInteger: r into: rs startingAt: 1. + self copyInteger: s into: rs startingAt: 33. + + ^ rs +] + +{ #category : 'accessing' } +JWAES256 class >> parameterValue [ + + ^ 'ES256' +] + +{ #category : 'private' } +JWAES256 class >> rsToDer: aByteArray [ + + | r s derR derS result offset | + r := aByteArray copyFrom: 1 to: 32. + s := aByteArray copyFrom: 33 to: 64. + + derR := self derIntegerFor: r. + derS := self derIntegerFor: s. + + result := ByteArray new: derR size + derS size + 6. + result at: 1 put: 16r30. "Sequence" + result at: 2 put: derR size + derS size + 4. + + offset := 3. + result at: offset put: 16r02. "Integer" + result at: offset + 1 put: derR size. + result + replaceFrom: offset + 2 + to: offset + 1 + derR size + with: derR + startingAt: 1. + + offset := offset + 2 + derR size. + result at: offset put: 16r02. "Integer" + result at: offset + 1 put: derS size. + result + replaceFrom: offset + 2 + to: offset + 1 + derS size + with: derS + startingAt: 1. + + ^ result +] + +{ #category : 'sign' } +JWAES256 class >> signMessage: message withKey: anObject [ + + | pkey derSig | + pkey := LcEvpPublicKey fromPrivateKeyPemString: anObject. + message pinInMemory. + derSig := [ pkey digestSignMessage: message asByteArray ] ensure: [ + message unpinInMemory ]. + + "OpenSSL returns DER format. ES256 requires 64 bytes (R | S)." + ^ self derToRS: derSig +]