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

Implementation of did:jwk #363

Merged
merged 7 commits into from
May 1, 2023
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
1 change: 0 additions & 1 deletion did/context/did-pkh-context-deref.json

This file was deleted.

1 change: 1 addition & 0 deletions did/did.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 10,7 @@ const (
PKHMethod Method = "pkh"
WebMethod Method = "web"
IONMethod Method = "ion"
JWKMethod Method = "jwk"
)

func (m Method) String() string {
Expand Down
179 changes: 179 additions & 0 deletions did/jwk.go
Original file line number Diff line number Diff line change
@@ -0,0 1,179 @@
package did

import (
"context"
gocrypto "crypto"
"encoding/base64"
"fmt"
"strings"

"github.com/TBD54566975/ssi-sdk/crypto"
"github.com/TBD54566975/ssi-sdk/cryptosuite"
"github.com/goccy/go-json"
"github.com/lestrrat-go/jwx/v2/jwk"
"github.com/pkg/errors"
)

type (
DIDJWK string
)

const (
// JWKPrefix did:jwk prefix
JWKPrefix = "did:jwk"
JWS2020Context = "https://w3id.org/security/suites/jws-2020/v1"
)

func (d DIDJWK) IsValid() bool {
_, err := d.Expand()
return err == nil
}

func (d DIDJWK) String() string {
return string(d)
}

// Suffix returns the value without the `did:jwk` prefix
func (d DIDJWK) Suffix() (string, error) {
if suffix, ok := strings.CutPrefix(string(d), JWKPrefix ":"); ok {
return suffix, nil
}
return "", fmt.Errorf("invalid did:jwk: %s", d)
}

func (DIDJWK) Method() Method {
return JWKMethod
}

// GenerateDIDJWK takes in a key type value that this library supports and constructs a conformant did:jwk identifier.
func GenerateDIDJWK(kt crypto.KeyType) (gocrypto.PrivateKey, *DIDJWK, error) {
if !isSupportedJWKType(kt) {
return nil, nil, fmt.Errorf("unsupported did:jwk type: %s", kt)
}

// 1. Generate a JWK
pubKey, privKey, err := crypto.GenerateKeyByKeyType(kt)
if err != nil {
return nil, nil, errors.Wrap(err, "generating key for did:jwk")
}
pubKeyJWK, err := crypto.PublicKeyToJWK(pubKey)
if err != nil {
return nil, nil, errors.Wrap(err, "converting public key to JWK")
}

// 2. Serialize it into a UTF-8 string
// 3. Encode string using base64url
// 4. Prepend the string with the did:jwk prefix
didJWK, err := CreateDIDJWK(pubKeyJWK)
if err != nil {
return nil, nil, errors.Wrap(err, "creating did:jwk")
}
return privKey, didJWK, nil
}

// CreateDIDJWK creates a did:jwk from a JWK public key by following the steps in the spec:
// https://github.com/quartzjer/did-jwk/blob/main/spec.md
func CreateDIDJWK(publicKeyJWK jwk.Key) (*DIDJWK, error) {
// 2. Serialize it into a UTF-8 string
pubKeyJWKBytes, err := json.Marshal(publicKeyJWK)
if err != nil {
return nil, errors.Wrap(err, "marshalling public key JWK")
}
pubKeyJWKStr := string(pubKeyJWKBytes)

// 3. Encode string using base64url
encodedPubKeyJWKStr := base64.RawURLEncoding.EncodeToString([]byte(pubKeyJWKStr))

// 4. Prepend the string with the did:jwk prefix
didJWK := DIDJWK(fmt.Sprintf("%s:%s", JWKPrefix, encodedPubKeyJWKStr))
return &didJWK, nil
}

// Expand turns the DID JWK into a compliant DID Document
func (d DIDJWK) Expand() (*Document, error) {
id := d.String()

if !strings.HasPrefix(id, JWKPrefix) {
return nil, fmt.Errorf("not a did:jwk DID, invalid prefix: %s", id)
}

encodedJWK, err := d.Suffix()
if err != nil {
return nil, errors.Wrap(err, "reading suffix")
}
decodedPubKeyJWKStr, err := base64.RawURLEncoding.DecodeString(encodedJWK)
if err != nil {
return nil, errors.Wrap(err, "decoding did:jwk")
}

var pubKeyJWK crypto.PublicKeyJWK
if err = json.Unmarshal(decodedPubKeyJWKStr, &pubKeyJWK); err != nil {
return nil, errors.Wrap(err, "unmarshalling did:jwk")
}

keyReference := "#0"
keyID := id keyReference

doc := Document{
Context: []string{KnownDIDContext, JWS2020Context},
ID: id,
VerificationMethod: []VerificationMethod{
{
ID: keyID,
Type: cryptosuite.JSONWebKey2020Type,
Controller: id,
PublicKeyJWK: &pubKeyJWK,
},
},
Authentication: []VerificationMethodSet{keyID},
AssertionMethod: []VerificationMethodSet{keyID},
KeyAgreement: []VerificationMethodSet{keyID},
CapabilityInvocation: []VerificationMethodSet{keyID},
CapabilityDelegation: []VerificationMethodSet{keyID},
}

// If the JWK contains a use property with the value "sig" then the keyAgreement property is not included in the
// DID Document. If the use value is "enc" then only the keyAgreement property is included in the DID Document.
switch pubKeyJWK.Use {
case "sig":
doc.KeyAgreement = nil
case "enc":
doc.Authentication = nil
doc.AssertionMethod = nil
doc.CapabilityInvocation = nil
doc.CapabilityDelegation = nil
}

return &doc, nil
}

func isSupportedJWKType(kt crypto.KeyType) bool {
jwkTypes := GetSupportedDIDJWKTypes()
for _, t := range jwkTypes {
if t == kt {
return true
}
}
return false
}
Comment on lines 150 to 158
Copy link
Contributor

Choose a reason for hiding this comment

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

Seem like this is the same as crypto.IsSupportedKeyType. Is it possible to DRY this up?

Copy link
Member Author

Choose a reason for hiding this comment

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

it happens to overlap but it's a distinct method since there's no guarantee we enable all supported key types for did key and DID JWK. for example, did:jwk can support any JWK type. did:key only supports what's in the spec.


func GetSupportedDIDJWKTypes() []crypto.KeyType {
Copy link
Contributor

Choose a reason for hiding this comment

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

Should this function call crypto.GetSupportedKeyTypes?

Copy link
Member Author

Choose a reason for hiding this comment

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

return []crypto.KeyType{crypto.Ed25519, crypto.X25519, crypto.SECP256k1, crypto.P256, crypto.P384, crypto.P521, crypto.RSA}
}

type JWKResolver struct{}

var _ Resolver = (*JWKResolver)(nil)

func (JWKResolver) Resolve(_ context.Context, did string, _ ...ResolutionOption) (*ResolutionResult, error) {
didJWK := DIDJWK(did)
doc, err := didJWK.Expand()
if err != nil {
return nil, errors.Wrap(err, "expanding did:jwk")
}
return &ResolutionResult{Document: *doc}, nil
Copy link
Contributor

Choose a reason for hiding this comment

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

Should this have more fields populated, according to https://w3c-ccg.github.io/did-resolution/#did-resolution-result ?

Copy link
Member Author

Choose a reason for hiding this comment

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

it should but I'd rather take this separately for all the resolvers. Updated this issue to include it: #331

}

func (JWKResolver) Methods() []Method {
return []Method{JWKMethod}
}
195 changes: 195 additions & 0 deletions did/jwk_test.go
Original file line number Diff line number Diff line change
@@ -0,0 1,195 @@
package did

import (
"context"
"embed"
"strings"
"testing"

"github.com/TBD54566975/ssi-sdk/crypto"
"github.com/TBD54566975/ssi-sdk/cryptosuite"
"github.com/goccy/go-json"
"github.com/lestrrat-go/jwx/v2/jwk"
"github.com/stretchr/testify/assert"
)

const (
P256Vector string = "did-jwk-p256.json"
X25519Vector string = "did-jwk-x25519.json"
)

var (
//go:embed testdata
jwkTestVectors embed.FS
jwkVectors = []string{P256Vector, X25519Vector}
)

// from https://github.com/quartzjer/did-jwk/blob/main/spec.md#examples
func TestDIDJWKVectors(t *testing.T) {
t.Run("P-256", func(tt *testing.T) {
did := "did:jwk:eyJjcnYiOiJQLTI1NiIsImt0eSI6IkVDIiwieCI6ImFjYklRaXVNczNpOF91c3pFakoydHBUdFJNNEVVM3l6OTFQSDZDZEgyVjAiLCJ5IjoiX0tjeUxqOXZXTXB0bm1LdG00NkdxRHo4d2Y3NEk1TEtncmwyR3pIM25TRSJ9"
didJWK := DIDJWK(did)
valid := didJWK.IsValid()
assert.True(tt, valid)

gotTestVector, err := getTestVector(P256Vector)
assert.NoError(t, err)
var didDoc Document
err = json.Unmarshal([]byte(gotTestVector), &didDoc)
assert.NoError(tt, err)

ourDID, err := didJWK.Expand()
assert.NoError(tt, err)

// turn into json and compare
ourDIDJSON, err := json.Marshal(ourDID)
assert.NoError(tt, err)
didDocJSON, err := json.Marshal(didDoc)
assert.NoError(tt, err)
assert.JSONEq(tt, string(ourDIDJSON), string(didDocJSON))
})

t.Run("X25519", func(tt *testing.T) {
did := "did:jwk:eyJrdHkiOiJPS1AiLCJjcnYiOiJYMjU1MTkiLCJ1c2UiOiJlbmMiLCJ4IjoiM3A3YmZYdDl3YlRUVzJIQzdPUTFOei1EUThoYmVHZE5yZngtRkctSUswOCJ9"
didJWK := DIDJWK(did)
valid := didJWK.IsValid()
assert.True(tt, valid)

gotTestVector, err := getTestVector(X25519Vector)
assert.NoError(t, err)
var didDoc Document
err = json.Unmarshal([]byte(gotTestVector), &didDoc)
assert.NoError(tt, err)

ourDID, err := didJWK.Expand()
assert.NoError(tt, err)

// turn into json and compare
ourDIDJSON, err := json.Marshal(ourDID)
assert.NoError(tt, err)
didDocJSON, err := json.Marshal(didDoc)
assert.NoError(tt, err)

assert.JSONEq(tt, string(ourDIDJSON), string(didDocJSON))
})
}

func TestGenerateDIDJWK(t *testing.T) {
tests := []struct {
name string
keyType crypto.KeyType
expectErr bool
}{
{
name: "Ed25519",
keyType: crypto.Ed25519,
expectErr: false,
},
{
name: "x25519",
keyType: crypto.X25519,
expectErr: false,
},
{
name: "SECP256k1",
keyType: crypto.SECP256k1,
expectErr: false,
},
{
name: "P256",
keyType: crypto.P256,
expectErr: false,
},
{
name: "P384",
keyType: crypto.P384,
expectErr: false,
},
{
name: "P521",
keyType: crypto.P521,
expectErr: false,
},
{
name: "RSA",
keyType: crypto.RSA,
expectErr: false,
},
{
name: "Unsupported",
keyType: crypto.KeyType("unsupported"),
expectErr: true,
},
}

for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
privKey, didJWK, err := GenerateDIDJWK(test.keyType)

if test.expectErr {
assert.Error(t, err)
return
}

jsonWebKey, err := cryptosuite.JSONWebKey2020FromPrivateKey(privKey)
assert.NoError(t, err)
assert.NotEmpty(t, jsonWebKey)

assert.NoError(t, err)
assert.NotNil(t, didJWK)
assert.NotEmpty(t, privKey)

assert.True(t, strings.Contains(string(*didJWK), "did:jwk"))
})
}
}

func TestExpandDIDJWK(t *testing.T) {
t.Run("happy path", func(t *testing.T) {
pk, sk, err := crypto.GenerateEd25519Key()
assert.NoError(t, err)
assert.NotEmpty(t, pk)
assert.NotEmpty(t, sk)

gotJWK, err := jwk.FromRaw(pk)
assert.NoError(t, err)

didJWK, err := CreateDIDJWK(gotJWK)
assert.NoError(t, err)
assert.NotEmpty(t, didJWK)

doc, err := didJWK.Expand()
assert.NoError(t, err)
assert.NotEmpty(t, doc)
assert.NoError(t, doc.IsValid())
})

t.Run("bad DID returns error", func(t *testing.T) {
badDID := DIDJWK("bad")
_, err := badDID.Expand()
assert.Error(t, err)
assert.Contains(t, err.Error(), "not a did:jwk DID, invalid prefix: bad")
})

t.Run("DID but not a valid did:jwk", func(t *testing.T) {
badDID := DIDJWK("did:jwk:bad")
_, err := badDID.Expand()
assert.Error(t, err)
assert.Contains(t, err.Error(), "unmarshalling did:jwk")
})
}

func TestGenerateAndResolveDIDJWK(t *testing.T) {
resolvers := []Resolver{JWKResolver{}}
resolver, _ := NewResolver(resolvers...)

for _, kt := range GetSupportedDIDJWKTypes() {
_, didJWK, err := GenerateDIDJWK(kt)
assert.NoError(t, err)

doc, err := resolver.Resolve(context.Background(), didJWK.String())
assert.NoError(t, err)
assert.NotEmpty(t, doc)
assert.Equal(t, didJWK.String(), doc.Document.ID)
}
}
Loading