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 4 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
178 changes: 178 additions & 0 deletions did/jwk.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
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"
)

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) {
split := strings.Split(string(d), JWKPrefix+":")
if len(split) != 2 {
return "", fmt.Errorf("invalid did:jwk: %s", d)
}
return split[1], nil
decentralgabe marked this conversation as resolved.
Show resolved Hide resolved
}

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.URLEncoding.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()

encodedJWK, err := d.Suffix()
if err != nil {
return nil, fmt.Errorf("invalid did:jwk: %s", d)
decentralgabe marked this conversation as resolved.
Show resolved Hide resolved
}
decodedPubKeyJWKStr, err := base64.URLEncoding.DecodeString(encodedJWK)
decentralgabe marked this conversation as resolved.
Show resolved Hide resolved
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) {
if !strings.HasPrefix(did, JWKPrefix) {
return nil, fmt.Errorf("not a did:jwk DID: %s", did)
}
Copy link
Contributor

Choose a reason for hiding this comment

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

This bit of logic is also done in the Suffix function, which is called inside Expand. Consider removing it.

Copy link
Member Author

Choose a reason for hiding this comment

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

removed

didJWK := DIDJWK(did)
doc, err := didJWK.Expand()
if err != nil {
return nil, errors.Wrapf(err, "could not expand did:jwk DID: %s", did)
decentralgabe marked this conversation as resolved.
Show resolved Hide resolved
}
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(sk)
Copy link
Contributor

Choose a reason for hiding this comment

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

this is the secretKey, no? Should you pass in the pk, i.e. publicKey ?

Copy link
Member Author

Choose a reason for hiding this comment

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

updated

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", func(t *testing.T) {
decentralgabe marked this conversation as resolved.
Show resolved Hide resolved
badDID := DIDJWK("bad")
_, err := badDID.Expand()
assert.Error(t, err)
assert.Contains(t, err.Error(), "invalid did:jwk: bad")
Copy link
Contributor

Choose a reason for hiding this comment

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

Might be useful to have the error say that the prefix wasn't found. That way it's informative to devs how to fix.

Copy link
Member Author

Choose a reason for hiding this comment

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

updated

})

t.Run("DID but not a valid did:jwk", func(t *testing.T) {
badDID := DIDKey("did:jwk:bad")
_, err := badDID.Expand()
assert.Error(t, err)
assert.Contains(t, err.Error(), "could not parse did:key: invalid did:key: did:jwk:bad")
})
Copy link
Contributor

Choose a reason for hiding this comment

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

What's the intention behind testing methods from DIDKey here? I'm not sure I follow.

Copy link
Member Author

Choose a reason for hiding this comment

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

bad copy and paste, updating

}

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)
}
}
3 changes: 2 additions & 1 deletion did/key.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ type (

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

func (d DIDKey) IsValid() bool {
Expand Down
Loading