Skip to content
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

JWE Support #220

Closed
wants to merge 8 commits into from
Closed
Show file tree
Hide file tree
Changes from 7 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
92 changes: 92 additions & 0 deletions jwe/aesgcm.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
package jwe

import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"errors"
)

var (
ErrInvalidKeySize = errors.New("invalid key size")
ErrInvalidTagSize = errors.New("invalid tag size")
ErrInvalidNonceSize = errors.New("invalid nonce size")
ErrUnsupportedEncryptionType = errors.New("unsupported encryption type")
)

const TagSizeAESGCM = 16

type EncryptionType string

var A256GCM = EncryptionType("A256GCM")
ortyomka marked this conversation as resolved.
Show resolved Hide resolved

type cipherAESGCM struct {
keySize int
getAEAD func(key []byte) (cipher.AEAD, error)
}

func (ci cipherAESGCM) encrypt(key, aad, plaintext []byte) (iv []byte, ciphertext []byte, tag []byte, err error) {
if len(key) != ci.keySize {
return nil, nil, nil, ErrInvalidKeySize
}

aead, err := ci.getAEAD(key)
if err != nil {
return nil, nil, nil, err
}

iv = make([]byte, aead.NonceSize())
_, err = rand.Read(iv)
if err != nil {
return nil, nil, nil, err
}

res := aead.Seal(nil, iv, plaintext, aad)
tagIndex := len(res) - TagSizeAESGCM

return iv, res[:tagIndex], res[tagIndex:], nil
}

func (ci cipherAESGCM) decrypt(key, aad, iv []byte, ciphertext []byte, tag []byte) ([]byte, error) {
if len(key) != ci.keySize {
return nil, ErrInvalidKeySize
}

if len(tag) != TagSizeAESGCM {
return nil, ErrInvalidTagSize
}

aead, err := ci.getAEAD(key)
if err != nil {
return nil, err
}

if len(iv) != aead.NonceSize() {
return nil, ErrInvalidNonceSize
}

return aead.Open(nil, iv, append(ciphertext, tag...), aad)
}

func newAESGCM(keySize int) *cipherAESGCM {
return &cipherAESGCM{
keySize: keySize,
getAEAD: func(key []byte) (cipher.AEAD, error) {
aesCipher, err := aes.NewCipher(key)
if err != nil {
return nil, err
}

return cipher.NewGCM(aesCipher)
},
}
}

func getCipher(alg EncryptionType) (*cipherAESGCM, error) {
switch alg {
case A256GCM:
return newAESGCM(32), nil
default:
return nil, ErrUnsupportedEncryptionType
}
}
69 changes: 69 additions & 0 deletions jwe/jwe.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package jwe

import (
"encoding/base64"
"encoding/json"
"strings"
)

func NewJWE(alg KeyAlgorithm, key interface{}, method EncryptionType, plaintext []byte) (*jwe, error) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Documentation for exported function

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added documentation

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Plaintext is the JWT in compact form, correct?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any sensitive info. It can be JWT in compact form.

jwe := &jwe{}

jwe.protected = make(map[string]string)
jwe.protected["enc"] = string(method)
chipher, err := getCipher(method)
if err != nil {
return nil, err
}

// Generate a random Content Encryption Key (CEK).
cek, err := generateKey(chipher.keySize)
if err != nil {
return nil, err
}

// Encrypt the CEK with the recipient's public key to produce the JWE Encrypted Key.
jwe.protected["alg"] = string(alg)
jwe.recipientKey, err = encryptKey(key, cek, alg)
if err != nil {
return nil, err
}

// Serialize Authenticated Data
rawProtected, err := json.Marshal(jwe.protected)
if err != nil {
return nil, err
}
rawProtectedBase64 := base64.RawURLEncoding.EncodeToString(rawProtected)

// Perform authenticated encryption on the plaintext
jwe.iv, jwe.ciphertext, jwe.tag, err = chipher.encrypt(cek, []byte(rawProtectedBase64), plaintext)
if err != nil {
return nil, err
}

return jwe, nil
}

type jwe struct {
protected map[string]string
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it make sense to make this header a struct with specific values?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changed to struct

recipientKey []byte
iv []byte
ciphertext []byte
tag []byte
}

func (jwe *jwe) CompactSerialize() (string, error) {
rawProtected, err := json.Marshal(jwe.protected)
if err != nil {
return "", err
}

protected := base64.RawURLEncoding.EncodeToString(rawProtected)
encryptedKey := base64.RawURLEncoding.EncodeToString(jwe.recipientKey)
iv := base64.RawURLEncoding.EncodeToString(jwe.iv)
ciphertext := base64.RawURLEncoding.EncodeToString(jwe.ciphertext)
tag := base64.RawURLEncoding.EncodeToString(jwe.tag)

return strings.Join([]string{protected, encryptedKey, iv, ciphertext, tag}, "."), nil
}
41 changes: 41 additions & 0 deletions jwe/jwe_decrypt.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package jwe

import (
"encoding/base64"
"encoding/json"
"errors"
)

func (jwe jwe) Decrypt(key interface{}) ([]byte, error) {

method, ok := jwe.protected["enc"]
if !ok {
return nil, errors.New("no \"enc\" header")
}
cipher, err := getCipher(EncryptionType(method))
if err != nil {
return nil, err
}

alg, ok := jwe.protected["alg"]
if !ok {
return nil, errors.New("no \"alg\" header")
}
// Decrypt JWE Encrypted Key with the recipient's private key to produce CEK.
cek, err := decryptKey(key, jwe.recipientKey, KeyAlgorithm(alg))
if err != nil {
return nil, err
}

// Serialize Authenticated Data
rawProtected, err := json.Marshal(jwe.protected)
if err != nil {
return nil, err
}
rawProtectedBase64 := base64.RawURLEncoding.EncodeToString(rawProtected)

// Perform authenticated decryption on the ciphertext
data, err := cipher.decrypt(cek, []byte(rawProtectedBase64), jwe.iv, jwe.ciphertext, jwe.tag)

return data, err
}
60 changes: 60 additions & 0 deletions jwe/jwe_parse.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package jwe

import (
"encoding/base64"
"encoding/json"
"errors"
"strings"
)

func ParseEncrypted(input string) (*jwe, error) {

if strings.HasPrefix(input, "{") {
return nil, errors.New("don't support full JWE")
}

return parseEncryptedCompact(input)
}

func parseEncryptedCompact(input string) (*jwe, error) {
parts := strings.Split(input, ".")

if len(parts) != 5 {
return nil, errors.New("encrypted token contains an invalid number of segments")
}

jwe := &jwe{}

rawProtected, err := base64.RawURLEncoding.DecodeString(parts[0])
if err != nil {
return nil, err
}

if len(rawProtected) == 0 {
return nil, errors.New("protected headers are empty")
}

err = json.Unmarshal(rawProtected, &jwe.protected)
if err != nil {
return nil, errors.New("protected headers are not in JSON format")
}

jwe.recipientKey, err = base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return nil, err
}
jwe.iv, err = base64.RawURLEncoding.DecodeString(parts[2])
if err != nil {
return nil, err
}
jwe.ciphertext, err = base64.RawURLEncoding.DecodeString(parts[3])
if err != nil {
return nil, err
}
jwe.tag, err = base64.RawURLEncoding.DecodeString(parts[4])
if err != nil {
return nil, err
}

return jwe, nil
}
56 changes: 56 additions & 0 deletions jwe/jwe_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package jwe_test
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be nice if we could re-use example data from https://datatracker.ietf.org/doc/html/rfc7516#appendix-A

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reused, This required setting a random, so I made a global random reader similar to the crypto.rand package.
Here


import (
"fmt"
"github.com/golang-jwt/jwt/v4"
"github.com/golang-jwt/jwt/v4/jwe"
"os"
"testing"
)

func TestParseEncrypted(t *testing.T) {
originalToken := "eyJoZWFkZXIiOiJ2YWx1ZSJ9.ZW5jcnlwdGVkS2V5.aXY.Y2lwaGVydGV4dA.dGFn"

jweToken, err := jwe.ParseEncrypted(originalToken)

if err != nil {
t.Error(err)
return
}

rawToken, err := jweToken.CompactSerialize()
if err != nil {
t.Error(err)
return
}

if rawToken != originalToken {
t.Error(fmt.Errorf("tokens are different: %s != %s", rawToken, originalToken))
}
}

func TestLifeCycle(t *testing.T) {
keyData, _ := os.ReadFile("../test/sample_key.pub")
key, _ := jwt.ParseRSAPublicKeyFromPEM(keyData)

originalText := "The true sign of intelligence is not knowledge but imagination."
token, err := jwe.NewJWE(jwe.RSA_OAEP, key, jwe.A256GCM, []byte(originalText))

if err != nil {
t.Error(err)
return
}

privKeyData, _ := os.ReadFile("../test/sample_key")
privKey, _ := jwt.ParseRSAPrivateKeyFromPEM(privKeyData)

text, err := token.Decrypt(privKey)
if err != nil {
t.Error(err)
return
}

if string(text) != originalText {
t.Error(fmt.Errorf("texts are different: %s != %s", string(text), originalText))
}
}
14 changes: 14 additions & 0 deletions jwe/key_generator.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package jwe

import "crypto/rand"

func generateKey(keySize int) ([]byte, error) {
key := make([]byte, keySize)

_, err := rand.Read(key)
if err != nil {
return nil, err
}

return key, nil
}
53 changes: 53 additions & 0 deletions jwe/keycrypter.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package jwe

import (
"crypto/rand"
"crypto/rsa"
"crypto/sha1"
"errors"
)

var (
ErrUnsupportedKeyType = errors.New("unsupported key type")
ErrUnsupportedKeyAlgorithm = errors.New("unsupported key algorithm")
)

type KeyAlgorithm string

var RSA_OAEP = KeyAlgorithm("RSA-OAEP")
ortyomka marked this conversation as resolved.
Show resolved Hide resolved

func rsaEncrypt(key *rsa.PublicKey, cek []byte, alg KeyAlgorithm) ([]byte, error) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

any particular reason why you are using functions here instead of a struct with methods? Just wondering. The latter might be better if we want to make an interface out of it.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changed to struct with Encrypt and Decrypt. For the future

switch alg {
case RSA_OAEP:
return rsa.EncryptOAEP(sha1.New(), rand.Reader, key, cek, []byte{})
default:
return nil, ErrUnsupportedKeyAlgorithm
}
}

func rsaDecrypt(key *rsa.PrivateKey, encryptedKey []byte, alg KeyAlgorithm) ([]byte, error) {
switch alg {
case RSA_OAEP:
return rsa.DecryptOAEP(sha1.New(), rand.Reader, key, encryptedKey, []byte{})
default:
return nil, ErrUnsupportedKeyAlgorithm
}
}

func encryptKey(key interface{}, cek []byte, alg KeyAlgorithm) ([]byte, error) {
switch pbk := key.(type) {
case *rsa.PublicKey:
return rsaEncrypt(pbk, cek, alg)
default:
return nil, ErrUnsupportedKeyType
}
}

func decryptKey(key interface{}, encryptedKey []byte, alg KeyAlgorithm) ([]byte, error) {
switch pk := key.(type) {
case *rsa.PrivateKey:
return rsaDecrypt(pk, encryptedKey, alg)
default:
return nil, ErrUnsupportedKeyType
}
}