Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 140 additions & 0 deletions source/JSONWebToken-Core-Tests/JWAES256Test.class.st
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
Class {
#name : 'JWAES256Test',
#superclass : 'TestCase',
#category : 'JSONWebToken-Core-Tests',
#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 [

| 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 := 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: [ 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' }
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 := JWAES256 signMessage: message withKey: privPem.
self assert: signature size equals: 64.

parts at: 3 put: (Base64UrlEncoder new encode: signature).
JWAES256 checkSignatureOfParts: parts withKey: pubPem
]
204 changes: 204 additions & 0 deletions source/JSONWebToken-Core/JWAES256.class.st
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
"
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.

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.
JWAES256 checkSignatureOfParts: { header . payload . signature } withKey: ecPublicPemKey.
```
"
Class {
#name : 'JWAES256',
#superclass : 'JsonWebAlgorithm',
#category : 'JSONWebToken-Core-Algorithms',
#package : 'JSONWebToken-Core',
#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: publicKeyPemString [

| jwtHeaderAndPayload signatureByteArray publicKey derSignature |
self assertPublicKeyPemString: publicKeyPemString.

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: publicKeyPemString.

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: privateKeyPemString [

| pkey derSig |
self assertPrivateKeyPemString: privateKeyPemString.

pkey := LcEvpPublicKey fromPrivateKeyPemString: privateKeyPemString.
message pinInMemory.
derSig := [ pkey digestSignMessage: message asByteArray ] ensure: [
message unpinInMemory ].

"OpenSSL returns DER format. ES256 requires 64 bytes (R | S)."
^ self derToRS: derSig
]