From dfbfe089e58e3b3eb6bad7ed9d8ccc201b9ac781 Mon Sep 17 00:00:00 2001 From: Tomas Kukol Date: Tue, 31 Mar 2026 10:15:41 +0200 Subject: [PATCH 1/2] 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 +] From da435fe2de0eb88f67b7d8faf1ba14bdd6f4ed38 Mon Sep 17 00:00:00 2001 From: Tomas Kukol Date: Wed, 8 Apr 2026 11:38:36 +0200 Subject: [PATCH 2/2] Added assertions for private PEM key and public PEM key strings as LcEvpPublicKey is very sensitive to correct keys and can crash Pharo, fixed failing tests. --- .../JWAES256Test.class.st | 87 ++++++++++++++++++- source/JSONWebToken-Core/JWAES256.class.st | 57 ++++++++++-- 2 files changed, 135 insertions(+), 9 deletions(-) diff --git a/source/JSONWebToken-Core-Tests/JWAES256Test.class.st b/source/JSONWebToken-Core-Tests/JWAES256Test.class.st index d228c30..dbf1ee6 100644 --- a/source/JSONWebToken-Core-Tests/JWAES256Test.class.st +++ b/source/JSONWebToken-Core-Tests/JWAES256Test.class.st @@ -5,6 +5,64 @@ Class { #package : 'JSONWebToken-Core-Tests' } +{ #category : 'test data' } +JWAES256Test >> getPayloadDictionary [ + + ^ { + ('sub' -> '1234567890'). + ('name' -> 'ES256 Test User'). + ('iat' -> 1775048930). + ('exp' -> 1775052530). + ('test_purpose' -> 'ES256 signing algorithm test') } asOrderedDictionary +] + +{ #category : 'test data' } +JWAES256Test >> getPemPrivateKey [ + + ^ '-----BEGIN EC PRIVATE KEY----- +MHcCAQEEIH9/APMoYm1tmblseTbersZ/0jbz4KoLFgYCA8kvU9tPoAoGCCqGSM49AwEHoUQDQgAE +5qbhpA+LJ+nDC4SrdOpQna193C6fmqJJzYnYCQJ6VL9I8mXceQ/RiZEZXmDK2HFACLxiwKp6nCgM +Hhhn/OMf9g== +-----END EC PRIVATE KEY-----' +] + +{ #category : 'test data' } +JWAES256Test >> getPemPublicKey [ + + ^ '-----BEGIN PUBLIC KEY----- +MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE5qbhpA+LJ+nDC4SrdOpQna193C6fmqJJzYnYCQJ6 +VL9I8mXceQ/RiZEZXmDK2HFACLxiwKp6nCgMHhhn/OMf9g== +-----END PUBLIC KEY-----' +] + +{ #category : 'tests' } +JWAES256Test >> testAssertPemStrings [ + + | privateKey publicKey | + privateKey := self getPemPrivateKey. + publicKey := self getPemPublicKey. + + "Valid keys should not signal anything" + JWAES256 assertPrivateKeyPemString: privateKey. + JWAES256 assertPublicKeyPemString: publicKey. + + "Keys with whitespace should now be accepted because we trim them" + JWAES256 assertPrivateKeyPemString: privateKey , ' '. + JWAES256 assertPrivateKeyPemString: ' ' , privateKey. + JWAES256 assertPublicKeyPemString: publicKey , ' '. + JWAES256 assertPublicKeyPemString: ' ' , publicKey. + + "Invalid headers" + self + should: [ JWAES256 assertPrivateKeyPemString: '-----BEGIN WRONG KEY-----' ] + raise: AssertionFailure. + + "Nil and empty" + self should: [ JWAES256 assertPrivateKeyPemString: nil ] raise: AssertionFailure. + self should: [ JWAES256 assertPrivateKeyPemString: '' ] raise: AssertionFailure. + self should: [ JWAES256 assertPrivateKeyPemString: 123 ] raise: AssertionFailure +] + { #category : 'tests' } JWAES256Test >> testInvalidSignature [ @@ -24,14 +82,35 @@ Zsd10kEtEwclNb8dXqbx3x/Ox7uN34ptxuwIV4y6QmstfKDbIp9qlgDSfQ== (Base64UrlEncoder new encode: 'payload' asByteArray). '' }. message := parts first , '.' , parts second. - signature := LargoJWAES256 signMessage: message withKey: privPem. + signature := JWAES256 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 + self should: [ JWAES256 checkSignatureOfParts: parts withKey: pubPem ] raise: Error +] + +{ #category : 'tests' } +JWAES256Test >> testSerializeAndDeserialize [ + + | jws tokenString dic | + jws := JsonWebSignature new + algorithm: JWAES256; + payload: JWTClaimsSet new; + yourself. + jws header typ: 'JWT'. + jws key: self getPemPrivateKey. + jws payload setClaims: self getPayloadDictionary. + tokenString := jws compactSerialized. + + jws := JsonWebSignature + materializeCompact: tokenString + key: self getPemPublicKey + checkSignature: true. + dic := self getPayloadDictionary. + self assert: (jws payload hasSameClaims: dic) ] { #category : 'tests' } @@ -53,9 +132,9 @@ Zsd10kEtEwclNb8dXqbx3x/Ox7uN34ptxuwIV4y6QmstfKDbIp9qlgDSfQ== (Base64UrlEncoder new encode: 'payload' asByteArray). '' }. message := parts first , '.' , parts second. - signature := LargoJWAES256 signMessage: message withKey: privPem. + signature := JWAES256 signMessage: message withKey: privPem. self assert: signature size equals: 64. parts at: 3 put: (Base64UrlEncoder new encode: signature). - LargoJWAES256 checkSignatureOfParts: parts withKey: pubPem + JWAES256 checkSignatureOfParts: parts withKey: pubPem ] diff --git a/source/JSONWebToken-Core/JWAES256.class.st b/source/JSONWebToken-Core/JWAES256.class.st index 7cc5878..fab707b 100644 --- a/source/JSONWebToken-Core/JWAES256.class.st +++ b/source/JSONWebToken-Core/JWAES256.class.st @@ -1,9 +1,11 @@ " 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. +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. +I provide robust assertions for public and private PEM key strings, supporting both PKCS#1 and PKCS#8 formats for private keys, and ensuring proper formatting by trimming whitespace. + Example usage: ```pharo signature := JWAES256 signMessage: 'message' withKey: ecPrivatePemKey. @@ -18,10 +20,53 @@ Class { #tag : 'Algorithms' } +{ #category : 'private' } +JWAES256 class >> assertPemString: aPemString beginsWithAny: headers endsWithAny: footers description: aDescription [ + + | trimmed | + aPemString ifNil: [ AssertionFailure signal: aDescription , ' is nil' ]. + + aPemString isString ifFalse: [ + AssertionFailure signal: aDescription , ' is not a string' ]. + + trimmed := aPemString trim. + + headers with: footers do: [ :header :footer | + ((trimmed beginsWith: header) and: [ trimmed endsWith: footer ]) ifTrue: [ + trimmed size < 100 ifTrue: [ + AssertionFailure signal: 'Not a valid ' , aDescription , ': content is too short' ]. + ^ self ] ]. + + AssertionFailure signal: + 'Not a valid ' , aDescription , ': missing or mismatched PEM headers' +] + +{ #category : 'asserting' } +JWAES256 class >> assertPrivateKeyPemString: aPrivateKeyPemString [ + + self + assertPemString: aPrivateKeyPemString + beginsWithAny: { '-----BEGIN EC PRIVATE KEY-----'. '-----BEGIN PRIVATE KEY-----' } + endsWithAny: { '-----END EC PRIVATE KEY-----'. '-----END PRIVATE KEY-----' } + description: 'EC private key' +] + +{ #category : 'asserting' } +JWAES256 class >> assertPublicKeyPemString: aPublicKeyPemString [ + + self + assertPemString: aPublicKeyPemString + beginsWithAny: { '-----BEGIN PUBLIC KEY-----' } + endsWithAny: { '-----END PUBLIC KEY-----' } + description: 'EC public key' +] + { #category : 'sign' } -JWAES256 class >> checkSignatureOfParts: parts withKey: key [ +JWAES256 class >> checkSignatureOfParts: parts withKey: publicKeyPemString [ | jwtHeaderAndPayload signatureByteArray publicKey derSignature | + self assertPublicKeyPemString: publicKeyPemString. + jwtHeaderAndPayload := $. join: { parts first. parts second }. @@ -30,7 +75,7 @@ JWAES256 class >> checkSignatureOfParts: parts withKey: key [ "ES256 signature is 64 bytes (R | S). OpenSSL needs it in DER format." derSignature := self rsToDer: signatureByteArray. - publicKey := LcEvpPublicKey fromPublicKeyPemString: key. + publicKey := LcEvpPublicKey fromPublicKeyPemString: publicKeyPemString. jwtHeaderAndPayload pinInMemory. derSignature pinInMemory. @@ -144,10 +189,12 @@ JWAES256 class >> rsToDer: aByteArray [ ] { #category : 'sign' } -JWAES256 class >> signMessage: message withKey: anObject [ +JWAES256 class >> signMessage: message withKey: privateKeyPemString [ | pkey derSig | - pkey := LcEvpPublicKey fromPrivateKeyPemString: anObject. + self assertPrivateKeyPemString: privateKeyPemString. + + pkey := LcEvpPublicKey fromPrivateKeyPemString: privateKeyPemString. message pinInMemory. derSig := [ pkey digestSignMessage: message asByteArray ] ensure: [ message unpinInMemory ].