-
Notifications
You must be signed in to change notification settings - Fork 55
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
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
This file was deleted.
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 | ||
} | ||
|
||
func GetSupportedDIDJWKTypes() []crypto.KeyType { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should this function call There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 ? There was a problem hiding this comment. Choose a reason for hiding this commentThe 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} | ||
} |
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) | ||
} | ||
} |
There was a problem hiding this comment.
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?There was a problem hiding this comment.
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.