-
Notifications
You must be signed in to change notification settings - Fork 14
Added support for ES256 (ECDSA with P-256 and SHA-256) JWT signing algorithm. #34
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
tomaskukol
wants to merge
2
commits into
noha:master
Choose a base branch
from
tomaskukol:feature/support-for-jwa-es256
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+344
−0
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
| ] |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
| ] | ||
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I wouldn't put the check here. Providing an API for checking surely is a good idea even it just checks the string format not the actually cert. But this type of defensive programming raises costs of execution for everyone. This should be handled by the user code