fork of indigo with slightly nicer lexgen

basic JWK support in crypto SDK (#1058)

Basic JWK serialization of the curves we use for atproto crypto (P-256,
K-256), for public keys only. This will get used in OAuth
implementation.

The decision to implement this directly instead of using something like
https://github.com/lestrrat-go/jwx is entangled with deciding to use
`golang-jwt` for service auth implementation (which is distinct from
OAuth implementation, and doesn't directly require JWKs). I think the
secp256k1 experimental support in `jwx/jwt` would pull in a new
dependency/implementation, and I like our current choice, and it doesn't
seem as easy to wedge in a new curve type/implementation with `jwx`
family of packages.

On the other hand, I sure don't love parsing those uncompressed bytes
out in to `(x, y)` coordinates!

I thought about only exposing `[]byte` JSON instead of the `JWK` struct
itself, but feels more idiomatic and flexible to just expose the struct.
For example, will make it easier for calling code to deal with JWK sets
(arrays of keys, used in OAuth client metadata).

authored by bnewbold.net and committed by GitHub 236dd575 f8de501b

Changed files
+225
atproto
+124
atproto/crypto/jwk.go
··· 1 + package crypto 2 + 3 + import ( 4 + "crypto/ecdsa" 5 + "crypto/elliptic" 6 + "encoding/base64" 7 + "encoding/json" 8 + "fmt" 9 + "math/big" 10 + 11 + secp256k1 "gitlab.com/yawning/secp256k1-voi" 12 + secp256k1secec "gitlab.com/yawning/secp256k1-voi/secec" 13 + ) 14 + 15 + // Representation of a JSON Web Key (JWK), as relevant to the keys supported by this package. 16 + // 17 + // Expected to be marshalled/unmarshalled as JSON. 18 + type JWK struct { 19 + KeyType string `json:"kty"` 20 + Curve string `json:"crv"` 21 + X string `json:"x"` // base64url, no padding 22 + Y string `json:"y"` // base64url, no padding 23 + Use string `json:"use,omitempty"` 24 + KeyID *string `json:"kid,omitempty"` 25 + } 26 + 27 + // Loads a [PublicKey] from JWK (serialized as JSON bytes) 28 + func ParsePublicJWKBytes(jwkBytes []byte) (PublicKey, error) { 29 + var jwk JWK 30 + if err := json.Unmarshal(jwkBytes, &jwk); err != nil { 31 + return nil, fmt.Errorf("parsing JWK JSON: %w", err) 32 + } 33 + return ParsePublicJWK(jwk) 34 + } 35 + 36 + // Loads a [PublicKey] from JWK struct. 37 + func ParsePublicJWK(jwk JWK) (PublicKey, error) { 38 + 39 + if jwk.KeyType != "EC" { 40 + return nil, fmt.Errorf("unsupported JWK key type: %s", jwk.KeyType) 41 + } 42 + 43 + // base64url with no encoding 44 + xbuf, err := base64.RawURLEncoding.DecodeString(jwk.X) 45 + if err != nil { 46 + return nil, fmt.Errorf("invalid JWK base64 encoding: %w", err) 47 + } 48 + ybuf, err := base64.RawURLEncoding.DecodeString(jwk.Y) 49 + if err != nil { 50 + return nil, fmt.Errorf("invalid JWK base64 encoding: %w", err) 51 + } 52 + 53 + switch jwk.Curve { 54 + case "P-256": 55 + curve := elliptic.P256() 56 + 57 + var x, y big.Int 58 + x.SetBytes(xbuf) 59 + y.SetBytes(ybuf) 60 + 61 + if !curve.Params().IsOnCurve(&x, &y) { 62 + return nil, fmt.Errorf("invalid P-256 public key (not on curve)") 63 + } 64 + pubECDSA := &ecdsa.PublicKey{ 65 + Curve: curve, 66 + X: &x, 67 + Y: &y, 68 + } 69 + pub := PublicKeyP256{pubP256: *pubECDSA} 70 + err := pub.checkCurve() 71 + if err != nil { 72 + return nil, err 73 + } 74 + return &pub, nil 75 + case "secp256k1": // K-256 76 + if len(xbuf) != 32 || len(ybuf) != 32 { 77 + return nil, fmt.Errorf("invalid K-256 coordinates") 78 + } 79 + xarr := ([32]byte)(xbuf[:32]) 80 + yarr := ([32]byte)(ybuf[:32]) 81 + p, err := secp256k1.NewPointFromCoords(&xarr, &yarr) 82 + if err != nil { 83 + return nil, fmt.Errorf("invalid K-256 coordinates: %w", err) 84 + } 85 + pubK, err := secp256k1secec.NewPublicKeyFromPoint(p) 86 + if err != nil { 87 + return nil, fmt.Errorf("invalid K-256/secp256k1 public key: %w", err) 88 + } 89 + pub := PublicKeyK256{pubK256: pubK} 90 + err = pub.ensureBytes() 91 + if err != nil { 92 + return nil, err 93 + } 94 + return &pub, nil 95 + default: 96 + return nil, fmt.Errorf("unsupported JWK cryptography: %s", jwk.Curve) 97 + } 98 + } 99 + 100 + func (k *PublicKeyP256) JWK() (*JWK, error) { 101 + jwk := JWK{ 102 + KeyType: "EC", 103 + Curve: "P-256", 104 + X: base64.RawURLEncoding.EncodeToString(k.pubP256.X.Bytes()), 105 + Y: base64.RawURLEncoding.EncodeToString(k.pubP256.Y.Bytes()), 106 + } 107 + return &jwk, nil 108 + } 109 + 110 + func (k *PublicKeyK256) JWK() (*JWK, error) { 111 + raw := k.UncompressedBytes() 112 + if len(raw) != 65 { 113 + return nil, fmt.Errorf("unexpected K-256 bytes size") 114 + } 115 + xbytes := raw[1:33] 116 + ybytes := raw[33:65] 117 + jwk := JWK{ 118 + KeyType: "EC", 119 + Curve: "secp256k1", 120 + X: base64.RawURLEncoding.EncodeToString(xbytes), 121 + Y: base64.RawURLEncoding.EncodeToString(ybytes), 122 + } 123 + return &jwk, nil 124 + }
+98
atproto/crypto/jwk_test.go
··· 1 + package crypto 2 + 3 + import ( 4 + "testing" 5 + 6 + "github.com/stretchr/testify/assert" 7 + ) 8 + 9 + func TestParseJWK(t *testing.T) { 10 + assert := assert.New(t) 11 + 12 + jwkTestFixtures := []string{ 13 + // https://datatracker.ietf.org/doc/html/rfc7517#appendix-A.1 14 + `{ 15 + "kty":"EC", 16 + "crv":"P-256", 17 + "x":"MKBCTNIcKUSDii11ySs3526iDZ8AiTo7Tu6KPAqv7D4", 18 + "y":"4Etl6SRW2YiLUrN5vfvVHuhp7x8PxltmWWlbbM4IFyM", 19 + "d":"870MB6gfuTJ4HtUnUvYMyJpr5eUZNP4Bk43bVdj3eAE", 20 + "use":"enc", 21 + "kid":"1" 22 + }`, 23 + // https://openid.net/specs/draft-jones-json-web-key-03.html; with kty in addition to alg 24 + `{ 25 + "alg":"EC", 26 + "kty":"EC", 27 + "crv":"P-256", 28 + "x":"MKBCTNIcKUSDii11ySs3526iDZ8AiTo7Tu6KPAqv7D4", 29 + "y":"4Etl6SRW2YiLUrN5vfvVHuhp7x8PxltmWWlbbM4IFyM", 30 + "use":"enc", 31 + "kid":"1" 32 + }`, 33 + // https://w3c-ccg.github.io/lds-ecdsa-secp256k1-2019/; with kty in addition to alg 34 + `{ 35 + "alg": "EC", 36 + "kty": "EC", 37 + "crv": "secp256k1", 38 + "kid": "JUvpllMEYUZ2joO59UNui_XYDqxVqiFLLAJ8klWuPBw", 39 + "x": "dWCvM4fTdeM0KmloF57zxtBPXTOythHPMm1HCLrdd3A", 40 + "y": "36uMVGM7hnw-N6GnjFcihWE3SkrhMLzzLCdPMXPEXlA" 41 + }`, 42 + } 43 + 44 + for _, jwkBytes := range jwkTestFixtures { 45 + _, err := ParsePublicJWKBytes([]byte(jwkBytes)) 46 + assert.NoError(err) 47 + } 48 + } 49 + 50 + func TestP256GenJWK(t *testing.T) { 51 + assert := assert.New(t) 52 + 53 + priv, err := GeneratePrivateKeyP256() 54 + if err != nil { 55 + t.Fatal(err) 56 + } 57 + pub, err := priv.PublicKey() 58 + if err != nil { 59 + t.Fatal(err) 60 + } 61 + 62 + pk, ok := pub.(*PublicKeyP256) 63 + if !ok { 64 + t.Fatal() 65 + } 66 + jwk, err := pk.JWK() 67 + if err != nil { 68 + t.Fatal(err) 69 + } 70 + 71 + _, err = ParsePublicJWK(*jwk) 72 + assert.NoError(err) 73 + } 74 + 75 + func TestK256GenJWK(t *testing.T) { 76 + assert := assert.New(t) 77 + 78 + priv, err := GeneratePrivateKeyK256() 79 + if err != nil { 80 + t.Fatal(err) 81 + } 82 + pub, err := priv.PublicKey() 83 + if err != nil { 84 + t.Fatal(err) 85 + } 86 + 87 + pk, ok := pub.(*PublicKeyK256) 88 + if !ok { 89 + t.Fatal() 90 + } 91 + jwk, err := pk.JWK() 92 + if err != nil { 93 + t.Fatal(err) 94 + } 95 + 96 + _, err = ParsePublicJWK(*jwk) 97 + assert.NoError(err) 98 + }
+3
atproto/crypto/keys.go
··· 64 64 // For systems with no compressed/uncompressed distinction, returns the same 65 65 // value as Bytes(). 66 66 UncompressedBytes() []byte 67 + 68 + // Serialization as JWK struct (which can be marshalled to JSON) 69 + JWK() (*JWK, error) 67 70 } 68 71 69 72 var ErrInvalidSignature = errors.New("crytographic signature invalid")