Skip to content
Merged
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
71 changes: 71 additions & 0 deletions pkg/jose/jwk.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package jose

import (
"crypto"
"encoding/json"
"errors"
"os"
"path/filepath"

"github.com/golang-jwt/jwt/v5"
"github.com/lestrrat-go/jwx/v3/jwk"
)

// JWK is a JSON Web Key supporting EC and RSA key types
type JWK struct {
KTY string `json:"kty"`
// EC key fields
CRV string `json:"crv,omitempty"`
X string `json:"x,omitempty"`
Y string `json:"y,omitempty"`
// RSA key fields
N string `json:"n,omitempty"`
E string `json:"e,omitempty"`
}

// ParseSigningKey parses a private key from a PEM file (supports EC and RSA in various formats)
// Handles SEC1, PKCS1, and PKCS8 formats automatically.
func ParseSigningKey(signingKeyPath string) (crypto.PrivateKey, error) {
keyByte, err := os.ReadFile(filepath.Clean(signingKeyPath))
if err != nil {
return nil, err
}

// Try EC (handles SEC1 and PKCS8 formats)
if privateKey, err := jwt.ParseECPrivateKeyFromPEM(keyByte); err == nil {
return privateKey, nil
}

// Try RSA (handles PKCS1 and PKCS8 formats)
if privateKey, err := jwt.ParseRSAPrivateKeyFromPEM(keyByte); err == nil {
return privateKey, nil
}

return nil, errors.New("unsupported key type: expected EC or RSA private key in PEM format")
}

// CreateJWK creates a JWK from a signing key file (supports EC and RSA)
func CreateJWK(signingKeyPath string) (*JWK, crypto.PrivateKey, error) {
privateKey, err := ParseSigningKey(signingKeyPath)
if err != nil {
return nil, nil, err
}

key, err := jwk.Import(privateKey)
if err != nil {
return nil, nil, err
}

// Marshal to JSON and unmarshal to our JWK struct
jwkJSON, err := json.Marshal(key)
if err != nil {
return nil, nil, err
}

result := &JWK{}
if err := json.Unmarshal(jwkJSON, result); err != nil {
return nil, nil, err
}

return result, privateKey, nil
}
121 changes: 121 additions & 0 deletions pkg/jose/jwk_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
package jose

import (
"crypto/ecdsa"
"crypto/rsa"
"encoding/pem"
"os"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestParseSigningKey(t *testing.T) {
t.Run("parses EC key SEC1 format", func(t *testing.T) {
keyPath := createTestECKey(t)
key, err := ParseSigningKey(keyPath)
require.NoError(t, err)
assert.NotNil(t, key)
_, ok := key.(*ecdsa.PrivateKey)
assert.True(t, ok, "expected *ecdsa.PrivateKey")
})

t.Run("parses EC key PKCS8 format", func(t *testing.T) {
keyPath := createTestECKeyPKCS8(t)
key, err := ParseSigningKey(keyPath)
require.NoError(t, err)
assert.NotNil(t, key)
_, ok := key.(*ecdsa.PrivateKey)
assert.True(t, ok, "expected *ecdsa.PrivateKey")
})

t.Run("parses RSA key PKCS1 format (RSA PRIVATE KEY)", func(t *testing.T) {
keyPath := createTestRSAKey(t)

// Verify the key file has the expected PEM block type
keyBytes, err := os.ReadFile(keyPath)
require.NoError(t, err)
block, _ := pem.Decode(keyBytes)
require.NotNil(t, block)
assert.Equal(t, "RSA PRIVATE KEY", block.Type, "expected PKCS1 format with RSA PRIVATE KEY block type")

key, err := ParseSigningKey(keyPath)
require.NoError(t, err)
assert.NotNil(t, key)
_, ok := key.(*rsa.PrivateKey)
assert.True(t, ok, "expected *rsa.PrivateKey")
})

t.Run("parses RSA key PKCS8 format (PRIVATE KEY)", func(t *testing.T) {
keyPath := createTestRSAKeyPKCS8(t)

// Verify the key file has the expected PEM block type
keyBytes, err := os.ReadFile(keyPath)
require.NoError(t, err)
block, _ := pem.Decode(keyBytes)
require.NotNil(t, block)
assert.Equal(t, "PRIVATE KEY", block.Type, "expected PKCS8 format with PRIVATE KEY block type")

key, err := ParseSigningKey(keyPath)
require.NoError(t, err)
assert.NotNil(t, key)
_, ok := key.(*rsa.PrivateKey)
assert.True(t, ok, "expected *rsa.PrivateKey")
})

t.Run("returns error for non-existent file", func(t *testing.T) {
_, err := ParseSigningKey("/non/existent/path.pem")
assert.Error(t, err)
})

t.Run("returns error for invalid key", func(t *testing.T) {
keyPath := createInvalidKeyFile(t)
_, err := ParseSigningKey(keyPath)
assert.Error(t, err)
assert.Contains(t, err.Error(), "unsupported key type")
})
}

func TestCreateJWK(t *testing.T) {
t.Run("creates JWK from EC key", func(t *testing.T) {
keyPath := createTestECKey(t)

jwk, privateKey, err := CreateJWK(keyPath)
require.NoError(t, err)

assert.Equal(t, "EC", jwk.KTY)
assert.Equal(t, "P-256", jwk.CRV)
assert.NotEmpty(t, jwk.X)
assert.NotEmpty(t, jwk.Y)
assert.Empty(t, jwk.N)
assert.Empty(t, jwk.E)
assert.NotNil(t, privateKey)
})

t.Run("creates JWK from RSA key", func(t *testing.T) {
keyPath := createTestRSAKey(t)

jwk, privateKey, err := CreateJWK(keyPath)
require.NoError(t, err)

assert.Equal(t, "RSA", jwk.KTY)
assert.Empty(t, jwk.CRV)
assert.Empty(t, jwk.X)
assert.Empty(t, jwk.Y)
assert.NotEmpty(t, jwk.N)
assert.NotEmpty(t, jwk.E)
assert.NotNil(t, privateKey)
})

t.Run("returns error for non-existent file", func(t *testing.T) {
_, _, err := CreateJWK("/non/existent/path.pem")
assert.Error(t, err)
})

t.Run("returns error for invalid key", func(t *testing.T) {
keyPath := createInvalidKeyFile(t)
_, _, err := CreateJWK(keyPath)
assert.Error(t, err)
})
}
23 changes: 23 additions & 0 deletions pkg/jose/jwt.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package jose

import (
"maps"

"github.com/golang-jwt/jwt/v5"
)

// MakeJWT creates a signed JWT with the given header, body, signing method, and key.
// The header parameter is merged with default headers set by the signing method.
func MakeJWT(header, body jwt.MapClaims, signingMethod jwt.SigningMethod, signingKey any) (string, error) {
token := jwt.NewWithClaims(signingMethod, body)

// Merge provided header fields with defaults (provided values override defaults)
maps.Copy(token.Header, header)

signedToken, err := token.SignedString(signingKey)
if err != nil {
return "", err
}

return signedToken, nil
}
96 changes: 96 additions & 0 deletions pkg/jose/jwt_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
package jose

import (
"crypto/ecdsa"
"crypto/rsa"
"testing"

"github.com/golang-jwt/jwt/v5"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestMakeJWT(t *testing.T) {
t.Run("creates signed JWT with EC key", func(t *testing.T) {
keyPath := createTestECKey(t)

jwk, privateKey, err := CreateJWK(keyPath)
require.NoError(t, err)

ecKey := privateKey.(*ecdsa.PrivateKey)

header := jwt.MapClaims{
"alg": "ES256",
"typ": "openid4vci-proof+jwt",
"kid": "key-1",
}
body := jwt.MapClaims{
"iss": "joe",
"aud": "https://example.com",
"iat": 1300819380,
"nonce": "n-0S6_WzA2Mj",
"jwk": jwk,
}

signedToken, err := MakeJWT(header, body, jwt.SigningMethodES256, ecKey)
require.NoError(t, err)
assert.NotEmpty(t, signedToken)

// Verify the token can be parsed
token, err := jwt.Parse(signedToken, func(token *jwt.Token) (interface{}, error) {
return &ecKey.PublicKey, nil
})
require.NoError(t, err)
assert.True(t, token.Valid)
})

t.Run("creates signed JWT with RSA key", func(t *testing.T) {
keyPath := createTestRSAKey(t)

jwk, privateKey, err := CreateJWK(keyPath)
require.NoError(t, err)

rsaKey := privateKey.(*rsa.PrivateKey)

header := jwt.MapClaims{
"alg": "RS256",
"typ": "JWT",
"kid": "rsa-key-1",
}
body := jwt.MapClaims{
"iss": "joe",
"aud": "https://example.com",
"iat": 1300819380,
"nonce": "n-0S6_WzA2Mj",
"jwk": jwk,
}

signedToken, err := MakeJWT(header, body, jwt.SigningMethodRS256, rsaKey)
require.NoError(t, err)
assert.NotEmpty(t, signedToken)

// Verify the token can be parsed
token, err := jwt.Parse(signedToken, func(token *jwt.Token) (interface{}, error) {
return &rsaKey.PublicKey, nil
})
require.NoError(t, err)
assert.True(t, token.Valid)
})

t.Run("returns error for nil key", func(t *testing.T) {
header := jwt.MapClaims{"alg": "ES256"}
body := jwt.MapClaims{"iss": "test"}

_, err := MakeJWT(header, body, jwt.SigningMethodES256, nil)
assert.Error(t, err)
})

t.Run("returns error for wrong key type", func(t *testing.T) {
header := jwt.MapClaims{"alg": "ES256"}
body := jwt.MapClaims{"iss": "test"}

// Use a string instead of a key
_, err := MakeJWT(header, body, jwt.SigningMethodES256, "not-a-key")
assert.Error(t, err)
})
}
Loading
Loading