diff --git a/.github/workflows/workspace-test.yml b/.github/workflows/workspace-test.yml new file mode 100644 index 0000000..3777641 --- /dev/null +++ b/.github/workflows/workspace-test.yml @@ -0,0 +1,16 @@ +name: Workspace Test + +on: + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: workspace-test-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + workspace-test: + uses: fil-forge/libforge/.github/workflows/go-workspace-test.yml@v1 diff --git a/README.md b/README.md index 762bf5d..b033d1a 100644 --- a/README.md +++ b/README.md @@ -24,19 +24,19 @@ fmt.Println("DID:", principal) ``` ```go -// generate a new ed25519 signer -signerPrincipal, err := ed25519.Generate() -fmt.Println("DID:", signerPrincipal.DID()) +// generate a new ed25519 issuer +issuer, err := ed25519.GenerateIssuer() +fmt.Println("DID:", issuer.DID()) // this principal can sign -sig := signerPrincipal.Sign([]byte{1, 2, 3}) +sig := issuer.Sign([]byte{1, 2, 3}) fmt.Printf("Signature: 0x%x\n", sig) // and has a private key (use format utility to multibase base64pad encode) -fmt.Println("Private Key:", signer.Format(signerPrincipal)) +fmt.Println("Private Key:", multikey.FormatSigner(issuer)) // which can be stored and decoded later... -signerPrincipal2, err := ed25519.Decode(signerPrincipal.Bytes()) +issuer2, err := ed25519.Decode(issuer.Bytes()) ``` #### Delegation @@ -134,7 +134,7 @@ See examples in [server_test.go](./examples/server_test.go) ```go echo, err := command.Parse("/example/echo") -serviceID, err := ed25519.Generate() +serviceID, err := ed25519.GenerateIssuer() ucanSrv := server.NewHTTP(serviceID) @@ -167,14 +167,15 @@ c, err := client.NewHTTP(serviceURL) // create an execution request and send it to the service, passing the // invocation and the delegation as proof we are authorized -req := execution.NewRequest(context.Background(), inv, execution.WithProofs(dlg)) +req := execution.NewRequest(context.Background(), inv, execution.WithDelegations(dlg)) resp, err := c.Execute(req) -o, x := result.Unwrap(resp.Receipt().Out()) -if x != nil { - fmt.Printf("Invocation failed: %v\n", x) +if out := resp.Receipt().Out(); out.IsOK() { + ok, _ := out.Unpack() + fmt.Printf("Echo response: %+v\n", ok) } else { - fmt.Printf("Echo response: %+v\n", o) + _, x := out.Unpack() + fmt.Printf("Invocation failed: %v\n", x) } ``` diff --git a/absentee/signer.go b/absentee/signer.go new file mode 100644 index 0000000..048040a --- /dev/null +++ b/absentee/signer.go @@ -0,0 +1,46 @@ +package absentee + +import ( + "fmt" + + "github.com/fil-forge/ucantone/did" + "github.com/fil-forge/ucantone/ucan" + "github.com/fil-forge/ucantone/varsig" + "github.com/fil-forge/ucantone/varsig/algorithm/nonstandard" +) + +var SignatureAlgorithm = nonstandard.New() + +// Issuer is a special type of issuer that produces an absent signature, +// which signals that verifier needs to verify authorization interactively. +type Issuer struct { + id did.DID +} + +var _ ucan.Issuer = Issuer{} + +func (a Issuer) DID() did.DID { + return a.id +} + +func (a Issuer) String() string { + return fmt.Sprintf("%s (absentee)", a.id) +} + +func (a Issuer) Sign(msg []byte) []byte { + return []byte{} +} + +func (a Issuer) SignatureAlgorithm() varsig.Algorithm { + return SignatureAlgorithm +} + +func (a Issuer) Verifier() ucan.Verifier { + panic("absentee issuer does not have a verifier") +} + +// From creates a special type of issuer that produces an absent signature, +// which signals that verifier needs to verify authorization interactively. +func From(id did.DID) Issuer { + return Issuer{id} +} diff --git a/principal/absentee/signer_test.go b/absentee/signer_test.go similarity index 91% rename from principal/absentee/signer_test.go rename to absentee/signer_test.go index 76e7b4b..3259bae 100644 --- a/principal/absentee/signer_test.go +++ b/absentee/signer_test.go @@ -3,8 +3,8 @@ package absentee_test import ( "testing" + "github.com/fil-forge/ucantone/absentee" "github.com/fil-forge/ucantone/did" - "github.com/fil-forge/ucantone/principal/absentee" "github.com/stretchr/testify/require" ) diff --git a/binding/binding.go b/binding/binding.go index a63a025..55378c1 100644 --- a/binding/binding.go +++ b/binding/binding.go @@ -81,13 +81,13 @@ func Bind[Args, OK CBORValue](cmd command.Command) Binding[Args, OK] { // Invoke constructs a signed invocation of the command carrying the given // typed arguments. -func (c Binding[Args, OK]) Invoke(issuer ucan.Signer, subject did.DID, arguments Args, options ...invocation.Option) (ucan.Invocation, error) { +func (c Binding[Args, OK]) Invoke(issuer ucan.Issuer, subject did.DID, arguments Args, options ...invocation.Option) (ucan.Invocation, error) { return invocation.Invoke(issuer, subject, c.Command, arguments, options...) } // Delegate issues a delegation granting authority over this command. It does // not involve the argument or result types. -func (c Binding[Args, OK]) Delegate(issuer ucan.Signer, audience did.DID, subject did.DID, options ...delegation.Option) (ucan.Delegation, error) { +func (c Binding[Args, OK]) Delegate(issuer ucan.Issuer, audience did.DID, subject did.DID, options ...delegation.Option) (ucan.Delegation, error) { return delegation.Delegate(issuer, audience, subject, c.Command, options...) } diff --git a/binding/example_test.go b/binding/example_test.go index 05372ee..ea537e1 100644 --- a/binding/example_test.go +++ b/binding/example_test.go @@ -6,7 +6,7 @@ import ( "github.com/fil-forge/ucantone/binding" "github.com/fil-forge/ucantone/execution" - "github.com/fil-forge/ucantone/principal/ed25519" + "github.com/fil-forge/ucantone/multikey/ed25519" tdm "github.com/fil-forge/ucantone/testutil/datamodel" "github.com/fil-forge/ucantone/ucan/command" ) @@ -22,8 +22,8 @@ var echo = binding.Bind[*tdm.TestObject, *tdm.TestObject2](command.MustParse("/e // typed arguments, a server handles it and returns a typed result, and the // client unpacks that result — all without hand-written encoding or decoding. func Example() { - client, _ := ed25519.Generate() - service, _ := ed25519.Generate() + client, _ := ed25519.GenerateIssuer() + service, _ := ed25519.GenerateIssuer() // Client: invoke the command with typed arguments. Invoke encodes them into // a signed invocation addressed to the service. @@ -37,7 +37,7 @@ func Example() { }) xreq := execution.NewRequest(context.Background(), inv) - xres, _ := execution.NewResponse(inv.Task().Link(), execution.WithSigner(service)) + xres, _ := execution.NewResponse(inv.Task().Link(), execution.WithIssuer(service)) _ = handle(xreq, xres) // Client: unpack the typed result (OK) out of the receipt. diff --git a/binding/handler.go b/binding/handler.go index 94a171d..8106974 100644 --- a/binding/handler.go +++ b/binding/handler.go @@ -82,25 +82,25 @@ func (r *Request[Args]) Task() *Task[Args] { return r.task } -// SignerSetter is implemented by responses that can issue their own receipt and -// therefore need a signer. [WithSigner] uses it to set that signer. -type SignerSetter interface { - SetSigner(ucan.Signer) error +// IssuerSetter is implemented by responses that can issue their own receipt and +// therefore need an issuer. [WithIssuer] uses it to set that issuer. +type IssuerSetter interface { + SetIssuer(ucan.Issuer) error } // ResponseOption configures a [Response] as it is built by [NewResponse], -// typically setting its outcome (success or failure), signer, or metadata. +// typically setting its outcome (success or failure), issuer, or metadata. type ResponseOption[OK cbg.CBORMarshaler] func(r *Response[OK]) error -// WithSigner sets the signer used to issue the response's receipt. It fails if -// the underlying response cannot accept one (does not implement [SignerSetter]). -func WithSigner[OK cbg.CBORMarshaler](signer ucan.Signer) ResponseOption[OK] { +// WithIssuer sets the issuer used to issue the response's receipt. It fails if +// the underlying response cannot accept one (does not implement [IssuerSetter]). +func WithIssuer[OK cbg.CBORMarshaler](issuer ucan.Issuer) ResponseOption[OK] { return func(resp *Response[OK]) error { - setter, ok := resp.res.(SignerSetter) + setter, ok := resp.res.(IssuerSetter) if !ok { - return fmt.Errorf("cannot set signer: underlying response is not a signer setter") + return fmt.Errorf("cannot set issuer: underlying response is not an issuer setter") } - return setter.SetSigner(signer) + return setter.SetIssuer(issuer) } } @@ -144,7 +144,7 @@ type Response[OK cbg.CBORMarshaler] struct { } // NewResponse creates a response for the given task, applying any options -// (such as [WithSuccess], [WithFailure], or [WithSigner]) that set its outcome. +// (such as [WithSuccess], [WithFailure], or [WithIssuer]) that set its outcome. func NewResponse[OK cbg.CBORMarshaler](task cid.Cid, options ...ResponseOption[OK]) (*Response[OK], error) { xres, err := execution.NewResponse(task) if err != nil { diff --git a/binding/handler_test.go b/binding/handler_test.go index fbc1cc3..ab1619d 100644 --- a/binding/handler_test.go +++ b/binding/handler_test.go @@ -15,7 +15,7 @@ import ( ) func TestHandler(t *testing.T) { - alice := testutil.RandomSigner(t) + alice := testutil.RandomIssuer(t) handler := binding.NewHandler(func(req *binding.Request[*tdm.TestObject], res *binding.Response[*tdm.TestObject2]) error { args := req.Task().Arguments() require.Equal(t, args.Bytes, []byte{0x01, 0x02, 0x03}) @@ -33,7 +33,7 @@ func TestHandler(t *testing.T) { req := execution.NewRequest(t.Context(), inv) require.NoError(t, err) - res, err := execution.NewResponse(inv.Task().Link(), execution.WithSigner(alice)) + res, err := execution.NewResponse(inv.Task().Link(), execution.WithIssuer(alice)) require.NoError(t, err) err = handler(req, res) diff --git a/client/http_test.go b/client/http_test.go index 996b26e..c81e195 100644 --- a/client/http_test.go +++ b/client/http_test.go @@ -15,8 +15,8 @@ import ( ) func TestHTTPClient(t *testing.T) { - service := testutil.RandomSigner(t) - alice := testutil.RandomSigner(t) + service := testutil.RandomIssuer(t) + alice := testutil.RandomIssuer(t) t.Run("invocation execution round trip", func(t *testing.T) { server := server.NewHTTP(service) diff --git a/did/context.go b/did/context.go new file mode 100644 index 0000000..9783e63 --- /dev/null +++ b/did/context.go @@ -0,0 +1,29 @@ +package did + +import ( + "encoding/json" + "fmt" +) + +const CoreContext = "https://www.w3.org/ns/did/v1.1" + +// Context handles both string and []string formats for @context field +// as allowed by the DID Core specification +type Context []string + +func (fc Context) MarshalJSON() ([]byte, error) { + return json.Marshal(OneOrMany[string](append([]string{CoreContext}, fc...))) +} + +func (fc *Context) UnmarshalJSON(data []byte) error { + var ctxStrs OneOrMany[string] + err := json.Unmarshal(data, &ctxStrs) + if err != nil { + return err + } + if len(ctxStrs) < 1 || ctxStrs[0] != CoreContext { + return fmt.Errorf("@context must list %q first", CoreContext) + } + *fc = Context(ctxStrs[1:]) + return nil +} diff --git a/did/context_test.go b/did/context_test.go new file mode 100644 index 0000000..7d754da --- /dev/null +++ b/did/context_test.go @@ -0,0 +1,68 @@ +package did_test + +import ( + "encoding/json" + "testing" + + "github.com/fil-forge/ucantone/did" + "github.com/stretchr/testify/require" +) + +func TestContext(t *testing.T) { + t.Run("to JSON", func(t *testing.T) { + t.Run("with no additional contexts, produces the DID Core context", func(t *testing.T) { + ctx := did.Context{} + b, err := json.Marshal(ctx) + require.NoError(t, err) + require.JSONEq(t, `"https://www.w3.org/ns/did/v1.1"`, string(b)) + }) + + t.Run("with additional contexts, puts the DID Core context first", func(t *testing.T) { + ctx := did.Context{ + "https://example.com/context1", + "https://example.com/context2", + } + b, err := json.Marshal(ctx) + require.NoError(t, err) + require.JSONEq(t, `[ + "https://www.w3.org/ns/did/v1.1", + "https://example.com/context1", + "https://example.com/context2" + ]`, string(b)) + }) + }) + + t.Run("from JSON", func(t *testing.T) { + t.Run("with a single string of the DID Core context, produces an empty Context", func(t *testing.T) { + var ctx did.Context + err := json.Unmarshal([]byte(`"https://www.w3.org/ns/did/v1.1"`), &ctx) + require.NoError(t, err) + require.Equal(t, did.Context{}, ctx) + }) + + t.Run("with a single string of the wrong context, fails", func(t *testing.T) { + var ctx did.Context + err := json.Unmarshal([]byte(`"https://example.com/wrong-context"`), &ctx) + require.ErrorContains(t, err, `@context must list "https://www.w3.org/ns/did/v1.1" first`) + }) + + t.Run("with multiple contexts, puts them in the Context", func(t *testing.T) { + var ctx did.Context + err := json.Unmarshal([]byte(`["https://www.w3.org/ns/did/v1.1", "https://example.com/context1"]`), &ctx) + require.NoError(t, err) + require.Equal(t, did.Context{"https://example.com/context1"}, ctx) + }) + + t.Run("with multiple contexts missing the DID Core context, fails", func(t *testing.T) { + var ctx did.Context + err := json.Unmarshal([]byte(`["https://example.com/context1", "https://example.com/context2"]`), &ctx) + require.ErrorContains(t, err, `@context must list "https://www.w3.org/ns/did/v1.1" first`) + }) + + t.Run("with no contexts, fails", func(t *testing.T) { + var ctx did.Context + err := json.Unmarshal([]byte(`[]`), &ctx) + require.ErrorContains(t, err, `@context must list "https://www.w3.org/ns/did/v1.1" first`) + }) + }) +} diff --git a/did/datetimestamp.go b/did/datetimestamp.go new file mode 100644 index 0000000..2ab02f3 --- /dev/null +++ b/did/datetimestamp.go @@ -0,0 +1,32 @@ +package did + +import ( + "encoding/json" + "time" +) + +type DateTimeStamp time.Time + +func (d DateTimeStamp) Time() time.Time { + return time.Time(d) +} + +func (d DateTimeStamp) MarshalJSON() ([]byte, error) { + return json.Marshal(d.Time().Format(time.RFC3339)) +} + +func (d *DateTimeStamp) UnmarshalJSON(b []byte) error { + var s string + err := json.Unmarshal(b, &s) + if err != nil { + return err + } + + t, err := time.Parse(time.RFC3339, s) + if err != nil { + return err + } + + *d = DateTimeStamp(t) + return nil +} diff --git a/did/datetimestamp_test.go b/did/datetimestamp_test.go new file mode 100644 index 0000000..535a548 --- /dev/null +++ b/did/datetimestamp_test.go @@ -0,0 +1,66 @@ +package did_test + +import ( + "encoding/json" + "testing" + "time" + + "github.com/fil-forge/ucantone/did" + "github.com/stretchr/testify/require" +) + +func TestDateTimeStamp(t *testing.T) { + t.Run("to JSON", func(t *testing.T) { + t.Run("marshals to a quoted RFC3339 string", func(t *testing.T) { + d := did.DateTimeStamp(time.Date(2023, 1, 2, 15, 4, 5, 0, time.UTC)) + b, err := json.Marshal(d) + require.NoError(t, err) + require.JSONEq(t, `"2023-01-02T15:04:05Z"`, string(b)) + }) + + t.Run("preserves timezone offset", func(t *testing.T) { + loc := time.FixedZone("IST", 5*60*60+30*60) + d := did.DateTimeStamp(time.Date(2023, 1, 2, 15, 4, 5, 0, loc)) + b, err := json.Marshal(d) + require.NoError(t, err) + require.JSONEq(t, `"2023-01-02T15:04:05+05:30"`, string(b)) + }) + }) + + t.Run("from JSON", func(t *testing.T) { + t.Run("unmarshals a valid RFC3339 string", func(t *testing.T) { + var d did.DateTimeStamp + err := json.Unmarshal([]byte(`"2023-01-02T15:04:05Z"`), &d) + require.NoError(t, err) + require.True(t, time.Date(2023, 1, 2, 15, 4, 5, 0, time.UTC).Equal(d.Time())) + }) + + t.Run("unmarshals a valid RFC3339 string with timezone offset", func(t *testing.T) { + var d did.DateTimeStamp + err := json.Unmarshal([]byte(`"2023-01-02T15:04:05+05:30"`), &d) + require.NoError(t, err) + loc := time.FixedZone("+0530", 5*60*60+30*60) + require.True(t, time.Date(2023, 1, 2, 15, 4, 5, 0, loc).Equal(d.Time())) + }) + + t.Run("fails on a non-string JSON value", func(t *testing.T) { + var d did.DateTimeStamp + err := json.Unmarshal([]byte(`1234567890`), &d) + require.Error(t, err) + }) + + t.Run("fails on a string that is not RFC3339", func(t *testing.T) { + var d did.DateTimeStamp + err := json.Unmarshal([]byte(`"not-a-date"`), &d) + require.Error(t, err) + }) + }) +} + +func TestDateTimeStamp_Time(t *testing.T) { + t.Run("returns the underlying time.Time value", func(t *testing.T) { + expected := time.Date(2023, 6, 15, 10, 30, 0, 0, time.UTC) + d := did.DateTimeStamp(expected) + require.Equal(t, expected, d.Time()) + }) +} diff --git a/did/did.go b/did/did.go index df02d53..dfb408b 100644 --- a/did/did.go +++ b/did/did.go @@ -13,6 +13,10 @@ import ( ) const Prefix = "did:" + +// TODO: This should really go in `did/key`, but we use it in `did.Parse` to +// validate `did:key` DIDs during parsing. We could drop that, but it's a nice +// check to have. const KeyPrefix = Prefix + "key:" const DIDCore = 0x0d1d @@ -150,10 +154,16 @@ func (d *DID) UnmarshalDagJSON(r io.Reader) error { return nil } +func New(method, identifier string) DID { + return DID{fmt.Sprintf("%s%s:%s", Prefix, method, identifier)} +} + func Parse(str string) (DID, error) { if !strings.HasPrefix(str, Prefix) { return DID{}, fmt.Errorf("must start with 'did:'") } + + // Convenience validation for `did:key` DIDs. if strings.HasPrefix(str, KeyPrefix) { code, _, err := mbase.Decode(str[len(KeyPrefix):]) if err != nil { @@ -163,5 +173,14 @@ func Parse(str string) (DID, error) { return DID{}, fmt.Errorf("not Base58BTC encoded") } } + return DID{str}, nil } + +func MustParse(str string) DID { + d, err := Parse(str) + if err != nil { + panic(fmt.Sprintf("invalid DID: %s", str)) + } + return d +} diff --git a/did/document.go b/did/document.go new file mode 100644 index 0000000..ef70f40 --- /dev/null +++ b/did/document.go @@ -0,0 +1,116 @@ +package did + +import ( + "encoding/json" +) + +// GenericMap is a value that is either specified to be a map with no particular +// set of keys, or whose spec is not currently implemented here. +type GenericMap = map[string]any + +// VerificationMaterial is the type-specific material fields of a +// [VerificationMethod], keyed by field name. +// https://www.w3.org/TR/cid-1.0/#verification-material +type VerificationMaterial = GenericMap + +// https://www.w3.org/TR/did-1.1/#core-properties +type Document struct { + Context Context `json:"@context"` + ID DID `json:"id"` + Controller OneOrMany[DID] `json:"controller,omitempty"` + AlsoKnownAs []DID `json:"alsoKnownAs,omitempty"` + Service []Service `json:"service,omitempty"` + VerificationMethods *VerificationMethods `json:"verificationMethod,omitempty"` + Authentication *VerificationRelationship `json:"authentication,omitzero"` + AssertionMethod *VerificationRelationship `json:"assertionMethod,omitzero"` + KeyAgreement *VerificationRelationship `json:"keyAgreement,omitzero"` + CapabilityInvocation *VerificationRelationship `json:"capabilityInvocation,omitzero"` + CapabilityDelegation *VerificationRelationship `json:"capabilityDelegation,omitzero"` +} + +func NewDocument(id DID) Document { + vms := VerificationMethods{} + return Document{ + ID: id, + VerificationMethods: &vms, + Authentication: &VerificationRelationship{allMethods: &vms}, + AssertionMethod: &VerificationRelationship{allMethods: &vms}, + KeyAgreement: &VerificationRelationship{allMethods: &vms}, + CapabilityInvocation: &VerificationRelationship{allMethods: &vms}, + CapabilityDelegation: &VerificationRelationship{allMethods: &vms}, + } +} + +// Fragment returns a URL for the given fragment within this document, e.g. for +// a verification method. +func (d Document) Fragment(fragment string) URL { + url, err := ParseURL(d.ID.String()) + if err != nil { + // This should not be possible: a DID is always a valid URL. (Actually, it's + // a valid *URI*, but that should work too.) + panic("failed to create URL for DID: " + err.Error()) + } + url.URL.Fragment = fragment + return url +} + +func (d *Document) UnmarshalJSON(b []byte) error { + type documentJSON struct { + Context Context `json:"@context"` + ID string `json:"id"` + Controller OneOrMany[DID] `json:"controller,omitempty"` + AlsoKnownAs []DID `json:"alsoKnownAs,omitempty"` + Service []Service `json:"service,omitempty"` + VerificationMethods VerificationMethods `json:"verificationMethod,omitempty"` + Authentication json.RawMessage `json:"authentication,omitempty"` + AssertionMethod json.RawMessage `json:"assertionMethod,omitempty"` + KeyAgreement json.RawMessage `json:"keyAgreement,omitempty"` + CapabilityInvocation json.RawMessage `json:"capabilityInvocation,omitempty"` + CapabilityDelegation json.RawMessage `json:"capabilityDelegation,omitempty"` + } + var raw documentJSON + if err := json.Unmarshal(b, &raw); err != nil { + return err + } + + d.Context = raw.Context + var err error + d.ID, err = Parse(raw.ID) + if err != nil { + return err + } + d.Controller = raw.Controller + d.AlsoKnownAs = raw.AlsoKnownAs + d.Service = raw.Service + d.VerificationMethods = &raw.VerificationMethods + + d.Authentication = &VerificationRelationship{allMethods: d.VerificationMethods} + d.AssertionMethod = &VerificationRelationship{allMethods: d.VerificationMethods} + d.KeyAgreement = &VerificationRelationship{allMethods: d.VerificationMethods} + d.CapabilityInvocation = &VerificationRelationship{allMethods: d.VerificationMethods} + d.CapabilityDelegation = &VerificationRelationship{allMethods: d.VerificationMethods} + + for _, rel := range []struct { + raw json.RawMessage + dest **VerificationRelationship + name string + }{ + {raw.Authentication, &d.Authentication, "authentication"}, + {raw.AssertionMethod, &d.AssertionMethod, "assertionMethod"}, + {raw.KeyAgreement, &d.KeyAgreement, "keyAgreement"}, + {raw.CapabilityInvocation, &d.CapabilityInvocation, "capabilityInvocation"}, + {raw.CapabilityDelegation, &d.CapabilityDelegation, "capabilityDelegation"}, + } { + if len(rel.raw) == 0 { + continue + } + (*rel.dest).allMethods = d.VerificationMethods + if err := json.Unmarshal(rel.raw, *rel.dest); err != nil { + return err + } + } + return nil +} + +// Services are not yet implemented. +type Service = GenericMap diff --git a/did/document_test.go b/did/document_test.go new file mode 100644 index 0000000..5c11f32 --- /dev/null +++ b/did/document_test.go @@ -0,0 +1,176 @@ +package did_test + +import ( + "encoding/json" + "testing" + + "github.com/fil-forge/ucantone/did" + "github.com/stretchr/testify/require" +) + +func TestDocument_MarshalJSON(t *testing.T) { + t.Run("verificationMethod marshals as array", func(t *testing.T) { + controller, err := did.Parse("did:example:123") + require.NoError(t, err) + doc := did.NewDocument(controller) + vm := did.VerificationMethod{ + ID: doc.Fragment("key-1"), + Controller: controller, + Type: did.MultikeyVerificationMethodType, + Material: did.GenericMap{did.MultikeyPublicKeyMultibaseProp: "zABC"}, + } + require.NoError(t, doc.VerificationMethods.Add(vm)) + + b, err := json.Marshal(doc) + require.NoError(t, err) + + require.JSONEq(t, `{ + "@context": "https://www.w3.org/ns/did/v1.1", + "id": "did:example:123", + "verificationMethod": [ + { + "controller": "did:example:123", + "id": "did:example:123#key-1", + "publicKeyMultibase": "zABC", + "type": "Multikey" + } + ] + }`, string(b)) + }) + + t.Run("empty verificationMethod is omitted", func(t *testing.T) { + exampleDID, err := did.Parse("did:example:123") + require.NoError(t, err) + doc := did.Document{ID: exampleDID} + b, err := json.Marshal(doc) + require.NoError(t, err) + + require.NotContains(t, string(b), "verificationMethod") + }) +} + +func TestDocument_UnmarshalJSON(t *testing.T) { + t.Run("URL references only", func(t *testing.T) { + data := `{ + "@context": "https://www.w3.org/ns/did/v1.1", + "id": "did:example:123", + "verificationMethod": [{ + "id": "did:example:123#key-1", + "type": "Multikey", + "controller": "did:example:123", + "publicKeyMultibase": "zABC" + }], + "authentication": ["did:example:123#key-1"] + }` + var doc did.Document + err := json.Unmarshal([]byte(data), &doc) + require.NoError(t, err) + require.Len(t, doc.VerificationMethods.All(), 1) + require.Equal(t, 1, doc.Authentication.Len()) + require.Equal(t, "did:example:123#key-1", doc.Authentication.Get(0).String()) + }) + + t.Run("embedded method in authentication", func(t *testing.T) { + data := `{ + "@context": "https://www.w3.org/ns/did/v1.1", + "id": "did:example:123", + "authentication": [{ + "id": "did:example:123#key-1", + "type": "Multikey", + "controller": "did:example:123", + "publicKeyMultibase": "zABC" + }] + }` + var doc did.Document + err := json.Unmarshal([]byte(data), &doc) + require.NoError(t, err) + require.Len(t, doc.VerificationMethods.All(), 1) + require.Equal(t, "did:example:123#key-1", doc.VerificationMethods.All()[0].ID.String()) + require.Equal(t, 1, doc.Authentication.Len()) + require.Equal(t, "did:example:123#key-1", doc.Authentication.Get(0).String()) + }) + + t.Run("mixed URL and embedded in same relationship", func(t *testing.T) { + data := `{ + "@context": "https://www.w3.org/ns/did/v1.1", + "id": "did:example:123", + "verificationMethod": [{ + "id": "did:example:123#key-1", + "type": "Multikey", + "controller": "did:example:123", + "publicKeyMultibase": "zABC" + }], + "authentication": [ + "did:example:123#key-1", + { + "id": "did:example:123#key-2", + "type": "Multikey", + "controller": "did:example:123", + "publicKeyMultibase": "zDEF" + } + ] + }` + var doc did.Document + err := json.Unmarshal([]byte(data), &doc) + require.NoError(t, err) + require.Len(t, doc.VerificationMethods.All(), 2) + require.Equal(t, 2, doc.Authentication.Len()) + require.Equal(t, "did:example:123#key-1", doc.Authentication.Get(0).String()) + require.Equal(t, "did:example:123#key-2", doc.Authentication.Get(1).String()) + }) + + t.Run("same embedded method in multiple relationships", func(t *testing.T) { + data := `{ + "@context": "https://www.w3.org/ns/did/v1.1", + "id": "did:example:123", + "authentication": [{ + "id": "did:example:123#key-1", + "type": "Multikey", + "controller": "did:example:123", + "publicKeyMultibase": "zABC" + }], + "assertionMethod": [{ + "id": "did:example:123#key-1", + "type": "Multikey", + "controller": "did:example:123", + "publicKeyMultibase": "zABC" + }] + }` + var doc did.Document + err := json.Unmarshal([]byte(data), &doc) + require.NoError(t, err) + require.Len(t, doc.VerificationMethods.All(), 1, "identical duplicate should appear once") + require.Equal(t, "did:example:123#key-1", doc.Authentication.Get(0).String()) + require.Equal(t, "did:example:123#key-1", doc.AssertionMethod.Get(0).String()) + }) + + t.Run("conflicting definitions for same ID", func(t *testing.T) { + data := `{ + "@context": "https://www.w3.org/ns/did/v1.1", + "id": "did:example:123", + "authentication": [{ + "id": "did:example:123#key-1", + "type": "Multikey", + "controller": "did:example:123", + "publicKeyMultibase": "zABC" + }], + "assertionMethod": [{ + "id": "did:example:123#key-1", + "type": "Multikey", + "controller": "did:example:123", + "publicKeyMultibase": "zDIFFERENT" + }] + }` + var doc did.Document + err := json.Unmarshal([]byte(data), &doc) + require.ErrorContains(t, err, `conflicting definitions for verification method "did:example:123#key-1"`) + }) +} + +func TestDocument_Fragment(t *testing.T) { + exampleDID, err := did.Parse("did:example:123") + require.NoError(t, err) + doc := did.Document{ID: exampleDID} + fragment := doc.Fragment("key-1") + require.Equal(t, "did:example:123#key-1", fragment.String()) +} diff --git a/did/errors.go b/did/errors.go new file mode 100644 index 0000000..5a08450 --- /dev/null +++ b/did/errors.go @@ -0,0 +1,22 @@ +package did + +import "fmt" + +type UnsupportedMethodError struct { + DID DID + Reason string +} + +func (e UnsupportedMethodError) Error() string { + return fmt.Sprintf("unsupported DID method in %s: %s", e.DID, e.Reason) +} + +func ValidateMethod(d DID, expected string) error { + if d.Method() != expected { + return UnsupportedMethodError{ + DID: d, + Reason: "expected " + expected, + } + } + return nil +} diff --git a/did/key/key.go b/did/key/key.go new file mode 100644 index 0000000..dc6fb1b --- /dev/null +++ b/did/key/key.go @@ -0,0 +1,45 @@ +package key + +import ( + "context" + + "github.com/fil-forge/ucantone/did" +) + +const Method = "key" + +var Resolver did.ResolverFunc = resolve + +// https://w3c-ccg.github.io/did-key-spec/#read +func resolve(_ context.Context, d did.DID) (did.Document, error) { + if err := did.ValidateMethod(d, Method); err != nil { + return did.Document{}, err + } + + doc := did.NewDocument(d) + vm := did.VerificationMethod{ + ID: doc.Fragment(d.Identifier()), + Controller: d, + Type: did.MultikeyVerificationMethodType, + Material: did.GenericMap{did.MultikeyPublicKeyMultibaseProp: d.Identifier()}, + } + + if err := doc.VerificationMethods.Add(vm); err != nil { + return did.Document{}, err + } + + if err := doc.Authentication.Add(vm); err != nil { + return did.Document{}, err + } + if err := doc.AssertionMethod.Add(vm); err != nil { + return did.Document{}, err + } + if err := doc.CapabilityDelegation.Add(vm); err != nil { + return did.Document{}, err + } + if err := doc.CapabilityInvocation.Add(vm); err != nil { + return did.Document{}, err + } + + return doc, nil +} diff --git a/did/key/key_test.go b/did/key/key_test.go new file mode 100644 index 0000000..9f787cf --- /dev/null +++ b/did/key/key_test.go @@ -0,0 +1,57 @@ +package key_test + +import ( + "encoding/json" + "testing" + + "github.com/fil-forge/ucantone/did" + "github.com/fil-forge/ucantone/did/key" + "github.com/stretchr/testify/require" +) + +func TestResolve(t *testing.T) { + t.Run("resolves a did:key by expanding it", func(t *testing.T) { + did, err := did.Parse("did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK") + require.NoError(t, err) + doc, err := key.Resolver(t.Context(), did) + require.NoError(t, err) + + docStr, err := json.Marshal(doc) + require.NoError(t, err) + + // Example from https://w3c-ccg.github.io/did-key-spec/#create + // * Note that the `enableEncryptionKeyDerivation` option is not currently + // supported and treated as false, so `keyAgreement` is omitted. + // * Also note that the `@context` is normalized to a single string, which is + // equivalent. + require.JSONEq(t, `{ + "@context": "https://www.w3.org/ns/did/v1.1", + "id": "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK", + "verificationMethod": [{ + "id": "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK#z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK", + "type": "Multikey", + "controller": "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK", + "publicKeyMultibase": "z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK" + }], + "authentication": [ + "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK#z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK" + ], + "assertionMethod": [ + "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK#z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK" + ], + "capabilityDelegation": [ + "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK#z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK" + ], + "capabilityInvocation": [ + "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK#z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK" + ] + }`, string(docStr)) + }) + + t.Run("doesn't resolve a non-key DID", func(t *testing.T) { + did, err := did.Parse("did:web:example.com") + require.NoError(t, err) + _, err = key.Resolver(t.Context(), did) + require.ErrorContains(t, err, "unsupported DID method in did:web:example.com: expected key") + }) +} diff --git a/did/oneormany.go b/did/oneormany.go new file mode 100644 index 0000000..0d11640 --- /dev/null +++ b/did/oneormany.go @@ -0,0 +1,37 @@ +package did + +import ( + "encoding/json" + "errors" +) + +type OneOrMany[T any] []T + +func (om OneOrMany[T]) MarshalJSON() ([]byte, error) { + if len(om) == 1 { + return json.Marshal(om[0]) + } + return json.Marshal([]T(om)) +} + +func (om *OneOrMany[T]) UnmarshalJSON(data []byte) error { + var single T + err := json.Unmarshal(data, &single) + if err == nil { + *om = OneOrMany[T]{single} + return nil + } + var typeErr *json.UnmarshalTypeError + if !errors.As(err, &typeErr) { + return err + } + + var multiple []T + err = json.Unmarshal(data, &multiple) + if err != nil { + return err + } + + *om = OneOrMany[T](multiple) + return nil +} diff --git a/did/oneormany_test.go b/did/oneormany_test.go new file mode 100644 index 0000000..3687015 --- /dev/null +++ b/did/oneormany_test.go @@ -0,0 +1,85 @@ +package did_test + +import ( + "encoding/json" + "testing" + + "github.com/fil-forge/ucantone/did" + "github.com/stretchr/testify/require" +) + +func TestOneOrMany(t *testing.T) { + t.Run("to JSON", func(t *testing.T) { + t.Run("with one string, produces a string", func(t *testing.T) { + oom := did.OneOrMany[string]{"one value"} + b, err := json.Marshal(oom) + require.NoError(t, err) + require.JSONEq(t, `"one value"`, string(b)) + }) + + t.Run("with multiple strings, produces an array", func(t *testing.T) { + oom := did.OneOrMany[string]{"one value", "another value"} + b, err := json.Marshal(oom) + require.NoError(t, err) + require.JSONEq(t, `["one value", "another value"]`, string(b)) + }) + + t.Run("with one map, produces a map", func(t *testing.T) { + oom := did.OneOrMany[map[string]string]{{"key": "value"}} + b, err := json.Marshal(oom) + require.NoError(t, err) + require.JSONEq(t, `{"key": "value"}`, string(b)) + }) + + t.Run("with multiple maps, produces an array of maps", func(t *testing.T) { + oom := did.OneOrMany[map[string]string]{{"key": "value"}, {"another": "map"}} + b, err := json.Marshal(oom) + require.NoError(t, err) + require.JSONEq(t, `[{"key": "value"}, {"another": "map"}]`, string(b)) + }) + + t.Run("with no values, produces an empty array", func(t *testing.T) { + oom := did.OneOrMany[string]{} + b, err := json.Marshal(oom) + require.NoError(t, err) + require.JSONEq(t, `[]`, string(b)) + }) + }) + + t.Run("from JSON", func(t *testing.T) { + t.Run("with a single string, produces an OneOrMany with one element", func(t *testing.T) { + var oom did.OneOrMany[string] + err := json.Unmarshal([]byte(`"one value"`), &oom) + require.NoError(t, err) + require.Equal(t, did.OneOrMany[string]{"one value"}, oom) + }) + + t.Run("with multiple strings, produces an OneOrMany with multiple elements", func(t *testing.T) { + var oom did.OneOrMany[string] + err := json.Unmarshal([]byte(`["one value", "another value"]`), &oom) + require.NoError(t, err) + require.Equal(t, did.OneOrMany[string]{"one value", "another value"}, oom) + }) + + t.Run("with a single map, produces an OneOrMany with one element", func(t *testing.T) { + var oom did.OneOrMany[map[string]string] + err := json.Unmarshal([]byte(`{"key": "value"}`), &oom) + require.NoError(t, err) + require.Equal(t, did.OneOrMany[map[string]string]{{"key": "value"}}, oom) + }) + + t.Run("with multiple maps, produces an OneOrMany with multiple elements", func(t *testing.T) { + var oom did.OneOrMany[map[string]string] + err := json.Unmarshal([]byte(`[{"key": "value"}, {"another": "map"}]`), &oom) + require.NoError(t, err) + require.Equal(t, did.OneOrMany[map[string]string]{{"key": "value"}, {"another": "map"}}, oom) + }) + + t.Run("with an empty array, produces an empty OneOrMany", func(t *testing.T) { + var oom did.OneOrMany[string] + err := json.Unmarshal([]byte(`[]`), &oom) + require.NoError(t, err) + require.Equal(t, did.OneOrMany[string]{}, oom) + }) + }) +} diff --git a/did/resolver.go b/did/resolver.go new file mode 100644 index 0000000..c63b2af --- /dev/null +++ b/did/resolver.go @@ -0,0 +1,31 @@ +package did + +import ( + "context" +) + +// Resolver resolves a DID to a DID Document. +type Resolver interface { + Resolve(ctx context.Context, d DID) (Document, error) +} + +// ResolverFunc is a simple function [Resolver]. +type ResolverFunc func(ctx context.Context, d DID) (Document, error) + +func (f ResolverFunc) Resolve(ctx context.Context, d DID) (Document, error) { + return f(ctx, d) +} + +type ResolverMap map[string]Resolver + +func (rm ResolverMap) Resolve(ctx context.Context, d DID) (Document, error) { + method := d.Method() + resolver, ok := rm[method] + if !ok { + return Document{}, UnsupportedMethodError{ + DID: d, + Reason: "no resolver registered", + } + } + return resolver.Resolve(ctx, d) +} diff --git a/did/resolver/bymethod.go b/did/resolver/bymethod.go new file mode 100644 index 0000000..c4744d8 --- /dev/null +++ b/did/resolver/bymethod.go @@ -0,0 +1,19 @@ +package resolver + +import ( + "context" + "fmt" + + "github.com/fil-forge/ucantone/did" +) + +type ByMethod map[string]did.Resolver + +func (bm ByMethod) Resolve(ctx context.Context, input did.DID) (did.Document, error) { + resolver, ok := bm[input.Method()] + if !ok { + return did.Document{}, fmt.Errorf("no resolver found for method: %s", input.Method()) + } + + return resolver.Resolve(ctx, input) +} diff --git a/did/resolver/bymethod_test.go b/did/resolver/bymethod_test.go new file mode 100644 index 0000000..6780382 --- /dev/null +++ b/did/resolver/bymethod_test.go @@ -0,0 +1,47 @@ +package resolver_test + +import ( + "context" + "testing" + + "github.com/fil-forge/ucantone/did" + "github.com/fil-forge/ucantone/did/resolver" + "github.com/stretchr/testify/require" +) + +func TestByMethod(t *testing.T) { + did1, err := did.Parse("did:web:example.com") + require.NoError(t, err) + did2, err := did.Parse("did:key:z6MkghfetkhrBZwUupJrv8MmYDH1JhKCQCGj1trbaZPA3dAd") + require.NoError(t, err) + did3, err := did.Parse("did:example:abc123") + require.NoError(t, err) + + var expectDID = func(expected did.DID) did.ResolverFunc { + return func(_ context.Context, input did.DID) (did.Document, error) { + require.Equal(t, expected, input) + return did.NewDocument(input), nil + } + } + + resolver := resolver.ByMethod{ + "web": expectDID(did1), + "key": expectDID(did2), + "example": expectDID(did3), + } + + doc, err := resolver.Resolve(nil, did1) + require.NoError(t, err) + require.Equal(t, did1, doc.ID) + + doc, err = resolver.Resolve(nil, did2) + require.NoError(t, err) + require.Equal(t, did2, doc.ID) + + doc, err = resolver.Resolve(nil, did3) + require.NoError(t, err) + require.Equal(t, did3, doc.ID) + + _, err = resolver.Resolve(nil, did.MustParse("did:unknown:abc123")) + require.Error(t, err) +} diff --git a/did/resolver/cached.go b/did/resolver/cached.go new file mode 100644 index 0000000..47c47cb --- /dev/null +++ b/did/resolver/cached.go @@ -0,0 +1,34 @@ +package resolver + +import ( + "context" + "time" + + "github.com/fil-forge/ucantone/did" + "github.com/patrickmn/go-cache" +) + +type Cached struct { + wrapped did.Resolver + cache *cache.Cache +} + +var _ did.Resolver = (*Cached)(nil) + +func NewCached(wrapped did.Resolver, ttl time.Duration) *Cached { + // items remain in the cache for `ttl`, expired items are purged every hour. + return &Cached{wrapped: wrapped, cache: cache.New(ttl, time.Hour)} +} + +func (c *Cached) Resolve(ctx context.Context, input did.DID) (did.Document, error) { + if out, found := c.cache.Get(input.String()); found { + return out.(did.Document), nil + } + out, err := c.wrapped.Resolve(ctx, input) + if err != nil { + return did.Document{}, err + } + c.cache.Set(input.String(), out, cache.DefaultExpiration) + + return out, nil +} diff --git a/did/resolver/cached_test.go b/did/resolver/cached_test.go new file mode 100644 index 0000000..f55a7b4 --- /dev/null +++ b/did/resolver/cached_test.go @@ -0,0 +1,126 @@ +package resolver_test + +import ( + "context" + "errors" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/fil-forge/ucantone/did" + "github.com/fil-forge/ucantone/did/resolver" + "github.com/stretchr/testify/require" +) + +func TestCached(t *testing.T) { + exampleDID, err := did.Parse("did:example:abc123") + require.NoError(t, err) + + t.Run("caches successful resolution", func(t *testing.T) { + var calls int + resolver := resolver.NewCached(did.ResolverFunc(func(ctx context.Context, d did.DID) (did.Document, error) { + calls++ + return did.NewDocument(d), nil + }), 100*time.Millisecond) + + result1, err := resolver.Resolve(t.Context(), exampleDID) + require.NoError(t, err) + require.Equal(t, did.NewDocument(exampleDID), result1) + require.Equal(t, 1, calls) + + result2, err := resolver.Resolve(t.Context(), exampleDID) + require.NoError(t, err) + require.Equal(t, did.NewDocument(exampleDID), result2) + require.Equal(t, 1, calls) + + time.Sleep(150 * time.Millisecond) + + result3, err := resolver.Resolve(t.Context(), exampleDID) + require.NoError(t, err) + require.Equal(t, did.NewDocument(exampleDID), result3) + require.Equal(t, 2, calls) + }) + + t.Run("does not cache errors", func(t *testing.T) { + var calls int + resolver := resolver.NewCached(did.ResolverFunc(func(ctx context.Context, d did.DID) (did.Document, error) { + calls++ + return did.Document{}, errors.New("resolution failed") + }), 100*time.Millisecond) + + _, err = resolver.Resolve(t.Context(), exampleDID) + require.Error(t, err) + require.Equal(t, 1, calls) + + _, err = resolver.Resolve(t.Context(), exampleDID) + require.Error(t, err) + require.Equal(t, 2, calls) + }) + + t.Run("handles concurrent access", func(t *testing.T) { + var calls atomic.Int32 + resolver := resolver.NewCached(did.ResolverFunc(func(ctx context.Context, d did.DID) (did.Document, error) { + calls.Add(1) + time.Sleep(10 * time.Millisecond) + return did.NewDocument(d), nil + }), time.Second) + + var wg sync.WaitGroup + results := make([]did.Document, 10) + errs := make([]error, 10) + for i := range 10 { + wg.Add(1) + go func(idx int) { + defer wg.Done() + results[idx], errs[idx] = resolver.Resolve(t.Context(), exampleDID) + }(i) + } + wg.Wait() + + for i := range 10 { + require.NoError(t, errs[i]) + require.Equal(t, did.NewDocument(exampleDID), results[i]) + } + + callsAfterWait := calls.Load() + + // Cache hit — no additional call. + _, err = resolver.Resolve(t.Context(), exampleDID) + require.NoError(t, err) + require.Equal(t, callsAfterWait, calls.Load()) + }) + + t.Run("handles different DIDs independently", func(t *testing.T) { + did1, err := did.Parse("did:example:one") + require.NoError(t, err) + did2, err := did.Parse("did:example:two") + require.NoError(t, err) + + var calls int + resolver := resolver.NewCached(did.ResolverFunc(func(ctx context.Context, d did.DID) (did.Document, error) { + calls++ + return did.NewDocument(d), nil + }), time.Second) + + result1, err := resolver.Resolve(t.Context(), did1) + require.NoError(t, err) + require.Equal(t, did.NewDocument(did1), result1) + require.Equal(t, 1, calls) + + result2, err := resolver.Resolve(t.Context(), did2) + require.NoError(t, err) + require.Equal(t, did.NewDocument(did2), result2) + require.Equal(t, 2, calls) + + result3, err := resolver.Resolve(t.Context(), did1) + require.NoError(t, err) + require.Equal(t, did.NewDocument(did1), result3) + require.Equal(t, 2, calls) + + result4, err := resolver.Resolve(t.Context(), did2) + require.NoError(t, err) + require.Equal(t, did.NewDocument(did2), result4) + require.Equal(t, 2, calls) + }) +} diff --git a/did/resolver/tiered.go b/did/resolver/tiered.go new file mode 100644 index 0000000..7fdf443 --- /dev/null +++ b/did/resolver/tiered.go @@ -0,0 +1,35 @@ +package resolver + +import ( + "context" + "errors" + "fmt" + + "github.com/fil-forge/ucantone/did" + verrs "github.com/fil-forge/ucantone/validator/errors" +) + +// Tiered is a DID resolver that tries multiple resolver tiers in order +// until one resolves the DID or all fail. Each tier is expected to return an +// error if it cannot resolve the DID so the next tier can be tried. If all +// tiers fail, the error from each tier is aggregated and returned in a single +// error. +type Tiered []did.Resolver + +var _ did.Resolver = (Tiered)(nil) + +func (c Tiered) Resolve(ctx context.Context, input did.DID) (did.Document, error) { + if len(c) == 0 { + return did.Document{}, verrs.NewDIDKeyResolutionError(input, fmt.Errorf("no resolvers configured")) + } + var errs []error + for _, resolver := range c { + doc, err := resolver.Resolve(ctx, input) + if err != nil { + errs = append(errs, err) + continue + } + return doc, nil + } + return did.Document{}, verrs.NewDIDKeyResolutionError(input, fmt.Errorf("not resolvable by any resolver: %w", errors.Join(errs...))) +} diff --git a/did/resolver/tiered_test.go b/did/resolver/tiered_test.go new file mode 100644 index 0000000..32f2121 --- /dev/null +++ b/did/resolver/tiered_test.go @@ -0,0 +1,86 @@ +package resolver_test + +import ( + "context" + "errors" + "testing" + + "github.com/fil-forge/ucantone/did" + "github.com/fil-forge/ucantone/did/resolver" + "github.com/stretchr/testify/require" +) + +func TestTiered(t *testing.T) { + resolvedDID, err := did.Parse("did:example:abc123") + require.NoError(t, err) + + t.Run("returns from the first resolver when it succeeds", func(t *testing.T) { + tieredResolver := &resolver.Tiered{ + did.ResolverFunc(func(ctx context.Context, input did.DID) (did.Document, error) { + return did.NewDocument(resolvedDID), nil + }), + did.ResolverFunc(func(ctx context.Context, input did.DID) (did.Document, error) { + require.Fail(t, "second resolver should not be called when first resolver succeeds") + return did.Document{}, nil + }), + } + + result, err := tieredResolver.Resolve(t.Context(), resolvedDID) + require.NoError(t, err) + require.Equal(t, did.NewDocument(resolvedDID), result) + }) + + t.Run("falls through to later resolver when earlier resolvers fail", func(t *testing.T) { + tieredResolver := &resolver.Tiered{ + did.ResolverFunc(func(ctx context.Context, input did.DID) (did.Document, error) { + return did.Document{}, errors.New("first resolver failed") + }), + did.ResolverFunc(func(ctx context.Context, input did.DID) (did.Document, error) { + return did.Document{}, errors.New("second resolver failed") + }), + did.ResolverFunc(func(ctx context.Context, input did.DID) (did.Document, error) { + return did.NewDocument(resolvedDID), nil + }), + } + + result, err := tieredResolver.Resolve(t.Context(), resolvedDID) + require.NoError(t, err) + require.Equal(t, did.NewDocument(resolvedDID), result) + }) + + t.Run("returns joined error when all tiers fail", func(t *testing.T) { + tieredResolver := &resolver.Tiered{ + did.ResolverFunc(func(ctx context.Context, input did.DID) (did.Document, error) { + return did.Document{}, errors.New("first resolver failed") + }), + did.ResolverFunc(func(ctx context.Context, input did.DID) (did.Document, error) { + return did.Document{}, errors.New("second resolver failed") + }), + did.ResolverFunc(func(ctx context.Context, input did.DID) (did.Document, error) { + return did.Document{}, errors.New("third resolver failed") + }), + } + + _, err := tieredResolver.Resolve(t.Context(), resolvedDID) + + var joinedError interface{ Unwrap() []error } + require.ErrorAs(t, err, &joinedError) + + require.Contains(t, err.Error(), "not resolvable by any resolver") + unwrappedErrors := joinedError.Unwrap() + require.Len(t, unwrappedErrors, 3) + require.Contains(t, unwrappedErrors[0].Error(), "first resolver failed") + require.Contains(t, unwrappedErrors[1].Error(), "second resolver failed") + require.Contains(t, unwrappedErrors[2].Error(), "third resolver failed") + }) + + t.Run("returns error with no tiers configured", func(t *testing.T) { + tieredResolver := &resolver.Tiered{ + // No resolvers configured + } + _, err := tieredResolver.Resolve(t.Context(), resolvedDID) + require.Error(t, err) + require.Contains(t, err.Error(), "unable to resolve") + require.Contains(t, err.Error(), "no resolvers configured") + }) +} diff --git a/did/resolver/wellknown.go b/did/resolver/wellknown.go new file mode 100644 index 0000000..11cad7e --- /dev/null +++ b/did/resolver/wellknown.go @@ -0,0 +1,23 @@ +package resolver + +import ( + "context" + "fmt" + + "github.com/fil-forge/ucantone/did" + verrs "github.com/fil-forge/ucantone/validator/errors" +) + +// WellKnown is a simple resolver that looks up DIDs in a local mapping. +type WellKnown map[did.DID]did.Document + +var _ did.Resolver = (WellKnown)(nil) + +func (wk WellKnown) Resolve(_ context.Context, input did.DID) (did.Document, error) { + // ctx is unused; this implementation only looks in a local mapping. + dk, ok := wk[input] + if !ok { + return did.Document{}, verrs.NewDIDKeyResolutionError(input, fmt.Errorf("not found in mapping: %s", input)) + } + return dk, nil +} diff --git a/did/resolver/wellknown_test.go b/did/resolver/wellknown_test.go new file mode 100644 index 0000000..c250a0e --- /dev/null +++ b/did/resolver/wellknown_test.go @@ -0,0 +1,36 @@ +package resolver_test + +import ( + "testing" + + "github.com/fil-forge/ucantone/did" + "github.com/fil-forge/ucantone/did/resolver" + "github.com/stretchr/testify/require" +) + +func TestWellKnown(t *testing.T) { + did1, err := did.Parse("did:web:example.com") + require.NoError(t, err) + did2, err := did.Parse("did:key:z6MkghfetkhrBZwUupJrv8MmYDH1JhKCQCGj1trbaZPA3dAd") + require.NoError(t, err) + did3, err := did.Parse("did:web:example.org") + require.NoError(t, err) + + wellKnownResolver := resolver.WellKnown{ + did1: did.NewDocument(did1), + did2: did.NewDocument(did2), + did3: did.NewDocument(did3), + } + + result, err := wellKnownResolver.Resolve(t.Context(), did1) + require.NoError(t, err) + require.Equal(t, did1, result.ID) + + result, err = wellKnownResolver.Resolve(t.Context(), did2) + require.NoError(t, err) + require.Equal(t, did2, result.ID) + + result, err = wellKnownResolver.Resolve(t.Context(), did3) + require.NoError(t, err) + require.Equal(t, did3, result.ID) +} diff --git a/did/resolver_test.go b/did/resolver_test.go new file mode 100644 index 0000000..39ae2ec --- /dev/null +++ b/did/resolver_test.go @@ -0,0 +1,101 @@ +package did_test + +import ( + "context" + "errors" + "testing" + + "github.com/fil-forge/ucantone/did" + "github.com/stretchr/testify/require" +) + +func TestResolverFunc(t *testing.T) { + t.Run("calls underlying function", func(t *testing.T) { + d, err := did.Parse("did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK") + require.NoError(t, err) + + want := did.NewDocument(d) + var resolver did.ResolverFunc = func(_ context.Context, resolved did.DID) (did.Document, error) { + require.Equal(t, d, resolved) + return want, nil + } + + got, err := resolver.Resolve(t.Context(), d) + require.NoError(t, err) + require.Equal(t, want, got) + }) + + t.Run("propagates error", func(t *testing.T) { + d, err := did.Parse("did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK") + require.NoError(t, err) + + sentinel := errors.New("resolve failed") + var resolver did.ResolverFunc = func(_ context.Context, _ did.DID) (did.Document, error) { + return did.Document{}, sentinel + } + + _, err = resolver.Resolve(t.Context(), d) + require.ErrorIs(t, err, sentinel) + }) +} + +func TestResolverMap(t *testing.T) { + keyDID, err := did.Parse("did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK") + require.NoError(t, err) + + webDID, err := did.Parse("did:web:example.com") + require.NoError(t, err) + + t.Run("routes to registered method", func(t *testing.T) { + want := did.NewDocument(keyDID) + rm := did.ResolverMap{ + "key": did.ResolverFunc(func(_ context.Context, _ did.DID) (did.Document, error) { + return want, nil + }), + } + + got, err := rm.Resolve(t.Context(), keyDID) + require.NoError(t, err) + require.Equal(t, want, got) + }) + + t.Run("routes to correct method among multiple", func(t *testing.T) { + keyDoc := did.NewDocument(keyDID) + webDoc := did.NewDocument(webDID) + + rm := did.ResolverMap{ + "key": did.ResolverFunc(func(_ context.Context, _ did.DID) (did.Document, error) { + return keyDoc, nil + }), + "web": did.ResolverFunc(func(_ context.Context, _ did.DID) (did.Document, error) { + return webDoc, nil + }), + } + + got, err := rm.Resolve(t.Context(), keyDID) + require.NoError(t, err) + require.Equal(t, keyDoc, got) + + got, err = rm.Resolve(t.Context(), webDID) + require.NoError(t, err) + require.Equal(t, webDoc, got) + }) + + t.Run("returns UnsupportedMethodError for unknown method", func(t *testing.T) { + rm := did.ResolverMap{} + + _, err := rm.Resolve(t.Context(), keyDID) + require.Error(t, err) + + var notSupported did.UnsupportedMethodError + require.ErrorAs(t, err, ¬Supported) + require.Equal(t, "key", notSupported.DID.Method()) + }) + + t.Run("UnsupportedMethodError message contains method name", func(t *testing.T) { + rm := did.ResolverMap{} + + _, err := rm.Resolve(t.Context(), keyDID) + require.ErrorContains(t, err, "key") + }) +} diff --git a/did/url.go b/did/url.go new file mode 100644 index 0000000..78bccce --- /dev/null +++ b/did/url.go @@ -0,0 +1,42 @@ +package did + +import ( + "encoding/json" + "net/url" +) + +// URL is a wrapper around url.URL that implements json.Marshaler and +// json.Unmarshaler. +type URL struct { + *url.URL +} + +func ParseURL(s string) (URL, error) { + parsed, err := url.Parse(s) + if err != nil { + return URL{}, err + } + return URL{URL: parsed}, nil +} + +// UnmarshalJSON implements the json.Unmarshaler interface. +func (u *URL) UnmarshalJSON(data []byte) error { + var s string + if err := json.Unmarshal(data, &s); err != nil { + return err + } + parsed, err := url.Parse(s) + if err != nil { + return err + } + u.URL = parsed + return nil +} + +// MarshalJSON implements the json.Marshaler interface. +func (u URL) MarshalJSON() ([]byte, error) { + if u.URL == nil { + return json.Marshal(nil) + } + return json.Marshal(u.String()) +} diff --git a/did/url_test.go b/did/url_test.go new file mode 100644 index 0000000..14df635 --- /dev/null +++ b/did/url_test.go @@ -0,0 +1,36 @@ +package did_test + +import ( + "encoding/json" + "testing" + + "github.com/fil-forge/ucantone/did" + "github.com/stretchr/testify/require" +) + +func TestURL(t *testing.T) { + t.Run("to JSON", func(t *testing.T) { + url, err := did.ParseURL("https://example.com") + require.NoError(t, err) + b, err := json.Marshal(url) + require.NoError(t, err) + require.JSONEq(t, `"https://example.com"`, string(b)) + }) + + t.Run("from JSON", func(t *testing.T) { + t.Run("legal URL", func(t *testing.T) { + var url did.URL + err := json.Unmarshal([]byte(`"https://example.com"`), &url) + require.NoError(t, err) + expectedURL, err := did.ParseURL("https://example.com") + require.NoError(t, err) + require.Equal(t, expectedURL, url) + }) + + t.Run("illegal URL", func(t *testing.T) { + var url did.URL + err := json.Unmarshal([]byte(`":not a url:"`), &url) + require.Error(t, err) + }) + }) +} diff --git a/did/verificationmethod.go b/did/verificationmethod.go new file mode 100644 index 0000000..e7cc4e4 --- /dev/null +++ b/did/verificationmethod.go @@ -0,0 +1,138 @@ +package did + +import ( + "encoding/json" + "fmt" + "time" +) + +// https://www.w3.org/TR/cid-1.0/#verification-methods +type VerificationMethod struct { + ID URL `json:"id"` + Controller DID `json:"controller"` + Expires *DateTimeStamp `json:"expires,omitempty"` + Revoked *DateTimeStamp `json:"revoked,omitempty"` + Type string + Material GenericMap +} + +// ValidAt reports whether the verification method is valid at time t. +func (v VerificationMethod) ValidAt(t time.Time) bool { + return !v.ExpiredAt(t) && !v.RevokedAt(t) +} + +// ExpiredAt reports whether the verification method is expired at time t. +func (v VerificationMethod) ExpiredAt(t time.Time) bool { + if v.Expires != nil && !t.Before(v.Expires.Time()) { + return true + } + return false +} + +// RevokedAt reports whether the verification method is revoked at time t. +func (v VerificationMethod) RevokedAt(t time.Time) bool { + if v.Revoked != nil && !t.Before(v.Revoked.Time()) { + return true + } + return false +} + +func (v VerificationMethod) String() string { + return fmt.Sprintf("VerificationMethod{id=%s, type=%s, controller=%s, expires=%v, revoked=%v, material=%v}", + v.ID, v.Type, v.Controller, v.Expires, v.Revoked, v.Material) +} + +func (v VerificationMethod) Equal(other VerificationMethod) bool { + if v.ID.String() != other.ID.String() || v.Type != other.Type || v.Controller != other.Controller { + return false + } + if (v.Expires == nil) != (other.Expires == nil) || (v.Revoked == nil) != (other.Revoked == nil) { + return false + } + if v.Expires != nil && *v.Expires != *other.Expires { + return false + } + if v.Revoked != nil && *v.Revoked != *other.Revoked { + return false + } + if len(v.Material) != len(other.Material) { + return false + } + for k, val := range v.Material { + if other.Material[k] != val { + return false + } + } + return true +} + +type vmBase struct { + ID URL `json:"id"` + Controller DID `json:"controller"` + Expires *DateTimeStamp `json:"expires,omitempty"` + Revoked *DateTimeStamp `json:"revoked,omitempty"` +} + +func (v *VerificationMethod) UnmarshalJSON(b []byte) error { + var base vmBase + if err := json.Unmarshal(b, &base); err != nil { + return err + } + v.ID = base.ID + v.Controller = base.Controller + v.Expires = base.Expires + v.Revoked = base.Revoked + + // Unmarshal all fields into a raw map, strip base keys and "type", + // leaving only the verification material fields. + var raw map[string]json.RawMessage + if err := json.Unmarshal(b, &raw); err != nil { + return err + } + for _, k := range []string{"id", "controller", "expires", "revoked"} { + delete(raw, k) + } + + if err := json.Unmarshal(raw["type"], &v.Type); err != nil { + return err + } + delete(raw, "type") + + extraJSON, err := json.Marshal(raw) + if err != nil { + return err + } + + return json.Unmarshal(extraJSON, &v.Material) +} + +func (v VerificationMethod) MarshalJSON() ([]byte, error) { + out := map[string]json.RawMessage{} + + // Marshal material fields first so base fields win on collision. + if v.Material != nil { + materialJSON, err := json.Marshal(v.Material) + if err != nil { + return nil, err + } + if err := json.Unmarshal(materialJSON, &out); err != nil { + return nil, err + } + } + + var err error + out["type"], err = json.Marshal(v.Type) + if err != nil { + return nil, err + } + + baseJSON, err := json.Marshal(vmBase{v.ID, v.Controller, v.Expires, v.Revoked}) + if err != nil { + return nil, err + } + if err := json.Unmarshal(baseJSON, &out); err != nil { + return nil, err + } + + return json.Marshal(out) +} diff --git a/did/verificationmethod_test.go b/did/verificationmethod_test.go new file mode 100644 index 0000000..de6362a --- /dev/null +++ b/did/verificationmethod_test.go @@ -0,0 +1,101 @@ +package did_test + +import ( + "encoding/json" + "testing" + + "github.com/fil-forge/ucantone/did" + "github.com/stretchr/testify/require" +) + +func TestVerificationMethod_MarshalJSON(t *testing.T) { + keyID, err := did.ParseURL("did:example:123#key-1") + require.NoError(t, err) + controller, err := did.Parse("did:example:123") + require.NoError(t, err) + + t.Run("Multikey", func(t *testing.T) { + vm := did.VerificationMethod{ + ID: keyID, + Controller: controller, + Type: did.MultikeyVerificationMethodType, + Material: did.GenericMap{did.MultikeyPublicKeyMultibaseProp: "zABC"}, + } + b, err := json.Marshal(vm) + require.NoError(t, err) + require.JSONEq(t, `{ + "id": "did:example:123#key-1", + "type": "Multikey", + "controller": "did:example:123", + "publicKeyMultibase": "zABC" + }`, string(b)) + }) + + t.Run("JsonWebKey", func(t *testing.T) { + vm := did.VerificationMethod{ + ID: keyID, + Controller: controller, + Type: did.JsonWebKeyVerificationMethodType, + Material: did.GenericMap{did.JsonWebKeyPublicKeyJwkProp: did.GenericMap{"kty": "OKP", "crv": "Ed25519", "x": "somebase64"}}, + } + b, err := json.Marshal(vm) + require.NoError(t, err) + require.JSONEq(t, `{ + "id": "did:example:123#key-1", + "type": "JsonWebKey", + "controller": "did:example:123", + "publicKeyJwk": {"kty": "OKP", "crv": "Ed25519", "x": "somebase64"} + }`, string(b)) + }) +} + +func TestVerificationMethod_UnmarshalJSON(t *testing.T) { + t.Run("Multikey", func(t *testing.T) { + data := `{ + "id": "did:example:123#key-1", + "type": "Multikey", + "controller": "did:example:123", + "publicKeyMultibase": "zH3C2AVvLMv6gmMNam3uVAjZpfkcJCwDwnZn6z3wXmqPV" + }` + var vm did.VerificationMethod + err := json.Unmarshal([]byte(data), &vm) + require.NoError(t, err) + require.Equal(t, "did:example:123#key-1", vm.ID.String()) + require.Equal(t, did.MultikeyVerificationMethodType, vm.Type) + require.Equal(t, "did:example:123", vm.Controller.String()) + require.Equal(t, "zH3C2AVvLMv6gmMNam3uVAjZpfkcJCwDwnZn6z3wXmqPV", vm.Material[did.MultikeyPublicKeyMultibaseProp]) + }) + + t.Run("JsonWebKey", func(t *testing.T) { + data := `{ + "id": "did:example:123#key-2", + "type": "JsonWebKey", + "controller": "did:example:123", + "publicKeyJwk": {"kty": "OKP", "crv": "Ed25519", "x": "somebase64"} + }` + var vm did.VerificationMethod + err := json.Unmarshal([]byte(data), &vm) + require.NoError(t, err) + require.Equal(t, did.JsonWebKeyVerificationMethodType, vm.Type) + jwk, ok := vm.Material[did.JsonWebKeyPublicKeyJwkProp].(map[string]any) + require.True(t, ok) + require.Equal(t, "OKP", jwk["kty"]) + }) + + t.Run("unknown type", func(t *testing.T) { + data := `{ + "id": "did:example:123#key-3", + "type": "SomeUnknownType", + "controller": "did:example:123", + "customField": "customValue" + }` + var vm did.VerificationMethod + err := json.Unmarshal([]byte(data), &vm) + require.NoError(t, err) + require.Equal(t, "SomeUnknownType", vm.Type) + require.Equal(t, "customValue", vm.Material["customField"]) + require.NotContains(t, vm.Material, "id") + require.NotContains(t, vm.Material, "type") + require.NotContains(t, vm.Material, "controller") + }) +} diff --git a/did/verificationmethods.go b/did/verificationmethods.go new file mode 100644 index 0000000..7d9a544 --- /dev/null +++ b/did/verificationmethods.go @@ -0,0 +1,52 @@ +package did + +import ( + "encoding/json" + "fmt" +) + +type VerificationMethods map[string]VerificationMethod + +func (vms *VerificationMethods) Add(vm VerificationMethod) error { + if *vms == nil { + *vms = make(VerificationMethods) + } + if existing, ok := (*vms)[vm.ID.String()]; ok { + if !existing.Equal(vm) { + return fmt.Errorf("conflicting definitions for verification method %q", vm.ID.String()) + } + return nil + } + (*vms)[vm.ID.String()] = vm + return nil +} + +func (v *VerificationMethods) All() []VerificationMethod { + var all []VerificationMethod + for _, vm := range *v { + all = append(all, vm) + } + return all +} + +func (vms VerificationMethods) MarshalJSON() ([]byte, error) { + methods := make([]VerificationMethod, 0, len(vms)) + for _, vm := range vms { + methods = append(methods, vm) + } + return json.Marshal(methods) +} + +func (v *VerificationMethods) UnmarshalJSON(b []byte) error { + var vms []VerificationMethod + err := json.Unmarshal(b, &vms) + if err != nil { + return err + } + for _, vm := range vms { + if err := v.Add(vm); err != nil { + return err + } + } + return nil +} diff --git a/did/verificationmethodtypes.go b/did/verificationmethodtypes.go new file mode 100644 index 0000000..c88c341 --- /dev/null +++ b/did/verificationmethodtypes.go @@ -0,0 +1,17 @@ +package did + +// https://www.w3.org/TR/cid-1.0/#Multikey +const MultikeyVerificationMethodType = "Multikey" + +const ( + MultikeyPublicKeyMultibaseProp = "publicKeyMultibase" + MultikeySecretKeyMultibaseProp = "secretKeyMultibase" +) + +// https://www.w3.org/TR/cid-1.0/#JsonWebKey +const JsonWebKeyVerificationMethodType = "JsonWebKey" + +const ( + JsonWebKeyPublicKeyJwkProp = "publicKeyJwk" + JsonWebKeySecretKeyJwkProp = "secretKeyJwk" +) diff --git a/did/verificationrelationship.go b/did/verificationrelationship.go new file mode 100644 index 0000000..dfa5777 --- /dev/null +++ b/did/verificationrelationship.go @@ -0,0 +1,78 @@ +package did + +import ( + "encoding/json" + "errors" +) + +type VerificationRelationship struct { + allMethods *VerificationMethods + relationshipMethods []URL +} + +func (vr *VerificationRelationship) Add(method VerificationMethod) error { + if vr.allMethods == nil { + return errors.New("verification relationship is not associated with a document") + } + if err := vr.allMethods.Add(method); err != nil { + return err + } + vr.relationshipMethods = append(vr.relationshipMethods, method.ID) + return nil +} + +func (vr *VerificationRelationship) All() []VerificationMethod { + vms := make([]VerificationMethod, 0, len(vr.relationshipMethods)) + for _, u := range vr.relationshipMethods { + if vm, ok := (*vr.allMethods)[u.String()]; ok { + vms = append(vms, vm) + } + } + return vms +} + +func (vr *VerificationRelationship) Get(i int) URL { + return vr.relationshipMethods[i] +} + +func (vr *VerificationRelationship) Len() int { + return len(vr.relationshipMethods) +} + +func (vr *VerificationRelationship) IsZero() bool { + return len(vr.relationshipMethods) == 0 +} + +func (vr *VerificationRelationship) MarshalJSON() ([]byte, error) { + return json.Marshal(vr.relationshipMethods) +} + +func (vr *VerificationRelationship) UnmarshalJSON(data []byte) error { + var raws []json.RawMessage + err := json.Unmarshal(data, &raws) + if err != nil { + return err + } + + for _, raw := range raws { + var u URL + err := json.Unmarshal(raw, &u) + if err == nil { + vr.relationshipMethods = append(vr.relationshipMethods, u) + continue + } + var typeErr *json.UnmarshalTypeError + if !errors.As(err, &typeErr) { + return err + } + var vm VerificationMethod + if err := json.Unmarshal(raw, &vm); err != nil { + return err + } + if err := vr.allMethods.Add(vm); err != nil { + return err + } + vr.relationshipMethods = append(vr.relationshipMethods, vm.ID) + } + return nil +} diff --git a/did/verificationrelationship_test.go b/did/verificationrelationship_test.go new file mode 100644 index 0000000..44ebf33 --- /dev/null +++ b/did/verificationrelationship_test.go @@ -0,0 +1,34 @@ +package did_test + +import ( + "testing" + + "github.com/fil-forge/ucantone/did" + "github.com/stretchr/testify/require" +) + +func TestVerificationRelationship_Add(t *testing.T) { + d, err := did.Parse("did:example:123456789abcdefghi") + require.NoError(t, err) + doc := did.NewDocument(d) + vm := did.VerificationMethod{ + ID: doc.Fragment("key-1"), + Controller: d, + Type: did.MultikeyVerificationMethodType, + Material: did.GenericMap{did.MultikeyPublicKeyMultibaseProp: "zABC"}, + } + err = doc.VerificationMethods.Add(vm) + require.NoError(t, err) + + require.Equal(t, 0, doc.Authentication.Len()) + + err = doc.Authentication.Add(vm) + require.NoError(t, err) + require.Equal(t, 1, doc.Authentication.Len()) + + var authVMIds []string + for _, authVM := range doc.Authentication.All() { + authVMIds = append(authVMIds, authVM.ID.String()) + } + require.Equal(t, []string{vm.ID.String()}, authVMIds) +} diff --git a/did/web/options.go b/did/web/options.go new file mode 100644 index 0000000..e0c7e4a --- /dev/null +++ b/did/web/options.go @@ -0,0 +1,64 @@ +package web + +import ( + "fmt" + "net/http" + "time" + + "github.com/gobwas/glob" +) + +type config struct { + timeout time.Duration + insecure bool + globs map[string]glob.Glob + transport http.RoundTripper +} + +type Option func(*config) error + +func WithTimeout(timeout time.Duration) Option { + return func(c *config) error { + if timeout == 0 { + return fmt.Errorf("timeout cannot be zero") + } + c.timeout = timeout + return nil + } +} + +func WithInsecure(insecure bool) Option { + return func(c *config) error { + c.insecure = insecure + return nil + } +} + +// WithPatterns restricts resolution to DIDs with identifiers which match the +// provided glob patterns. Patterns are matched against the DID identifier only, +// not the `did:web:` prefix. +func WithPatterns(patterns ...string) Option { + return func(c *config) error { + for _, p := range patterns { + g, err := glob.Compile(p) + if err != nil { + return fmt.Errorf("compiling pattern %q: %w", p, err) + } + if c.globs == nil { + c.globs = map[string]glob.Glob{} + } + c.globs[p] = g + } + return nil + } +} + +func WithTransport(transport http.RoundTripper) Option { + return func(c *config) error { + if transport == nil { + return fmt.Errorf("transport cannot be nil") + } + c.transport = transport + return nil + } +} diff --git a/did/web/web.go b/did/web/web.go new file mode 100644 index 0000000..4733d75 --- /dev/null +++ b/did/web/web.go @@ -0,0 +1,109 @@ +package web + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strings" + "time" + + "github.com/fil-forge/ucantone/did" +) + +const Method = "web" + +const WellKnownDIDPath = "/.well-known/did.json" + +var _ did.Resolver = (*Resolver)(nil) + +// Resolver resolves a did:web DID to a DID Document by fetching the document +// over HTTPS (or HTTP if configured), according to the did:web method spec. +// https://w3c-ccg.github.io/did-method-web/#read-resolve +type Resolver struct { + cfg config +} + +func NewResolver(options ...Option) (*Resolver, error) { + cfg := &config{ + // default timeout of 10 seconds + timeout: 10 * time.Second, + insecure: false, + } + for _, opt := range options { + if err := opt(cfg); err != nil { + return nil, err + } + } + + return &Resolver{cfg: *cfg}, nil +} + +func (r *Resolver) Resolve(ctx context.Context, d did.DID) (did.Document, error) { + if err := did.ValidateMethod(d, Method); err != nil { + return did.Document{}, err + } + + if r.cfg.globs != nil { + match := false + for _, g := range r.cfg.globs { + if match = g.Match(d.Identifier()); match { + break + } + } + if !match { + return did.Document{}, fmt.Errorf("resolution of %s via HTTP not permitted", d) + } + } + + url := documentURL(d, r.cfg.insecure) + + httpClient := &http.Client{ + Timeout: r.cfg.timeout, + Transport: r.cfg.transport, + } + req, err := http.NewRequestWithContext(ctx, "GET", url.String(), nil) + if err != nil { + return did.Document{}, fmt.Errorf("creating HTTP request: %w", err) + } + resp, err := httpClient.Do(req) + if err != nil { + return did.Document{}, fmt.Errorf("performing HTTP request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return did.Document{}, fmt.Errorf("unexpected status: %d", resp.StatusCode) + } + + var doc did.Document + if err := json.NewDecoder(resp.Body).Decode(&doc); err != nil { + return did.Document{}, fmt.Errorf("parsing DID document JSON: %w", err) + } + + return doc, nil +} + +func documentURL(d did.DID, insecure bool) url.URL { + scheme := "https" + if insecure { + scheme = "http" + } + + segments := strings.Split(d.Identifier(), ":") + host := segments[0] + // Unescape percent-encoded colons in the host, to support port numbers. + host = strings.ReplaceAll(host, "%3A", ":") + path := "" + if len(segments) > 1 { + path = "/" + strings.Join(segments[1:], "/") + "/did.json" + } else { + path = WellKnownDIDPath + } + return url.URL{ + Scheme: scheme, + Host: host, + Path: path, + } +} diff --git a/did/web/web_test.go b/did/web/web_test.go new file mode 100644 index 0000000..c56aed8 --- /dev/null +++ b/did/web/web_test.go @@ -0,0 +1,208 @@ +package web_test + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "strings" + "testing" + "time" + + "github.com/fil-forge/ucantone/did" + "github.com/fil-forge/ucantone/did/web" + "github.com/stretchr/testify/require" +) + +func TestNewResolver(t *testing.T) { + t.Run("creates resolver with default timeout", func(t *testing.T) { + resolver, err := web.NewResolver() + require.NoError(t, err) + require.NotNil(t, resolver) + }) + + t.Run("creates resolver with custom timeout", func(t *testing.T) { + resolver, err := web.NewResolver(web.WithTimeout(5*time.Second), web.WithInsecure(true)) + require.NoError(t, err) + require.NotNil(t, resolver) + }) + + t.Run("fails with zero timeout", func(t *testing.T) { + resolver, err := web.NewResolver(web.WithTimeout(0)) + require.Error(t, err) + require.Contains(t, err.Error(), "timeout cannot be zero") + require.Nil(t, resolver) + }) +} + +// roundTripFunc implements http.RoundTripper using a plain function. +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } + +func TestResolver(t *testing.T) { + t.Run("resolves a pathless DID", func(t *testing.T) { + doc := did.NewDocument(did.MustParse("did:web:example.com")) + doc.AlsoKnownAs = []did.DID{did.MustParse("did:example:abc123")} + body, err := json.Marshal(doc) + require.NoError(t, err) + + transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { + require.Equal(t, "https://example.com/.well-known/did.json", r.URL.String()) + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(bytes.NewReader(body)), + }, nil + }) + + resolver, err := web.NewResolver(web.WithTransport(transport)) + require.NoError(t, err) + + result, resolveErr := resolver.Resolve(t.Context(), did.MustParse("did:web:example.com")) + require.NoError(t, resolveErr) + require.Equal(t, did.MustParse("did:web:example.com"), result.ID) + require.Equal(t, []did.DID{did.MustParse("did:example:abc123")}, result.AlsoKnownAs) + }) + + t.Run("resolves a pathful DID", func(t *testing.T) { + doc := did.NewDocument(did.MustParse("did:web:example.com:users:alice")) + doc.AlsoKnownAs = []did.DID{did.MustParse("did:example:abc123")} + body, err := json.Marshal(doc) + require.NoError(t, err) + + transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { + require.Equal(t, "https://example.com/users/alice/did.json", r.URL.String()) + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(bytes.NewReader(body)), + }, nil + }) + + resolver, err := web.NewResolver(web.WithTransport(transport)) + require.NoError(t, err) + + result, resolveErr := resolver.Resolve(t.Context(), did.MustParse("did:web:example.com:users:alice")) + require.NoError(t, resolveErr) + require.Equal(t, did.MustParse("did:web:example.com:users:alice"), result.ID) + require.Equal(t, []did.DID{did.MustParse("did:example:abc123")}, result.AlsoKnownAs) + }) + + t.Run("resolves a portly DID", func(t *testing.T) { + doc := did.NewDocument(did.MustParse("did:web:example.com%3A3000")) + doc.AlsoKnownAs = []did.DID{did.MustParse("did:example:abc123")} + body, err := json.Marshal(doc) + require.NoError(t, err) + + transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { + require.Equal(t, "https://example.com:3000/.well-known/did.json", r.URL.String()) + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(bytes.NewReader(body)), + }, nil + }) + + resolver, err := web.NewResolver(web.WithTransport(transport)) + require.NoError(t, err) + + result, resolveErr := resolver.Resolve(t.Context(), did.MustParse("did:web:example.com%3A3000")) + require.NoError(t, resolveErr) + require.Equal(t, did.MustParse("did:web:example.com%3A3000"), result.ID) + require.Equal(t, []did.DID{did.MustParse("did:example:abc123")}, result.AlsoKnownAs) + }) + + t.Run("returns error on HTTP 404", func(t *testing.T) { + transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusNotFound, + Body: io.NopCloser(strings.NewReader("")), + }, nil + }) + + resolver, err := web.NewResolver(web.WithTransport(transport)) + require.NoError(t, err) + + _, resolveErr := resolver.Resolve(t.Context(), did.MustParse("did:web:example.com")) + require.ErrorContains(t, resolveErr, "unexpected status: 404") + }) + + t.Run("returns error on invalid JSON", func(t *testing.T) { + transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader("invalid json")), + }, nil + }) + + resolver, err := web.NewResolver(web.WithTransport(transport)) + require.NoError(t, err) + + _, resolveErr := resolver.Resolve(t.Context(), did.MustParse("did:web:example.com")) + require.ErrorContains(t, resolveErr, "parsing DID document JSON") + }) + + t.Run("returns error when pattern blocks resolution", func(t *testing.T) { + resolver, err := web.NewResolver(web.WithPatterns("*.example.com")) + require.NoError(t, err) + + _, resolveErr := resolver.Resolve(t.Context(), did.MustParse("did:web:notfound.com")) + require.ErrorContains(t, resolveErr, "resolution of did:web:notfound.com via HTTP not permitted") + }) + + t.Run("allows insecure HTTP resolution", func(t *testing.T) { + doc := did.NewDocument(did.MustParse("did:web:example.com")) + body, err := json.Marshal(doc) + require.NoError(t, err) + + transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { + require.Equal(t, "http", r.URL.Scheme) + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(bytes.NewReader(body)), + }, nil + }) + + resolver, err := web.NewResolver(web.WithInsecure(true), web.WithTransport(transport)) + require.NoError(t, err) + + result, resolveErr := resolver.Resolve(t.Context(), did.MustParse("did:web:example.com")) + require.NoError(t, resolveErr) + require.Equal(t, did.MustParse("did:web:example.com"), result.ID) + }) + + t.Run("returns error when timeout is exceeded", func(t *testing.T) { + doc := did.NewDocument(did.MustParse("did:web:example.com")) + body, err := json.Marshal(doc) + require.NoError(t, err) + + // Responds after 100ms, or immediately if context is cancelled. + transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { + select { + // The roundtripper is synchronous, so we have to cooperate. + case <-r.Context().Done(): + return nil, r.Context().Err() + case <-time.After(10 * time.Millisecond): + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(bytes.NewReader(body)), + }, nil + } + }) + + slowResolver, err := web.NewResolver(web.WithTimeout(5*time.Millisecond), web.WithTransport(transport)) + require.NoError(t, err) + _, resolveErr := slowResolver.Resolve(t.Context(), did.MustParse("did:web:example.com")) + require.ErrorContains(t, resolveErr, "context deadline exceeded") + + fastResolver, err := web.NewResolver(web.WithTimeout(20*time.Millisecond), web.WithTransport(transport)) + require.NoError(t, err) + result, resolveErr := fastResolver.Resolve(t.Context(), did.MustParse("did:web:example.com")) + require.NoError(t, resolveErr) + require.Equal(t, did.MustParse("did:web:example.com"), result.ID) + }) +} diff --git a/errors/datamodel/errors.go b/errors/datamodel/errors.go index c8648c6..0deae13 100644 --- a/errors/datamodel/errors.go +++ b/errors/datamodel/errors.go @@ -1,16 +1,52 @@ package datamodel +import ( + "fmt" + "io" +) + type ErrorModel struct { ErrorName string `cborgen:"name"` Message string `cborgen:"message"` } -func (u ErrorModel) Name() string { - return u.ErrorName +func (em ErrorModel) Name() string { + return em.ErrorName } -func (u ErrorModel) Error() string { - return u.Message +func (em ErrorModel) Error() string { + return em.Message } var _ error = (*ErrorModel)(nil) + +type ErrorModelWithCause struct { + ErrorName string + Message string + Cause error +} + +func (emc ErrorModelWithCause) Name() string { + return emc.ErrorName +} + +func (emc ErrorModelWithCause) Error() string { + if emc.Cause == nil { + return emc.Message + } + return fmt.Sprintf("%s: %s", emc.Message, emc.Cause.Error()) +} + +func (emc ErrorModelWithCause) Unwrap() error { + return emc.Cause +} + +func (emc ErrorModelWithCause) MarshalCBOR(w io.Writer) error { + em := ErrorModel{ + ErrorName: emc.ErrorName, + Message: emc.Error(), + } + return em.MarshalCBOR(w) +} + +var _ error = (*ErrorModelWithCause)(nil) diff --git a/examples/binding_test.go b/examples/binding_test.go index 3e4b8ff..70377e3 100644 --- a/examples/binding_test.go +++ b/examples/binding_test.go @@ -11,7 +11,7 @@ import ( "github.com/fil-forge/ucantone/client" "github.com/fil-forge/ucantone/examples/types" "github.com/fil-forge/ucantone/execution" - "github.com/fil-forge/ucantone/principal/ed25519" + "github.com/fil-forge/ucantone/multikey/ed25519" "github.com/fil-forge/ucantone/server" "github.com/fil-forge/ucantone/ucan/command" "github.com/fil-forge/ucantone/ucan/invocation" @@ -32,11 +32,11 @@ var echoCmd = binding.Bind[*types.EchoArguments, *types.EchoArguments](command.M // itself as an in-process HTTP transport, so the invocation runs through the // real encode/decode/handle/encode/decode cycle without binding a port. func TestBindingEndToEnd(t *testing.T) { - service, err := ed25519.Generate() + service, err := ed25519.GenerateIssuer() if err != nil { t.Fatal(err) } - alice, err := ed25519.Generate() + alice, err := ed25519.GenerateIssuer() if err != nil { t.Fatal(err) } diff --git a/examples/command_definition_test.go b/examples/command_definition_test.go index c789b69..e1d3343 100644 --- a/examples/command_definition_test.go +++ b/examples/command_definition_test.go @@ -6,7 +6,7 @@ import ( "github.com/fil-forge/ucantone/examples/types" "github.com/fil-forge/ucantone/ipld/datamodel" - "github.com/fil-forge/ucantone/principal/ed25519" + "github.com/fil-forge/ucantone/multikey/ed25519" "github.com/fil-forge/ucantone/ucan/command" "github.com/fil-forge/ucantone/ucan/delegation" "github.com/fil-forge/ucantone/ucan/invocation" @@ -21,12 +21,12 @@ func TestCommandDefinition(t *testing.T) { } // mailer is an email service that can send emails - mailer, err := ed25519.Generate() + mailer, err := ed25519.GenerateIssuer() if err != nil { panic(err) } - alice, err := ed25519.Generate() + alice, err := ed25519.GenerateIssuer() if err != nil { panic(err) } @@ -67,12 +67,12 @@ func TestTypedCommandDefinition(t *testing.T) { messageSend := binding.Bind[*types.MessageSendArguments, *datamodel.Map](command.MustParse("/message/send")) // mailer is an email service that can send emails - mailer, err := ed25519.Generate() + mailer, err := ed25519.GenerateIssuer() if err != nil { panic(err) } - alice, err := ed25519.Generate() + alice, err := ed25519.GenerateIssuer() if err != nil { panic(err) } diff --git a/examples/container_test.go b/examples/container_test.go index b99d287..74ae063 100644 --- a/examples/container_test.go +++ b/examples/container_test.go @@ -5,7 +5,7 @@ import ( "testing" "github.com/fil-forge/ucantone/ipld/datamodel" - "github.com/fil-forge/ucantone/principal/ed25519" + "github.com/fil-forge/ucantone/multikey/ed25519" "github.com/fil-forge/ucantone/ucan/command" "github.com/fil-forge/ucantone/ucan/container" "github.com/fil-forge/ucantone/ucan/delegation" @@ -15,12 +15,12 @@ import ( func TestContainer(t *testing.T) { // mailer is an email service that can send emails - mailer, err := ed25519.Generate() + mailer, err := ed25519.GenerateIssuer() if err != nil { panic(err) } - alice, err := ed25519.Generate() + alice, err := ed25519.GenerateIssuer() if err != nil { panic(err) } diff --git a/examples/delegations_test.go b/examples/delegations_test.go index f6fd0a2..8c88455 100644 --- a/examples/delegations_test.go +++ b/examples/delegations_test.go @@ -3,7 +3,7 @@ package examples import ( "testing" - "github.com/fil-forge/ucantone/principal/ed25519" + "github.com/fil-forge/ucantone/multikey/ed25519" "github.com/fil-forge/ucantone/ucan/command" "github.com/fil-forge/ucantone/ucan/delegation" "github.com/fil-forge/ucantone/ucan/delegation/policy" @@ -11,17 +11,17 @@ import ( func TestDelegations(t *testing.T) { // mailer is an email service that can send emails - mailer, err := ed25519.Generate() + mailer, err := ed25519.GenerateIssuer() if err != nil { panic(err) } - alice, err := ed25519.Generate() + alice, err := ed25519.GenerateIssuer() if err != nil { panic(err) } - bob, err := ed25519.Generate() + bob, err := ed25519.GenerateIssuer() if err != nil { panic(err) } diff --git a/examples/invocations_test.go b/examples/invocations_test.go index 8c5d667..a14028f 100644 --- a/examples/invocations_test.go +++ b/examples/invocations_test.go @@ -5,7 +5,7 @@ import ( "testing" "github.com/fil-forge/ucantone/ipld/datamodel" - "github.com/fil-forge/ucantone/principal/ed25519" + "github.com/fil-forge/ucantone/multikey/ed25519" "github.com/fil-forge/ucantone/ucan/command" "github.com/fil-forge/ucantone/ucan/delegation" "github.com/fil-forge/ucantone/ucan/invocation" @@ -13,12 +13,12 @@ import ( func TestInvocations(t *testing.T) { // mailer is an email service that can send emails - mailer, err := ed25519.Generate() + mailer, err := ed25519.GenerateIssuer() if err != nil { panic(err) } - alice, err := ed25519.Generate() + alice, err := ed25519.GenerateIssuer() if err != nil { panic(err) } diff --git a/examples/principals_test.go b/examples/principals_test.go index 9e07ebd..ebd004a 100644 --- a/examples/principals_test.go +++ b/examples/principals_test.go @@ -6,8 +6,8 @@ import ( "testing" "github.com/fil-forge/ucantone/did" - "github.com/fil-forge/ucantone/principal/ed25519" - "github.com/fil-forge/ucantone/principal/signer" + "github.com/fil-forge/ucantone/multikey" + "github.com/fil-forge/ucantone/multikey/ed25519" ) func TestParseDIDKey(t *testing.T) { @@ -27,7 +27,7 @@ func TestParseDIDWeb(t *testing.T) { } func TestGenerateDIDKey(t *testing.T) { - principal, err := ed25519.Generate() + principal, err := ed25519.GenerateIssuer() if err != nil { panic(err) } @@ -36,73 +36,37 @@ func TestGenerateDIDKey(t *testing.T) { sig := principal.Sign([]byte{1, 2, 3}) fmt.Printf("Signature: 0x%x\n", sig) // they have a private key (use format utility to multibase base64pad encode) - fmt.Println("Private Key:", signer.Format(principal)) + fmt.Println("Private Key:", multikey.FormatSigner(principal)) // which can be stored and decoded later... principal2, err := ed25519.Decode(principal.Bytes()) if err != nil { panic(err) } - fmt.Println(principal2.DID()) -} - -func TestWrapDIDWeb(t *testing.T) { - webPrincipal, err := did.Parse("did:web:service.example.com") - if err != nil { - panic(err) - } - signerPrincipal, err := ed25519.Generate() - if err != nil { - panic(err) - } - fmt.Println("DID:", signerPrincipal.DID()) - - principal, err := signer.Wrap(signerPrincipal, webPrincipal) - if err != nil { - panic(err) - } - // DID is did:web: - fmt.Println("DID (wrapped):", principal.DID()) - // ..but this principal is a [principal.Signer] - sig := principal.Sign([]byte{1, 2, 3}) - fmt.Printf("Signature: 0x%x\n", sig) - // ...and has a private key (use format utility to multibase base64pad encode) - fmt.Println("Private Key:", signer.Format(principal)) - // ...which can be stored and decoded later - signerPrincipal2, err := ed25519.Decode(principal.Bytes()) - if err != nil { - panic(err) - } - fmt.Println("DID:", signerPrincipal2.DID()) - // ...and re-wrapped - principal2, err := signer.Wrap(signerPrincipal2, webPrincipal) - if err != nil { - panic(err) - } - fmt.Println("DID (wrapped):", principal2.DID()) + fmt.Println("DID:", principal2.KeyDID()) } func TestConvertEd25519SignerPrincipalToNativeEd25519PrivateKey(t *testing.T) { - // generate ed25519 signer principal - principal, err := ed25519.Generate() + // generate ed25519 signer signer + signer, err := ed25519.Generate() if err != nil { panic(err) } - fmt.Println("DID:", principal.DID()) + fmt.Printf("Key: %s / %s\n", multikey.FormatVerifier(signer.Verifier().(multikey.Verifier)), multikey.FormatSigner(signer)) // convert to native ed25519 private key - sk := cryptoEd25519.NewKeyFromSeed(principal.Raw()) + sk := cryptoEd25519.NewKeyFromSeed(signer.Raw()) sig := cryptoEd25519.Sign(sk, []byte{1, 2, 3}) fmt.Printf("Signature: 0x%x\n", sig) - // convert back to ed25519 signer principal - principal2, err := ed25519.FromRaw(sk.Seed()) + // convert back to ed25519 signer + signer2, err := ed25519.FromRaw(sk.Seed()) if err != nil { panic(err) } - fmt.Println("DID:", principal2.DID()) + fmt.Printf("Key: %s / %s\n", multikey.FormatVerifier(signer2.Verifier().(multikey.Verifier)), multikey.FormatSigner(signer2)) // signature should match signature above...since it's the same private key - sig = principal2.Sign([]byte{1, 2, 3}) + sig = signer2.Sign([]byte{1, 2, 3}) fmt.Printf("Signature: 0x%x\n", sig) } diff --git a/examples/promises_test.go b/examples/promises_test.go index 865be0e..ef9257b 100644 --- a/examples/promises_test.go +++ b/examples/promises_test.go @@ -6,7 +6,7 @@ import ( "github.com/fil-forge/ucantone/examples/types" "github.com/fil-forge/ucantone/ipld/datamodel" - "github.com/fil-forge/ucantone/principal/ed25519" + "github.com/fil-forge/ucantone/multikey/ed25519" "github.com/fil-forge/ucantone/ucan/command" "github.com/fil-forge/ucantone/ucan/delegation" "github.com/fil-forge/ucantone/ucan/invocation" @@ -17,18 +17,18 @@ import ( func TestPromises(t *testing.T) { // Mailer is an email service that can send emails - mailer, err := ed25519.Generate() + mailer, err := ed25519.GenerateIssuer() if err != nil { panic(err) } - alice, err := ed25519.Generate() + alice, err := ed25519.GenerateIssuer() if err != nil { panic(err) } // A list of email addresses - mailingList, err := ed25519.Generate() + mailingList, err := ed25519.GenerateIssuer() if err != nil { panic(err) } @@ -92,18 +92,18 @@ func TestTypedPromises(t *testing.T) { emailList := binding.Bind[*types.EmailsListArguments, *datamodel.Map](command.MustParse("/emails/list")) // Mailer is an email service that can send emails - mailer, err := ed25519.Generate() + mailer, err := ed25519.GenerateIssuer() if err != nil { panic(err) } - alice, err := ed25519.Generate() + alice, err := ed25519.GenerateIssuer() if err != nil { panic(err) } // A list of email addresses - mailingList, err := ed25519.Generate() + mailingList, err := ed25519.GenerateIssuer() if err != nil { panic(err) } diff --git a/examples/server_test.go b/examples/server_test.go index f1b6630..1f63fcf 100644 --- a/examples/server_test.go +++ b/examples/server_test.go @@ -12,7 +12,7 @@ import ( "github.com/fil-forge/ucantone/examples/types" "github.com/fil-forge/ucantone/execution" "github.com/fil-forge/ucantone/ipld/datamodel" - "github.com/fil-forge/ucantone/principal/ed25519" + "github.com/fil-forge/ucantone/multikey/ed25519" "github.com/fil-forge/ucantone/server" "github.com/fil-forge/ucantone/testutil" "github.com/fil-forge/ucantone/ucan/command" @@ -28,7 +28,7 @@ func TestServer(t *testing.T) { panic(err) } - serviceID, err := ed25519.Generate() + serviceID, err := ed25519.GenerateIssuer() if err != nil { panic(err) } @@ -65,7 +65,7 @@ func TestServer(t *testing.T) { // Server is now running and can accept invocations! - alice, err := ed25519.Generate() + alice, err := ed25519.GenerateIssuer() if err != nil { panic(err) } @@ -115,7 +115,7 @@ func TestServer(t *testing.T) { func TestTypedServer(t *testing.T) { echo := binding.Bind[*types.EchoArguments, *types.EchoArguments](command.MustParse("/example/echo")) - serviceID, err := ed25519.Generate() + serviceID, err := ed25519.GenerateIssuer() if err != nil { panic(err) } @@ -154,7 +154,7 @@ func TestTypedServer(t *testing.T) { // Server is now running and can accept invocations! - alice, err := ed25519.Generate() + alice, err := ed25519.GenerateIssuer() if err != nil { panic(err) } @@ -207,7 +207,7 @@ func TestServerRoundTripper(t *testing.T) { panic(err) } - serviceID, err := ed25519.Generate() + serviceID, err := ed25519.GenerateIssuer() if err != nil { panic(err) } @@ -227,7 +227,7 @@ func TestServerRoundTripper(t *testing.T) { panic(err) } - alice, err := ed25519.Generate() + alice, err := ed25519.GenerateIssuer() if err != nil { panic(err) } diff --git a/examples/typed_args_test.go b/examples/typed_args_test.go index c1088ad..d2ac992 100644 --- a/examples/typed_args_test.go +++ b/examples/typed_args_test.go @@ -10,7 +10,7 @@ import ( edm "github.com/fil-forge/ucantone/errors/datamodel" "github.com/fil-forge/ucantone/examples/types" "github.com/fil-forge/ucantone/ipld/datamodel" - "github.com/fil-forge/ucantone/principal/ed25519" + "github.com/fil-forge/ucantone/multikey/ed25519" "github.com/fil-forge/ucantone/ucan/command" "github.com/fil-forge/ucantone/ucan/invocation" "github.com/fil-forge/ucantone/ucan/receipt" @@ -39,7 +39,7 @@ func TestExtractTypedArgsFromInvocation(t *testing.T) { // Metadata is spec'd as an untyped {String : Any} map — debug/audit // information that isn't semantically meaningful to the delegation // chain. Pass it as a Go map literal via WithMetadata. - alice, err := ed25519.Generate() + alice, err := ed25519.GenerateIssuer() require.NoError(t, err) inv, err := invocation.Invoke( diff --git a/execution/dispatcher/dispatcher.go b/execution/dispatcher/dispatcher.go index 0ae65e7..9c98107 100644 --- a/execution/dispatcher/dispatcher.go +++ b/execution/dispatcher/dispatcher.go @@ -4,7 +4,6 @@ import ( "fmt" "github.com/fil-forge/ucantone/execution" - "github.com/fil-forge/ucantone/principal" "github.com/fil-forge/ucantone/ucan" "github.com/fil-forge/ucantone/validator" ) @@ -12,7 +11,7 @@ import ( // Dispatcher executes UCAN invocations by dispatching them to registered // handlers. type Dispatcher struct { - authority principal.Signer + authority ucan.Issuer handlers map[ucan.Command]execution.HandlerFunc validationOpts []validator.Option receiptTimestamps bool @@ -23,7 +22,7 @@ type Dispatcher struct { // // The authority is the identity of the local authority, used to verify // signatures of delegations signed by it and sign receipts for executed tasks. -func New(authority principal.Signer, options ...Option) *Dispatcher { +func New(authority ucan.Issuer, options ...Option) *Dispatcher { cfg := execConfig{} for _, opt := range options { opt(&cfg) @@ -48,7 +47,7 @@ func (d *Dispatcher) Execute(req execution.Request) (execution.Response, error) if aud != d.authority.DID() { return execution.NewResponse( req.Invocation().Task().Link(), - execution.WithSigner(d.authority), + execution.WithIssuer(d.authority), execution.WithReceiptTimestamp(d.receiptTimestamps), execution.WithFailure(execution.NewInvalidAudienceError(d.authority.DID(), aud)), ) @@ -59,7 +58,7 @@ func (d *Dispatcher) Execute(req execution.Request) (execution.Response, error) if !ok { return execution.NewResponse( req.Invocation().Task().Link(), - execution.WithSigner(d.authority), + execution.WithIssuer(d.authority), execution.WithReceiptTimestamp(d.receiptTimestamps), execution.WithFailure(NewHandlerNotFoundError(cmd)), ) @@ -79,7 +78,7 @@ func (d *Dispatcher) Execute(req execution.Request) (execution.Response, error) if err != nil { return execution.NewResponse( req.Invocation().Task().Link(), - execution.WithSigner(d.authority), + execution.WithIssuer(d.authority), execution.WithReceiptTimestamp(d.receiptTimestamps), execution.WithFailure(err), ) @@ -87,7 +86,7 @@ func (d *Dispatcher) Execute(req execution.Request) (execution.Response, error) res, err := execution.NewResponse( req.Invocation().Task().Link(), - execution.WithSigner(d.authority), + execution.WithIssuer(d.authority), execution.WithReceiptTimestamp(d.receiptTimestamps), ) if err != nil { @@ -98,7 +97,7 @@ func (d *Dispatcher) Execute(req execution.Request) (execution.Response, error) if err != nil { return execution.NewResponse( req.Invocation().Task().Link(), - execution.WithSigner(d.authority), + execution.WithIssuer(d.authority), execution.WithReceiptTimestamp(d.receiptTimestamps), execution.WithFailure(execution.NewHandlerExecutionError(cmd, err)), ) diff --git a/execution/dispatcher/dispatcher_test.go b/execution/dispatcher/dispatcher_test.go index 5c0a364..627702d 100644 --- a/execution/dispatcher/dispatcher_test.go +++ b/execution/dispatcher/dispatcher_test.go @@ -15,8 +15,8 @@ import ( ) func TestDispatcher(t *testing.T) { - service := testutil.RandomSigner(t) - alice := testutil.RandomSigner(t) + service := testutil.RandomIssuer(t) + alice := testutil.RandomIssuer(t) t.Run("dispatches invocations for execution", func(t *testing.T) { executor := dispatcher.New(service) diff --git a/execution/response.go b/execution/response.go index 08b833a..1745a6d 100644 --- a/execution/response.go +++ b/execution/response.go @@ -12,7 +12,7 @@ import ( ) type ExecResponse struct { - signer ucan.Signer + issuer ucan.Issuer task cid.Cid receipt ucan.Receipt metadata ucan.Container @@ -21,9 +21,9 @@ type ExecResponse struct { type ResponseOption func(r *ExecResponse) error -func WithSigner(signer ucan.Signer) ResponseOption { +func WithIssuer(issuer ucan.Issuer) ResponseOption { return func(resp *ExecResponse) error { - resp.signer = signer + resp.issuer = issuer return nil } } @@ -88,7 +88,7 @@ func (r *ExecResponse) Receipt() ucan.Receipt { } func (r *ExecResponse) SetFailure(x error) error { - if r.signer == nil { + if r.issuer == nil { return fmt.Errorf("cannot issue receipt: missing signer") } var errVal cbg.CBORMarshaler @@ -104,7 +104,7 @@ func (r *ExecResponse) SetFailure(x error) error { "message": x.Error(), } } - rcpt, err := receipt.IssueErr(r.signer, r.task, errVal) + rcpt, err := receipt.IssueErr(r.issuer, r.task, errVal) if err != nil { return err } @@ -125,16 +125,16 @@ func (r *ExecResponse) SetReceipt(receipt ucan.Receipt) error { return nil } -func (r *ExecResponse) SetSigner(signer ucan.Signer) error { - r.signer = signer +func (r *ExecResponse) SetIssuer(issuer ucan.Issuer) error { + r.issuer = issuer return nil } func (r *ExecResponse) SetSuccess(ok cbg.CBORMarshaler) error { - if r.signer == nil { - return fmt.Errorf("cannot issue receipt: missing signer") + if r.issuer == nil { + return fmt.Errorf("cannot issue receipt: missing issuer") } - rcpt, err := receipt.IssueOK(r.signer, r.task, ok) + rcpt, err := receipt.IssueOK(r.issuer, r.task, ok) if err != nil { return err } diff --git a/go.mod b/go.mod index 692941e..c9b0d6e 100644 --- a/go.mod +++ b/go.mod @@ -3,12 +3,14 @@ module github.com/fil-forge/ucantone go 1.25.0 require ( - github.com/alanshaw/dag-json-gen v0.0.4 + github.com/alanshaw/dag-json-gen v0.0.6 github.com/gobwas/glob v0.2.3 - github.com/ipfs/go-cid v0.6.0 - github.com/multiformats/go-multibase v0.2.0 + github.com/ipfs/go-cid v0.6.1 + github.com/multiformats/go-multibase v0.3.0 + github.com/multiformats/go-multicodec v0.10.0 github.com/multiformats/go-multihash v0.2.3 github.com/multiformats/go-varint v0.1.0 + github.com/patrickmn/go-cache v2.1.0+incompatible github.com/stretchr/testify v1.11.1 github.com/whyrusleeping/cbor-gen v0.3.1 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b @@ -16,19 +18,19 @@ require ( ) require ( - github.com/davecgh/go-spew v1.1.1 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/kr/pretty v0.3.1 // indirect github.com/minio/sha256-simd v1.0.1 // indirect - github.com/mr-tron/base58 v1.2.0 // indirect + github.com/mr-tron/base58 v1.3.0 // indirect github.com/multiformats/go-base32 v0.1.0 // indirect github.com/multiformats/go-base36 v0.2.0 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/spaolacci/murmur3 v1.1.0 // indirect gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect - golang.org/x/crypto v0.49.0 // indirect - golang.org/x/sys v0.42.0 // indirect + golang.org/x/crypto v0.50.0 // indirect + golang.org/x/sys v0.43.0 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/yaml.v3 v3.0.1 // indirect lukechampine.com/blake3 v1.4.1 // indirect diff --git a/go.sum b/go.sum index 98f8e1b..d7c8faa 100644 --- a/go.sum +++ b/go.sum @@ -1,12 +1,12 @@ -github.com/alanshaw/dag-json-gen v0.0.4 h1:qoryz04TVH6zu16NRFnzgolzQGaPfTvoIawv/F5rDoY= -github.com/alanshaw/dag-json-gen v0.0.4/go.mod h1:rXxWw0SItP9QjxpRMpkju66h0KumF7TPCtvHdOKS5lY= +github.com/alanshaw/dag-json-gen v0.0.6 h1:MiscvWVOhs6/ux7OUdPz2nDRA7GwklZyaAy4XWexpr0= +github.com/alanshaw/dag-json-gen v0.0.6/go.mod h1:rXxWw0SItP9QjxpRMpkju66h0KumF7TPCtvHdOKS5lY= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= -github.com/ipfs/go-cid v0.6.0 h1:DlOReBV1xhHBhhfy/gBNNTSyfOM6rLiIx9J7A4DGf30= -github.com/ipfs/go-cid v0.6.0/go.mod h1:NC4kS1LZjzfhK40UGmpXv5/qD2kcMzACYJNntCUiDhQ= +github.com/ipfs/go-cid v0.6.1 h1:T5TnNb08+ueovG76Z5gx1L4Y7QOaGTXHg1F6raWFxIc= +github.com/ipfs/go-cid v0.6.1/go.mod h1:zrY0SwOhjrrIdfPQ/kf+k1sXyJ0QE7cMxfCployLBs0= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= @@ -20,21 +20,25 @@ github.com/matryer/is v1.4.0 h1:sosSmIWwkYITGrxZ25ULNDeKiMNzFSr4V/eqBQP0PeE= github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM= github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8= -github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o= -github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc= +github.com/mr-tron/base58 v1.3.0 h1:K6Y13R2h+dku0wOqKtecgRnBUBPrZzLZy5aIj8lCcJI= +github.com/mr-tron/base58 v1.3.0/go.mod h1:2BuubE67DCSWwVfx37JWNG8emOC0sHEU4/HpcYgCLX8= github.com/multiformats/go-base32 v0.1.0 h1:pVx9xoSPqEIQG8o+UbAe7DNi51oej1NtK+aGkbLYxPE= github.com/multiformats/go-base32 v0.1.0/go.mod h1:Kj3tFY6zNr+ABYMqeUNeGvkIC/UYgtWibDcT0rExnbI= github.com/multiformats/go-base36 v0.2.0 h1:lFsAbNOGeKtuKozrtBsAkSVhv1p9D0/qedU9rQyccr0= github.com/multiformats/go-base36 v0.2.0/go.mod h1:qvnKE++v+2MWCfePClUEjE78Z7P2a1UV0xHgWc0hkp4= -github.com/multiformats/go-multibase v0.2.0 h1:isdYCVLvksgWlMW9OZRYJEa9pZETFivncJHmHnnd87g= -github.com/multiformats/go-multibase v0.2.0/go.mod h1:bFBZX4lKCA/2lyOFSAoKH5SS6oPyjtnzK/XTFDPkNuk= +github.com/multiformats/go-multibase v0.3.0 h1:8helZD2+4Db7NNWFiktk2NePbF0boolBe6bDQvM4r68= +github.com/multiformats/go-multibase v0.3.0/go.mod h1:MoBLQPCkRTOL3eveIPO81860j2AQY8JwcnNlRkGRUfI= +github.com/multiformats/go-multicodec v0.10.0 h1:UpP223cig/Cx8J76jWt91njpK3GTAO1w02sdcjZDSuc= +github.com/multiformats/go-multicodec v0.10.0/go.mod h1:wg88pM+s2kZJEQfRCKBNU+g32F5aWBEjyFHXvZLTcLI= github.com/multiformats/go-multihash v0.2.3 h1:7Lyc8XfX/IY2jWb/gI7JP+o7JEq9hOa7BFvVU9RSh+U= github.com/multiformats/go-multihash v0.2.3/go.mod h1:dXgKXCXjBzdscBLk9JkjINiEsCKRVch90MdaGiKsvSM= github.com/multiformats/go-varint v0.1.0 h1:i2wqFp4sdl3IcIxfAonHQV9qU5OsZ4Ts9IOoETFs5dI= github.com/multiformats/go-varint v0.1.0/go.mod h1:5KVAVXegtfmNQQm/lCY+ATvDzvJJhSkUlGQV9wgObdI= +github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= +github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= @@ -48,10 +52,10 @@ gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b h1:CzigHMRyS gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b/go.mod h1:/y/V339mxv2sZmYYR64O07VuCpdNZqCTwO8ZcouTMI8= gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 h1:qwDnMxjkyLmAFgcfgTnfJrmYKWhHnci3GjDqcZp1M3Q= gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02/go.mod h1:JTnUj0mpYiAsuZLmKjTx/ex3AtMowcCgnE7YNyCEP0I= -golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= -golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= -golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= +golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY= golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/principal/ed25519/signer.go b/multikey/ed25519/signer.go similarity index 65% rename from principal/ed25519/signer.go rename to multikey/ed25519/signer.go index 17f4583..2a398c8 100644 --- a/principal/ed25519/signer.go +++ b/multikey/ed25519/signer.go @@ -6,18 +6,19 @@ import ( "fmt" "github.com/fil-forge/ucantone/did" - "github.com/fil-forge/ucantone/principal" - "github.com/fil-forge/ucantone/principal/ed25519/verifier" + "github.com/fil-forge/ucantone/multikey" + "github.com/fil-forge/ucantone/multikey/ed25519/verifier" + "github.com/fil-forge/ucantone/ucan" "github.com/fil-forge/ucantone/varsig" + "github.com/fil-forge/ucantone/varsig/algorithm/eddsa" "github.com/multiformats/go-multibase" + "github.com/multiformats/go-multicodec" "github.com/multiformats/go-varint" ) -const Code = 0x1300 +const Code = multicodec.Ed25519Priv -var SignatureAlgorithm = verifier.SignatureAlgorithm - -var tagSize = varint.UvarintSize(Code) +var tagSize = varint.UvarintSize(uint64(Code)) // Go ed25519 private key size is private + public. Go refers to the private key // bytes as the "seed". @@ -31,11 +32,19 @@ func Generate() (Signer, error) { return nil, fmt.Errorf("generating Ed25519 key: %w", err) } s := make(Signer, size) - varint.PutUvarint(s, Code) + varint.PutUvarint(s, uint64(Code)) copy(s[tagSize:], priv) return s, nil } +func GenerateIssuer() (multikey.Issuer, error) { + signer, err := Generate() + if err != nil { + return nil, fmt.Errorf("generating signer: %w", err) + } + return multikey.KeyIssuer(signer), nil +} + // Parse parses a multibase encoded string containing a ed25519 signer // multiformat varint (0x1300) + 32 byte ed25519 private key func Parse(str string) (Signer, error) { @@ -46,11 +55,6 @@ func Parse(str string) (Signer, error) { return Decode(bytes) } -func Format(signer principal.Signer) string { - s, _ := multibase.Encode(multibase.Base64pad, signer.Bytes()) - return s -} - // Decode decodes a buffer of an ed25519 signer multiformat varint (0x1300) + 32 // byte ed25519 private key. func Decode(b []byte) (Signer, error) { @@ -62,8 +66,8 @@ func Decode(b []byte) (Signer, error) { if err != nil { return nil, fmt.Errorf("reading private key uvarint: %w", err) } - if skc != Code { - return nil, fmt.Errorf("invalid private key codec: 0x%02x, expected: 0x%02x", skc, Code) + if skc != uint64(Code) { + return nil, fmt.Errorf("invalid private key codec: %s [0x%02x], expected: %s [0x%02x]", multicodec.Code(skc), skc, Code, uint64(Code)) } s := make(Signer, size) @@ -83,33 +87,42 @@ func FromRaw(b []byte) (Signer, error) { return nil, fmt.Errorf("invalid length: %d wanted: %d", len(b), ed25519.SeedSize) } s := make(Signer, size) - varint.PutUvarint(s, Code) + varint.PutUvarint(s, uint64(Code)) copy(s[tagSize:size], b[:ed25519.SeedSize]) return s, nil } type Signer []byte -var _ principal.Signer = (Signer)(nil) +var _ multikey.Signer = (Signer)(nil) -func (s Signer) Code() uint64 { +func (s Signer) Code() multicodec.Code { return Code } -func (s Signer) SignatureAlgorithm() varsig.SignatureAlgorithm { - return SignatureAlgorithm +func (s Signer) PrivateKey() any { + sk := ed25519.NewKeyFromSeed(s[tagSize:]) + return sk } -func (s Signer) Verifier() principal.Verifier { +func (s Signer) PublicKey() any { + return s.verifier().PublicKey() +} + +func (s Signer) SignatureAlgorithm() varsig.Algorithm { + return eddsa.Ed25519 +} + +func (s Signer) Verifier() ucan.Verifier { + return s.verifier() +} + +func (s Signer) verifier() multikey.Verifier { sk := ed25519.NewKeyFromSeed(s[tagSize:]) v, _ := verifier.FromRaw(sk.Public().(ed25519.PublicKey)) return v } -func (s Signer) DID() did.DID { - return s.Verifier().DID() -} - // Bytes returns the private key bytes with multiformat prefix varint. func (s Signer) Bytes() []byte { return s @@ -126,3 +139,7 @@ func (s Signer) Sign(msg []byte) []byte { sk := ed25519.NewKeyFromSeed(s[tagSize:]) return ed25519.Sign(sk, msg) } + +func (s Signer) KeyDID() did.DID { + return s.verifier().KeyDID() +} diff --git a/principal/ed25519/signer_test.go b/multikey/ed25519/signer_test.go similarity index 67% rename from principal/ed25519/signer_test.go rename to multikey/ed25519/signer_test.go index 7274930..39c2b29 100644 --- a/principal/ed25519/signer_test.go +++ b/multikey/ed25519/signer_test.go @@ -4,7 +4,8 @@ import ( "crypto/ed25519" "testing" - ed "github.com/fil-forge/ucantone/principal/ed25519" + "github.com/fil-forge/ucantone/multikey" + ed "github.com/fil-forge/ucantone/multikey/ed25519" "github.com/stretchr/testify/require" ) @@ -12,29 +13,31 @@ func TestGenerateEncodeDecode(t *testing.T) { s0, err := ed.Generate() require.NoError(t, err) - t.Log(s0.DID().String()) + t.Log(multikey.FormatVerifier(s0.Verifier().(multikey.Verifier))) s1, err := ed.Decode(s0.Bytes()) require.NoError(t, err) - t.Log(s1.DID().String()) - require.Equal(t, s0.DID(), s1.DID(), "public key mismatch") + t.Log(multikey.FormatVerifier(s1.Verifier().(multikey.Verifier))) + require.Equal(t, s0, s1, "private key mismatch") + require.Equal(t, s0.Verifier(), s1.Verifier(), "public key mismatch") } func TestGenerateFormatParse(t *testing.T) { s0, err := ed.Generate() require.NoError(t, err) - t.Log(s0.DID().String()) + t.Log(multikey.FormatVerifier(s0.Verifier().(multikey.Verifier))) - str := ed.Format(s0) + str := multikey.FormatSigner(s0) t.Log(str) s1, err := ed.Parse(str) require.NoError(t, err) - t.Log(s1.DID().String()) - require.Equal(t, s0.DID(), s1.DID(), "public key mismatch") + t.Log(multikey.FormatVerifier(s1.Verifier().(multikey.Verifier))) + require.Equal(t, s0, s1, "private key mismatch") + require.Equal(t, s0.Verifier(), s1.Verifier(), "public key mismatch") } func TestVerify(t *testing.T) { diff --git a/principal/ed25519/verifier/verifier.go b/multikey/ed25519/verifier/verifier.go similarity index 66% rename from principal/ed25519/verifier/verifier.go rename to multikey/ed25519/verifier/verifier.go index 3397086..d0c2297 100644 --- a/principal/ed25519/verifier/verifier.go +++ b/multikey/ed25519/verifier/verifier.go @@ -6,31 +6,26 @@ import ( "fmt" "github.com/fil-forge/ucantone/did" - "github.com/fil-forge/ucantone/principal" - "github.com/fil-forge/ucantone/principal/multiformat" - keyverifier "github.com/fil-forge/ucantone/principal/verifier" - varsig_ed25519 "github.com/fil-forge/ucantone/varsig/algorithm/ed25519" + "github.com/fil-forge/ucantone/multikey" + "github.com/fil-forge/ucantone/multikey/internal/multiformat" "github.com/multiformats/go-multibase" + "github.com/multiformats/go-multicodec" "github.com/multiformats/go-varint" ) func init() { - keyverifier.Register(Code, func(b []byte) (principal.Verifier, error) { - return Decode(b) - }) + multikey.Register(Code, Decode) } -const Code = 0xed +const Code = multicodec.Ed25519Pub -var SignatureAlgorithm = varsig_ed25519.New() - -var publicTagSize = varint.UvarintSize(Code) +var publicTagSize = varint.UvarintSize(uint64(Code)) const keySize = ed25519.PublicKeySize var size = publicTagSize + keySize -func Parse(str string) (Verifier, error) { +func ParseKeyDID(str string) (multikey.Verifier, error) { did, err := did.Parse(str) if err != nil { return nil, fmt.Errorf("invalid DID: %w", err) @@ -48,11 +43,11 @@ func Parse(str string) (Verifier, error) { return Decode(bytes) } -func Format(verifier principal.Verifier) string { - return verifier.DID().String() +func Format(verifier multikey.Verifier) string { + return verifier.KeyDID().String() } -func Decode(b []byte) (Verifier, error) { +func Decode(b []byte) (multikey.Verifier, error) { if len(b) != size { return nil, fmt.Errorf("invalid length: %d wanted: %d", len(b), size) } @@ -60,8 +55,8 @@ func Decode(b []byte) (Verifier, error) { if err != nil { return nil, fmt.Errorf("reading uvarint: %w", err) } - if code != Code { - return nil, fmt.Errorf("invalid public key codec: 0x%02x, expected: 0x%02x", code, Code) + if code != uint64(Code) { + return nil, fmt.Errorf("invalid public key codec: %s [0x%02x], expected: %s [0x%02x]", multicodec.Code(code), code, Code, uint64(Code)) } v := make(Verifier, size) copy(v, b) @@ -83,20 +78,22 @@ func FromRaw(b []byte) (Verifier, error) { type Verifier []byte -var _ principal.Verifier = (Verifier)(nil) +var _ multikey.Verifier = (Verifier)(nil) -func (v Verifier) Code() uint64 { +func (v Verifier) Code() multicodec.Code { return Code } +func (v Verifier) PublicKey() any { + return ed25519.PublicKey(v[publicTagSize:]) +} + func (v Verifier) Verify(msg []byte, sig []byte) bool { return ed25519.Verify(ed25519.PublicKey(v[publicTagSize:]), msg, sig) } -func (v Verifier) DID() did.DID { - b58key, _ := multibase.Encode(multibase.Base58BTC, v) - id, _ := did.Parse(did.KeyPrefix + b58key) - return id +func (v Verifier) String() string { + return multikey.FormatVerifier(v) } // Bytes returns the public key bytes with multiformat prefix varint. @@ -110,3 +107,7 @@ func (v Verifier) Raw() []byte { copy(k, v[publicTagSize:]) return k } + +func (v Verifier) KeyDID() did.DID { + return multikey.KeyDID(v) +} diff --git a/principal/ed25519/verifier/verifier_test.go b/multikey/ed25519/verifier/verifier_test.go similarity index 83% rename from principal/ed25519/verifier/verifier_test.go rename to multikey/ed25519/verifier/verifier_test.go index c2cc0b7..af98add 100644 --- a/principal/ed25519/verifier/verifier_test.go +++ b/multikey/ed25519/verifier/verifier_test.go @@ -4,15 +4,15 @@ import ( "crypto/ed25519" "testing" - "github.com/fil-forge/ucantone/principal/ed25519/verifier" + "github.com/fil-forge/ucantone/multikey/ed25519/verifier" "github.com/stretchr/testify/require" ) -func TestParse(t *testing.T) { +func TestParseKeyDID(t *testing.T) { str := "did:key:z6MkgZN5cRgWqesJeaZCEs7eKzyQsfpzmhnSEqTL6FZt56Ym" - v, err := verifier.Parse(str) + v, err := verifier.ParseKeyDID(str) require.NoError(t, err) - require.Equal(t, str, v.DID().String()) + require.Equal(t, str, v.KeyDID().String()) } func TestDecode(t *testing.T) { diff --git a/multikey/internal/multiformat/multiformat.go b/multikey/internal/multiformat/multiformat.go new file mode 100644 index 0000000..18dfe6b --- /dev/null +++ b/multikey/internal/multiformat/multiformat.go @@ -0,0 +1,36 @@ +package multiformat + +import ( + "bytes" + "fmt" + + "github.com/multiformats/go-multicodec" + "github.com/multiformats/go-varint" +) + +func TagWith(code multicodec.Code, bytes []byte) []byte { + offset := varint.UvarintSize(uint64(code)) + tagged := make([]byte, len(bytes)+offset) + varint.PutUvarint(tagged, uint64(code)) + copy(tagged[offset:], bytes) + return tagged +} + +func UntagWith(code multicodec.Code, source []byte, offset int) ([]byte, error) { + b := source + if offset != 0 { + b = source[offset:] + } + + tag, err := varint.ReadUvarint(bytes.NewReader(b)) + if err != nil { + return nil, err + } + + if tag != uint64(code) { + return nil, fmt.Errorf("expected multiformat with tag %s [0x%02x] instead got %s [0x%02x]", code, uint64(code), multicodec.Code(tag), tag) + } + + size := varint.UvarintSize(uint64(code)) + return b[size:], nil +} diff --git a/principal/multiformat/multiformat_test.go b/multikey/internal/multiformat/multiformat_test.go similarity index 76% rename from principal/multiformat/multiformat_test.go rename to multikey/internal/multiformat/multiformat_test.go index d8c5fb2..0f56ec3 100644 --- a/principal/multiformat/multiformat_test.go +++ b/multikey/internal/multiformat/multiformat_test.go @@ -3,7 +3,7 @@ package multiformat_test import ( "testing" - "github.com/fil-forge/ucantone/principal/multiformat" + "github.com/fil-forge/ucantone/multikey/internal/multiformat" "github.com/fil-forge/ucantone/testutil" "github.com/stretchr/testify/require" ) @@ -21,6 +21,6 @@ func TestTag(t *testing.T) { tb := multiformat.TagWith(1, b) _, err := multiformat.UntagWith(2, tb, 0) require.Error(t, err) - require.Equal(t, "expected multiformat with 0x2 tag instead got 0x1", err.Error()) + require.Equal(t, "expected multiformat with tag cidv2 [0x02] instead got cidv1 [0x01]", err.Error()) }) } diff --git a/multikey/multikey.go b/multikey/multikey.go new file mode 100644 index 0000000..08ab745 --- /dev/null +++ b/multikey/multikey.go @@ -0,0 +1,177 @@ +package multikey + +import ( + "context" + "fmt" + + "github.com/fil-forge/ucantone/did" + "github.com/fil-forge/ucantone/did/key" + "github.com/fil-forge/ucantone/ucan" + "github.com/multiformats/go-multibase" + "github.com/multiformats/go-multicodec" + "github.com/multiformats/go-varint" +) + +// DeriveVerifier produces a [ucan.Verifier] from Multikey [did.VerificationMaterial]. +func DeriveVerifier(_ context.Context, mat did.VerificationMaterial) (ucan.Verifier, error) { + pkm, ok := mat[did.MultikeyPublicKeyMultibaseProp].(string) + if !ok { + return nil, fmt.Errorf("Multikey verification method missing %s", did.MultikeyPublicKeyMultibaseProp) + } + return Parse(pkm) +} + +func DeriveVerificationMethod(id did.URL, v Verifier) did.VerificationMethod { + return did.VerificationMethod{ + ID: id, + Type: did.MultikeyVerificationMethodType, + Material: did.GenericMap{did.MultikeyPublicKeyMultibaseProp: FormatVerifier(v)}, + } +} + +// Decoder decodes multiformat-tagged public key bytes into a Verifier. +type Decoder func([]byte) (Verifier, error) + +var decoders = map[multicodec.Code]Decoder{} + +// Register registers a [Decoder] for a given Multicodec code. The code should +// be the Multicodec code tagged `key` in the multicodec table. +func Register(code multicodec.Code, d Decoder) { + decoders[code] = d +} + +// Parses a Multikey string into a Verifier. +func Parse(mk string) (Verifier, error) { + _, bytes, err := multibase.Decode(mk) + if err != nil { + return nil, err + } + + keyTypeCode, _, err := varint.FromUvarint(bytes) + if err != nil { + return nil, fmt.Errorf("reading uvarint: %w", err) + } + + d, ok := decoders[multicodec.Code(keyTypeCode)] + if !ok { + return nil, fmt.Errorf("no decoder registered for key type code: %s [0x%02x]", multicodec.Code(keyTypeCode), keyTypeCode) + } + return d(bytes) +} + +// Signer is a multikey signer. It contains the private key bytes and can sign +// with them. +type Signer interface { + ucan.Signer + + Code() multicodec.Code + + // Bytes returns the private key bytes with multiformats prefix varint. + Bytes() []byte + + // Raw returns the bytes of the private key without multiformats tags. + Raw() []byte + + // PrivateKey returns the private key in whatever format the underlying + // implementation uses. Where possible, this should be a type that + // ["crypto/x509".MarshalPKCS8PrivateKey] supports. + PrivateKey() any + + // PublicKey returns the public key in whatever format the underlying + // implementation uses. Where possible, this should be a type that + // ["crypto/x509".MarshalPKIXPublicKey] supports. + PublicKey() any + + // KeyDID is a convenience for `Verifier().KeyDID()`. + KeyDID() did.DID +} + +// Issuer is a [ucan.Issuer] whose signer is specifically a multikey [Signer]. +type Issuer interface { + ucan.Principal + Signer + String() string +} + +// Verifier is a multikey verifier. It contains the public key bytes and can +// verify signatures with them. +type Verifier interface { + ucan.Verifier + + Code() multicodec.Code + + // Bytes returns the public key bytes with multiformats prefix varint. + Bytes() []byte + + // Raw returns the bytes of the public key without multiformats tags. + Raw() []byte + + // PublicKey returns the public key in whatever format the underlying + // implementation uses. Where possible, this should be a type that + // ["crypto/x509".MarshalPKIXPublicKey] supports. + PublicKey() any + + // KeyDID returns the `did:key` DID corresponding to this Verifier's public + // key (which is not necessarily the same DID as the issuer of a token this + // Verifier can verify). + KeyDID() did.DID +} + +// Format formats a [multikey.Signer] into a multibase encoded string +// (Base64pad). +func FormatSigner(signer Signer) string { + s, _ := multibase.Encode(multibase.Base64pad, signer.Bytes()) + return s +} + +// Format formats a [multikey.Verifier] into a multibase encoded string (Base58BTC). +func FormatVerifier(verifier Verifier) string { + s, _ := multibase.Encode(multibase.Base58BTC, verifier.Bytes()) + return s +} + +// KeyDID returns the `did:key` DID corresponding to the given Verifier's public +// key. This may not be the same DID that is using this key in some particular +// context, but it's a DID that *can* use it, and is the only `did:key` DID that +// can use it. If this key was found on a `did:key` DID, then this is by +// definition the DID it was found on. If this key was found on, for example, a +// `did:web` DID, then this is not. +func KeyDID(v Verifier) did.DID { + return did.New(key.Method, FormatVerifier(v)) +} + +// KeyIssuer wraps a [Signer] to produce a [ucan.Issuer] using the signer's +// [KeyDID] as the issuer DID. +func KeyIssuer(s Signer) Issuer { + return keyIssuer{s} +} + +type keyIssuer struct{ Signer } + +func (k keyIssuer) DID() did.DID { return k.KeyDID() } + +func (k keyIssuer) String() string { + return k.DID().String() +} + +type issuer struct { + did did.DID + Signer +} + +var _ ucan.Issuer = issuer{} + +// NewIssuer creates a new multikey Issuer with the given DID and multikey +// signer. The two may be completely unrelated: creating a useful Issuer is the +// caller's responsibility. +func NewIssuer(did did.DID, signer Signer) issuer { + return issuer{did: did, Signer: signer} +} + +func (i issuer) DID() did.DID { + return i.did +} + +func (i issuer) String() string { + return fmt.Sprintf("%s (key: %s)", i.did, i.Signer.Verifier().String()) +} diff --git a/principal/secp256k1/signer.go b/multikey/secp256k1/signer.go similarity index 65% rename from principal/secp256k1/signer.go rename to multikey/secp256k1/signer.go index 0ab1258..7c2224d 100644 --- a/principal/secp256k1/signer.go +++ b/multikey/secp256k1/signer.go @@ -6,19 +6,20 @@ import ( "fmt" "github.com/fil-forge/ucantone/did" - "github.com/fil-forge/ucantone/principal" - "github.com/fil-forge/ucantone/principal/secp256k1/verifier" + "github.com/fil-forge/ucantone/multikey" + "github.com/fil-forge/ucantone/multikey/secp256k1/verifier" + "github.com/fil-forge/ucantone/ucan" "github.com/fil-forge/ucantone/varsig" + "github.com/fil-forge/ucantone/varsig/algorithm/ecdsa" "github.com/multiformats/go-multibase" + "github.com/multiformats/go-multicodec" "github.com/multiformats/go-varint" "gitlab.com/yawning/secp256k1-voi/secec" ) -const Code = 0x1301 +const Code = multicodec.Secp256k1Priv -var SignatureAlgorithm = verifier.SignatureAlgorithm - -var tagSize = varint.UvarintSize(Code) +var tagSize = varint.UvarintSize(uint64(Code)) const keySize = 32 @@ -30,13 +31,21 @@ func Generate() (Signer, error) { return nil, fmt.Errorf("generating secp256k1 key: %w", err) } s := make(Signer, size) - varint.PutUvarint(s, Code) + varint.PutUvarint(s, uint64(Code)) copy(s[tagSize:], sk.Bytes()) return s, nil } +func GenerateIssuer() (multikey.Issuer, error) { + signer, err := Generate() + if err != nil { + return nil, fmt.Errorf("generating signer: %w", err) + } + return multikey.KeyIssuer(signer), nil +} + // Parse parses a multibase encoded string containing a secp256k1 signer -// multiformat varint (0x1301) + byte secp256k1 raw scalar value. +// multiformat varint (0x1301) + byte secp256k1 raw scalar value. func Parse(str string) (Signer, error) { _, bytes, err := multibase.Decode(str) if err != nil { @@ -45,7 +54,7 @@ func Parse(str string) (Signer, error) { return Decode(bytes) } -func Format(signer principal.Signer) string { +func Format(signer multikey.Signer) string { s, _ := multibase.Encode(multibase.Base64pad, signer.Bytes()) return s } @@ -60,8 +69,8 @@ func Decode(b []byte) (Signer, error) { if err != nil { return nil, fmt.Errorf("reading private key uvarint: %w", err) } - if skc != Code { - return nil, fmt.Errorf("invalid private key codec: 0x%02x, expected: 0x%02x", skc, Code) + if skc != uint64(Code) { + return nil, fmt.Errorf("invalid private key codec: %s [0x%02x], expected: %s [0x%02x]", multicodec.Code(skc), skc, Code, uint64(Code)) } _, err = secec.NewPrivateKey(b[tagSize:]) if err != nil { @@ -83,33 +92,42 @@ func FromRaw(b []byte) (Signer, error) { return nil, fmt.Errorf("invalid length: %d wanted: %d", len(b), keySize) } s := make(Signer, size) - varint.PutUvarint(s, Code) + varint.PutUvarint(s, uint64(Code)) copy(s[tagSize:], b) return s, nil } type Signer []byte -var _ principal.Signer = (Signer)(nil) +var _ multikey.Signer = (Signer)(nil) + +func (s Signer) SignatureAlgorithm() varsig.Algorithm { + return ecdsa.Secp256k1 +} -func (s Signer) Code() uint64 { +func (s Signer) Code() multicodec.Code { return Code } -func (s Signer) SignatureAlgorithm() varsig.SignatureAlgorithm { - return SignatureAlgorithm +func (s Signer) PrivateKey() any { + sk, _ := secec.NewPrivateKey(s[tagSize:]) + return sk +} + +func (s Signer) PublicKey() any { + return s.verifier().PublicKey() +} + +func (s Signer) Verifier() ucan.Verifier { + return s.verifier() } -func (s Signer) Verifier() principal.Verifier { +func (s Signer) verifier() multikey.Verifier { sk, _ := secec.NewPrivateKey(s[tagSize:]) v, _ := verifier.FromRaw(sk.PublicKey().CompressedBytes()) return v } -func (s Signer) DID() did.DID { - return s.Verifier().DID() -} - // Bytes returns the private key bytes with multiformat prefix varint. func (s Signer) Bytes() []byte { return s @@ -137,3 +155,7 @@ func (s Signer) Sign(msg []byte) []byte { ) return sig } + +func (s Signer) KeyDID() did.DID { + return s.verifier().KeyDID() +} diff --git a/principal/secp256k1/signer_test.go b/multikey/secp256k1/signer_test.go similarity index 74% rename from principal/secp256k1/signer_test.go rename to multikey/secp256k1/signer_test.go index 520de1b..647b2cc 100644 --- a/principal/secp256k1/signer_test.go +++ b/multikey/secp256k1/signer_test.go @@ -5,7 +5,8 @@ import ( "crypto/sha256" "testing" - secp256k1 "github.com/fil-forge/ucantone/principal/secp256k1" + "github.com/fil-forge/ucantone/multikey" + "github.com/fil-forge/ucantone/multikey/secp256k1" "github.com/stretchr/testify/require" "gitlab.com/yawning/secp256k1-voi/secec" ) @@ -14,20 +15,21 @@ func TestGenerateEncodeDecode(t *testing.T) { s0, err := secp256k1.Generate() require.NoError(t, err) - t.Log(s0.DID().String()) + t.Log(multikey.FormatVerifier(s0.Verifier().(multikey.Verifier))) s1, err := secp256k1.Decode(s0.Bytes()) require.NoError(t, err) - t.Log(s1.DID().String()) - require.Equal(t, s0.DID(), s1.DID(), "public key mismatch") + t.Log(multikey.FormatVerifier(s1.Verifier().(multikey.Verifier))) + require.Equal(t, s0, s1, "private key mismatch") + require.Equal(t, s0.Verifier(), s1.Verifier(), "public key mismatch") } func TestGenerateFormatParse(t *testing.T) { s0, err := secp256k1.Generate() require.NoError(t, err) - t.Log(s0.DID().String()) + t.Log(multikey.FormatVerifier(s0.Verifier().(multikey.Verifier))) str := secp256k1.Format(s0) t.Log(str) @@ -35,8 +37,8 @@ func TestGenerateFormatParse(t *testing.T) { s1, err := secp256k1.Parse(str) require.NoError(t, err) - t.Log(s1.DID().String()) - require.Equal(t, s0.DID(), s1.DID(), "public key mismatch") + t.Log(multikey.FormatVerifier(s1.Verifier().(multikey.Verifier))) + require.Equal(t, s0.Verifier(), s1.Verifier(), "public key mismatch") } func TestVerify(t *testing.T) { diff --git a/principal/secp256k1/verifier/verifier.go b/multikey/secp256k1/verifier/verifier.go similarity index 72% rename from principal/secp256k1/verifier/verifier.go rename to multikey/secp256k1/verifier/verifier.go index 207a33d..f9f4d31 100644 --- a/principal/secp256k1/verifier/verifier.go +++ b/multikey/secp256k1/verifier/verifier.go @@ -7,32 +7,28 @@ import ( "fmt" "github.com/fil-forge/ucantone/did" - "github.com/fil-forge/ucantone/principal" - "github.com/fil-forge/ucantone/principal/multiformat" - keyverifier "github.com/fil-forge/ucantone/principal/verifier" - varsig_secp256k1 "github.com/fil-forge/ucantone/varsig/algorithm/secp256k1" + "github.com/fil-forge/ucantone/multikey" + "github.com/fil-forge/ucantone/multikey/internal/multiformat" "github.com/multiformats/go-multibase" + "github.com/multiformats/go-multicodec" "github.com/multiformats/go-varint" "gitlab.com/yawning/secp256k1-voi/secec" ) func init() { - keyverifier.Register(Code, func(b []byte) (principal.Verifier, error) { - return Decode(b) - }) + multikey.Register(Code, Decode) } -const Code = 0xe7 +// Code is the multicodec code for `secp256k1-pub`. +const Code = multicodec.Secp256k1Pub -var SignatureAlgorithm = varsig_secp256k1.New() - -var publicTagSize = varint.UvarintSize(Code) +var publicTagSize = varint.UvarintSize(uint64(Code)) const keySize = 33 var size = publicTagSize + keySize -func Parse(str string) (Verifier, error) { +func ParseKeyDID(str string) (multikey.Verifier, error) { did, err := did.Parse(str) if err != nil { return nil, fmt.Errorf("invalid DID: %w", err) @@ -50,11 +46,11 @@ func Parse(str string) (Verifier, error) { return Decode(bytes) } -func Format(verifier principal.Verifier) string { - return verifier.DID().String() +func Format(verifier multikey.Verifier) string { + return verifier.KeyDID().String() } -func Decode(b []byte) (Verifier, error) { +func Decode(b []byte) (multikey.Verifier, error) { if len(b) != size { return nil, fmt.Errorf("invalid length: %d wanted: %d", len(b), size) } @@ -62,7 +58,7 @@ func Decode(b []byte) (Verifier, error) { if err != nil { return nil, fmt.Errorf("reading uvarint: %w", err) } - if code != Code { + if code != uint64(Code) { return nil, fmt.Errorf("invalid public key codec: 0x%02x, expected: 0x%02x", code, Code) } _, err = secec.NewPublicKey(b[publicTagSize:]) @@ -89,12 +85,17 @@ func FromRaw(b []byte) (Verifier, error) { type Verifier []byte -var _ principal.Verifier = (Verifier)(nil) +var _ multikey.Verifier = (Verifier)(nil) -func (v Verifier) Code() uint64 { +func (v Verifier) Code() multicodec.Code { return Code } +func (v Verifier) PublicKey() any { + pk, _ := secec.NewPublicKey(v[publicTagSize:]) + return pk +} + func (v Verifier) Verify(msg []byte, sig []byte) bool { pk, err := secec.NewPublicKey(v[publicTagSize:]) if err != nil { @@ -113,10 +114,8 @@ func (v Verifier) Verify(msg []byte, sig []byte) bool { ) } -func (v Verifier) DID() did.DID { - b58key, _ := multibase.Encode(multibase.Base58BTC, v) - id, _ := did.Parse(did.KeyPrefix + b58key) - return id +func (v Verifier) String() string { + return multikey.FormatVerifier(v) } // Bytes returns the public key bytes with multiformat prefix varint. @@ -130,3 +129,7 @@ func (v Verifier) Raw() []byte { copy(k, v[publicTagSize:]) return k } + +func (v Verifier) KeyDID() did.DID { + return multikey.KeyDID(v) +} diff --git a/principal/secp256k1/verifier/verifier_test.go b/multikey/secp256k1/verifier/verifier_test.go similarity index 87% rename from principal/secp256k1/verifier/verifier_test.go rename to multikey/secp256k1/verifier/verifier_test.go index e750723..a5768c7 100644 --- a/principal/secp256k1/verifier/verifier_test.go +++ b/multikey/secp256k1/verifier/verifier_test.go @@ -3,16 +3,16 @@ package verifier_test import ( "testing" - "github.com/fil-forge/ucantone/principal/secp256k1/verifier" + "github.com/fil-forge/ucantone/multikey/secp256k1/verifier" "github.com/stretchr/testify/require" "gitlab.com/yawning/secp256k1-voi/secec" ) func TestParse(t *testing.T) { str := "did:key:zQ3shokFvN6Ggnq5j6G76527464y7n7y767y767y767y767y7" - v, err := verifier.Parse(str) + v, err := verifier.ParseKeyDID(str) require.NoError(t, err) - require.Equal(t, str, v.DID().String()) + require.Equal(t, str, v.KeyDID().String()) } func TestDecode(t *testing.T) { diff --git a/principal/absentee/signer.go b/principal/absentee/signer.go deleted file mode 100644 index 7381101..0000000 --- a/principal/absentee/signer.go +++ /dev/null @@ -1,36 +0,0 @@ -package absentee - -import ( - "github.com/fil-forge/ucantone/did" - "github.com/fil-forge/ucantone/ucan" - "github.com/fil-forge/ucantone/varsig" - "github.com/fil-forge/ucantone/varsig/algorithm/nonstandard" -) - -var SignatureAlgorithm = nonstandard.New() - -// Signer is a special type of signer that produces an absent signature, -// which signals that verifier needs to verify authorization interactively. -type Signer struct { - id did.DID -} - -var _ ucan.Signer = Signer{} - -func (a Signer) DID() did.DID { - return a.id -} - -func (a Signer) Sign(msg []byte) []byte { - return []byte{} -} - -func (a Signer) SignatureAlgorithm() varsig.SignatureAlgorithm { - return SignatureAlgorithm -} - -// From creates a special type of signer that produces an absent signature, -// which signals that verifier needs to verify authorization interactively. -func From(id did.DID) Signer { - return Signer{id} -} diff --git a/principal/multiformat/multiformat.go b/principal/multiformat/multiformat.go deleted file mode 100644 index 2436563..0000000 --- a/principal/multiformat/multiformat.go +++ /dev/null @@ -1,35 +0,0 @@ -package multiformat - -import ( - "bytes" - "fmt" - - "github.com/multiformats/go-varint" -) - -func TagWith(code uint64, bytes []byte) []byte { - offset := varint.UvarintSize(code) - tagged := make([]byte, len(bytes)+offset) - varint.PutUvarint(tagged, code) - copy(tagged[offset:], bytes) - return tagged -} - -func UntagWith(code uint64, source []byte, offset int) ([]byte, error) { - b := source - if offset != 0 { - b = source[offset:] - } - - tag, err := varint.ReadUvarint(bytes.NewReader(b)) - if err != nil { - return nil, err - } - - if tag != code { - return nil, fmt.Errorf("expected multiformat with 0x%x tag instead got 0x%x", code, tag) - } - - size := varint.UvarintSize(code) - return b[size:], nil -} diff --git a/principal/principal.go b/principal/principal.go deleted file mode 100644 index 455ad30..0000000 --- a/principal/principal.go +++ /dev/null @@ -1,29 +0,0 @@ -package principal - -import ( - "github.com/fil-forge/ucantone/ucan" -) - -// Signer is the principal that can issue UCANs (and sign payloads). While it's -// primary role is to sign payloads it also provides a `Verifier` interface so -// it can be used for verifying signed payloads as well. -type Signer interface { - ucan.Signer - Code() uint64 - Verifier() Verifier - // Bytes returns the private key bytes with multiformats prefix varint. - Bytes() []byte - // Raw encodes the bytes of the private key without multiformats tags. - Raw() []byte -} - -// Verifier is the principal that issued a UCAN. In usually represents remote -// principal and is used to verify that certain payloads were signed by it. -type Verifier interface { - ucan.Verifier - Code() uint64 - // Bytes returns the public key bytes with multiformats prefix varint. - Bytes() []byte - // Raw encodes the bytes of the public key without multiformats tags. - Raw() []byte -} diff --git a/principal/signer/signer.go b/principal/signer/signer.go deleted file mode 100644 index edf1f85..0000000 --- a/principal/signer/signer.go +++ /dev/null @@ -1,73 +0,0 @@ -package signer - -import ( - "fmt" - - "github.com/multiformats/go-multibase" - - "github.com/fil-forge/ucantone/did" - "github.com/fil-forge/ucantone/principal" - "github.com/fil-forge/ucantone/principal/verifier" - "github.com/fil-forge/ucantone/varsig" -) - -type Unwrapper interface { - // Unwrap returns the unwrapped did:key of this signer. - Unwrap() principal.Signer -} - -type WrappedSigner struct { - key principal.Signer - verifier principal.Verifier -} - -func (w *WrappedSigner) Code() uint64 { - return w.key.Code() -} - -func (w *WrappedSigner) DID() did.DID { - return w.verifier.DID() -} - -func (w *WrappedSigner) Bytes() []byte { - return w.key.Bytes() -} - -func (w *WrappedSigner) Raw() []byte { - return w.key.Raw() -} - -func (w *WrappedSigner) Sign(msg []byte) []byte { - return w.key.Sign(msg) -} - -func (w *WrappedSigner) SignatureAlgorithm() varsig.SignatureAlgorithm { - return w.key.SignatureAlgorithm() -} - -func (w *WrappedSigner) Unwrap() principal.Signer { - return w.key -} - -func (w *WrappedSigner) Verifier() principal.Verifier { - return w.verifier -} - -// Wrap the key of this signer into a signer with a different DID. This is -// primarily used to wrap a did:key signer with a signer that has a DID of -// a different method. -func Wrap(key principal.Signer, id did.DID) (*WrappedSigner, error) { - if key.DID().Method() != "key" { - return nil, fmt.Errorf("verifier is not a did:key") - } - vrf, err := verifier.Wrap(key.Verifier(), id) - if err != nil { - return nil, err - } - return &WrappedSigner{key, vrf}, nil -} - -func Format(signer principal.Signer) string { - s, _ := multibase.Encode(multibase.Base64pad, signer.Bytes()) - return s -} diff --git a/principal/signer/signer_test.go b/principal/signer/signer_test.go deleted file mode 100644 index a07676b..0000000 --- a/principal/signer/signer_test.go +++ /dev/null @@ -1,25 +0,0 @@ -package signer_test - -import ( - "testing" - - "github.com/fil-forge/ucantone/principal/ed25519" - "github.com/fil-forge/ucantone/principal/signer" - "github.com/stretchr/testify/require" -) - -func TestFormatParse(t *testing.T) { - s0, err := ed25519.Generate() - require.NoError(t, err) - - t.Log(s0.DID().String()) - - str := signer.Format(s0) - t.Log(str) - - s1, err := ed25519.Parse(str) - require.NoError(t, err) - - t.Log(s1.DID().String()) - require.Equal(t, s0.DID(), s1.DID(), "public key mismatch") -} diff --git a/principal/verifier/verifier.go b/principal/verifier/verifier.go deleted file mode 100644 index d5dc8cb..0000000 --- a/principal/verifier/verifier.go +++ /dev/null @@ -1,108 +0,0 @@ -package verifier - -import ( - "errors" - "fmt" - - "github.com/fil-forge/ucantone/did" - "github.com/fil-forge/ucantone/principal" - "github.com/multiformats/go-multibase" - "github.com/multiformats/go-varint" -) - -// Decoder decodes multiformat-tagged public key bytes into a Verifier. -type Decoder func([]byte) (principal.Verifier, error) - -var decoders = map[uint64]Decoder{} - -func Register(code uint64, d Decoder) { - decoders[code] = d -} - -type Unwrapper interface { - // Unwrap returns the unwrapped did:key of this signer. - Unwrap() principal.Verifier -} - -type WrappedVerifier struct { - id did.DID - key principal.Verifier -} - -func (w *WrappedVerifier) Code() uint64 { - return w.key.Code() -} - -func (w *WrappedVerifier) DID() did.DID { - return w.id -} - -func (w *WrappedVerifier) Bytes() []byte { - return w.key.Bytes() -} - -func (w *WrappedVerifier) Raw() []byte { - return w.key.Raw() -} - -func (w *WrappedVerifier) Verify(msg []byte, sig []byte) bool { - return w.key.Verify(msg, sig) -} - -func (w *WrappedVerifier) Unwrap() principal.Verifier { - return w.key -} - -// Wrap the key of this verifier into a verifier with a different DID. This is -// primarily used to wrap a did:key verifier with a verifier that has a DID of -// a different method. -func Wrap(key principal.Verifier, id did.DID) (*WrappedVerifier, error) { - if key.DID().Method() != "key" { - return nil, fmt.Errorf("verifier is not a did:key") - } - return &WrappedVerifier{id, key}, nil -} - -func Format(verifier principal.Verifier) string { - return verifier.DID().String() -} - -// Parse parses a string into a Verifier. The string must be a valid did:key -// DID. An appropriate decoder should be registered in advance with [Register] -// for the key type code. -func Parse(s string) (principal.Verifier, error) { - did, err := did.Parse(s) - if err != nil { - return nil, err - } - return FromDIDKey(did) -} - -// FromDIDKey decodes a did:key DID into a Verifier. An appropriate decoder -// should be registered in advance with [Register] for the key type code. -// Returns an error if the DID is not a did:key, if the did:key is malformed, or -// if there is no decoder registered for the key type code. -func FromDIDKey(did did.DID) (principal.Verifier, error) { - if did.Method() != "key" { - return nil, fmt.Errorf("unsupported DID method: %s", did.Method()) - } - - code, bytes, err := multibase.Decode(did.Identifier()) - if err != nil { - return nil, err - } - if code != multibase.Base58BTC { - return nil, errors.New("not Base58BTC encoded") - } - - keyTypeCode, _, err := varint.FromUvarint(bytes) - if err != nil { - return nil, fmt.Errorf("reading uvarint: %w", err) - } - - d, ok := decoders[keyTypeCode] - if !ok { - return nil, fmt.Errorf("no decoder registered for key type code: 0x%x", keyTypeCode) - } - return d(bytes) -} diff --git a/server/example_test.go b/server/example_test.go index 26efbf1..e3ddc68 100644 --- a/server/example_test.go +++ b/server/example_test.go @@ -9,7 +9,7 @@ import ( "github.com/fil-forge/ucantone/binding" "github.com/fil-forge/ucantone/client" "github.com/fil-forge/ucantone/execution" - "github.com/fil-forge/ucantone/principal/ed25519" + "github.com/fil-forge/ucantone/multikey/ed25519" "github.com/fil-forge/ucantone/server" tdm "github.com/fil-forge/ucantone/testutil/datamodel" "github.com/fil-forge/ucantone/ucan/command" @@ -35,7 +35,7 @@ func ExampleNewRoute() { }), } - service, _ := ed25519.Generate() + service, _ := ed25519.GenerateIssuer() srv := server.NewHTTP(service) for _, r := range routes { srv.Handle(r.Command, r.Handler) @@ -47,7 +47,7 @@ func ExampleNewRoute() { // A client invokes the command with typed arguments and unpacks the typed // result from the receipt. - alice, _ := ed25519.Generate() + alice, _ := ed25519.GenerateIssuer() inv, _ := echo.Invoke(alice, alice.DID(), &tdm.TestObject{Bytes: []byte("hi")}, invocation.WithAudience(service.DID())) resp, _ := c.Execute(execution.NewRequest(context.Background(), inv)) diff --git a/server/http.go b/server/http.go index 5e60901..e6a29dd 100644 --- a/server/http.go +++ b/server/http.go @@ -9,21 +9,20 @@ import ( "github.com/fil-forge/ucantone/execution" "github.com/fil-forge/ucantone/execution/dispatcher" - "github.com/fil-forge/ucantone/principal" "github.com/fil-forge/ucantone/transport" "github.com/fil-forge/ucantone/ucan" "github.com/fil-forge/ucantone/ucan/container" ) type HTTPServer struct { - id principal.Signer + id ucan.Issuer executor *dispatcher.Dispatcher codec transport.InboundCodec[*http.Request, *http.Response] listeners []EventListener } // NewHTTP creates a new server capable of handling UCAN invocations over HTTP. -func NewHTTP(id principal.Signer, options ...HTTPOption) *HTTPServer { +func NewHTTP(id ucan.Issuer, options ...HTTPOption) *HTTPServer { cfg := httpServerConfig{ codec: transport.DefaultHTTPInboundCodec, } diff --git a/server/http_test.go b/server/http_test.go index 34c15bd..cec77d2 100644 --- a/server/http_test.go +++ b/server/http_test.go @@ -18,8 +18,8 @@ import ( ) func TestHTTPServer(t *testing.T) { - service := testutil.RandomSigner(t) - alice := testutil.RandomSigner(t) + service := testutil.RandomIssuer(t) + alice := testutil.RandomIssuer(t) t.Run("invocation execution round trip", func(t *testing.T) { server := server.NewHTTP(service, server.WithReceiptTimestamps(true)) diff --git a/testutil/helpers.go b/testutil/helpers.go index 1729499..1429d2b 100644 --- a/testutil/helpers.go +++ b/testutil/helpers.go @@ -8,8 +8,8 @@ import ( "github.com/fil-forge/ucantone/did" "github.com/fil-forge/ucantone/ipld" "github.com/fil-forge/ucantone/ipld/datamodel" - "github.com/fil-forge/ucantone/principal" - "github.com/fil-forge/ucantone/principal/ed25519" + "github.com/fil-forge/ucantone/multikey" + "github.com/fil-forge/ucantone/multikey/ed25519" "github.com/fil-forge/ucantone/ucan" "github.com/ipfs/go-cid" "github.com/multiformats/go-multihash" @@ -76,12 +76,32 @@ func RandomDigest(t *testing.T) multihash.Multihash { return RandomCID(t).Hash() } -func RandomSigner(t *testing.T) principal.Signer { +func RandomMultikeySigner(t *testing.T) multikey.Signer { t.Helper() return Must(ed25519.Generate())(t) } +func RandomSigner(t *testing.T) ucan.Signer { + t.Helper() + return RandomMultikeySigner(t) +} + +func RandomMultikeyIssuer(t *testing.T) multikey.Issuer { + t.Helper() + return Must(ed25519.GenerateIssuer())(t) +} + +func RandomIssuer(t *testing.T) ucan.Issuer { + t.Helper() + return RandomMultikeyIssuer(t) +} + +func RandomPrincipal(t *testing.T) ucan.Principal { + t.Helper() + return RandomMultikeyIssuer(t) +} + func RandomDID(t *testing.T) did.DID { t.Helper() - return RandomSigner(t).DID() + return RandomMultikeyIssuer(t).DID() } diff --git a/transport/codec_test.go b/transport/codec_test.go index 62ad221..18c5df0 100644 --- a/transport/codec_test.go +++ b/transport/codec_test.go @@ -21,9 +21,9 @@ import ( ) func TestHTTPInboundCodec(t *testing.T) { - alice := testutil.RandomSigner(t) - bob := testutil.RandomSigner(t) - service := testutil.RandomSigner(t) + alice := testutil.RandomIssuer(t) + bob := testutil.RandomIssuer(t) + service := testutil.RandomIssuer(t) cmd, err := command.Parse("/console/log") require.NoError(t, err) @@ -120,9 +120,9 @@ func TestHTTPInboundCodec(t *testing.T) { } func TestHTTPOutboundCodec(t *testing.T) { - alice := testutil.RandomSigner(t) - bob := testutil.RandomSigner(t) - service := testutil.RandomSigner(t) + alice := testutil.RandomIssuer(t) + bob := testutil.RandomIssuer(t) + service := testutil.RandomIssuer(t) cmd, err := command.Parse("/console/log") require.NoError(t, err) diff --git a/ucan/container/container_test.go b/ucan/container/container_test.go index 81ad553..ff2cdc6 100644 --- a/ucan/container/container_test.go +++ b/ucan/container/container_test.go @@ -25,7 +25,7 @@ func TestContainer(t *testing.T) { } for _, code := range codecs { t.Run(container.FormatCodec(code)+" with invocation", func(t *testing.T) { - issuer := testutil.RandomSigner(t) + issuer := testutil.RandomIssuer(t) subject := testutil.RandomDID(t) command := testutil.Must(command.Parse("/test/invoke"))(t) arguments := testutil.RandomArgs(t) @@ -46,7 +46,7 @@ func TestContainer(t *testing.T) { } func TestContainerDeduplicates(t *testing.T) { - issuer := testutil.RandomSigner(t) + issuer := testutil.RandomIssuer(t) audience := testutil.RandomDID(t) subject := testutil.RandomDID(t) cmd := testutil.Must(command.Parse("/test/invoke"))(t) diff --git a/ucan/crypto/signature/signature.go b/ucan/crypto/signature/signature.go index 9982dcb..50c0a5f 100644 --- a/ucan/crypto/signature/signature.go +++ b/ucan/crypto/signature/signature.go @@ -5,13 +5,13 @@ import ( ) type Signature struct { - header varsig.VarsigHeader[varsig.SignatureAlgorithm, varsig.PayloadEncoding] + header varsig.Varsig bytes []byte } -func NewSignature[S varsig.SignatureAlgorithm, P varsig.PayloadEncoding](header varsig.VarsigHeader[S, P], bytes []byte) *Signature { +func NewSignature(header varsig.Varsig, bytes []byte) *Signature { return &Signature{ - varsig.NewHeader[varsig.SignatureAlgorithm, varsig.PayloadEncoding](header.SignatureAlgorithm(), header.PayloadEncoding()), + header, bytes, } } @@ -22,7 +22,7 @@ func (s *Signature) Bytes() []byte { } // Header implements ucan.Signature. -func (s *Signature) Header() varsig.VarsigHeader[varsig.SignatureAlgorithm, varsig.PayloadEncoding] { +func (s *Signature) Header() varsig.Varsig { return s.header } diff --git a/ucan/delegation/delegation.go b/ucan/delegation/delegation.go index 0572d7d..3587022 100644 --- a/ucan/delegation/delegation.go +++ b/ucan/delegation/delegation.go @@ -18,7 +18,6 @@ import ( edm "github.com/fil-forge/ucantone/ucan/envelope/datamodel" "github.com/fil-forge/ucantone/ucan/nonce" "github.com/fil-forge/ucantone/varsig" - varsig_dagcbor "github.com/fil-forge/ucantone/varsig/payload/dagcbor" ) type Delegation struct { @@ -151,7 +150,7 @@ func (d *Delegation) UnmarshalCBOR(r io.Reader) error { if sigPayload.TokenPayload1_0_0_rc1 == nil { return errors.New("invalid or unsupported delegation token payload") } - header, err := varsig.Decode(sigPayload.Header) + header, _, err := varsig.Decode(sigPayload.Header) if err != nil { return fmt.Errorf("decoding varsig header: %w", err) } @@ -188,7 +187,7 @@ func (d *Delegation) UnmarshalDagJSON(r io.Reader) error { if sigPayload.TokenPayload1_0_0_rc1 == nil { return errors.New("invalid or unsupported delegation token payload") } - header, err := varsig.Decode(sigPayload.Header) + header, _, err := varsig.Decode(sigPayload.Header) if err != nil { return fmt.Errorf("decoding varsig header: %w", err) } @@ -228,7 +227,7 @@ func Decode(b []byte) (*Delegation, error) { } func Delegate( - issuer ucan.Signer, + issuer ucan.Issuer, audience did.DID, subject did.DID, command ucan.Command, @@ -242,12 +241,9 @@ func Delegate( } } - sigAlgo, ok := varsig.GetSignatureAlgorithmCodec(issuer.SignatureAlgorithm()) - if !ok { - return nil, fmt.Errorf("missing codec for signature algorithm: %d", issuer.SignatureAlgorithm().Code()) - } - sigHeader := varsig.NewHeader(sigAlgo, varsig_dagcbor.NewCodec()) - h, err := varsig.Encode(sigHeader) + sigAlgo := issuer.SignatureAlgorithm() + vsig := varsig.New(sigAlgo, varsig.DagCbor) + h, err := vsig.Encode() if err != nil { return nil, fmt.Errorf("encoding varsig header: %w", err) } @@ -309,7 +305,7 @@ func Delegate( } sigBytes := issuer.Sign(sigBuf.Bytes()) - sig := signature.NewSignature(sigHeader, sigBytes) + sig := signature.NewSignature(vsig, sigBytes) envelope := edm.EnvelopeModel{ Signature: sigBytes, diff --git a/ucan/delegation/delegation_test.go b/ucan/delegation/delegation_test.go index aaf2da9..66668fe 100644 --- a/ucan/delegation/delegation_test.go +++ b/ucan/delegation/delegation_test.go @@ -6,20 +6,22 @@ import ( "os" "testing" + "github.com/stretchr/testify/require" + "github.com/fil-forge/ucantone/did" - "github.com/fil-forge/ucantone/principal/ed25519" - "github.com/fil-forge/ucantone/principal/secp256k1" + "github.com/fil-forge/ucantone/multikey" + "github.com/fil-forge/ucantone/multikey/ed25519" + "github.com/fil-forge/ucantone/multikey/secp256k1" "github.com/fil-forge/ucantone/testutil" "github.com/fil-forge/ucantone/ucan" "github.com/fil-forge/ucantone/ucan/command" "github.com/fil-forge/ucantone/ucan/delegation" "github.com/fil-forge/ucantone/ucan/token" - "github.com/stretchr/testify/require" ) func TestDelegation(t *testing.T) { t.Run("minimal", func(t *testing.T) { - issuer := testutil.RandomSigner(t) + issuer := testutil.RandomIssuer(t) audience := testutil.RandomDID(t) command := testutil.Must(command.Parse("/test/invoke"))(t) then := ucan.Now() @@ -42,7 +44,7 @@ func TestDelegation(t *testing.T) { }) t.Run("secp256k1", func(t *testing.T) { - issuer := testutil.Must(secp256k1.Generate())(t) + issuer := testutil.Must(secp256k1.GenerateIssuer())(t) audience := testutil.RandomDID(t) command := testutil.Must(command.Parse("/test/invoke"))(t) @@ -55,8 +57,7 @@ func TestDelegation(t *testing.T) { decoded, err := delegation.Decode(encoded) require.NoError(t, err) - ok, err := token.VerifySignature(decoded, issuer.Verifier()) - require.NoError(t, err) + ok := token.VerifySignature(decoded, issuer.Verifier()) require.True(t, ok) }) } @@ -102,12 +103,12 @@ func TestFixtures(t *testing.T) { err := json.Unmarshal(fixtureBytes, &fixtures) require.NoError(t, err) - principals := map[string]ucan.Signer{} + principals := map[string]multikey.Issuer{} for name, skstr := range fixtures.Principals { bytes := testutil.Must(base64.StdEncoding.DecodeString(skstr))(t) signer := testutil.Must(ed25519.Decode(bytes))(t) - principals[signer.DID().String()] = signer - t.Logf("%s: %s", name, signer.DID()) + principals[signer.KeyDID().String()] = multikey.KeyIssuer(signer) + t.Logf("%s: %s", name, signer.KeyDID()) } for _, vector := range fixtures.Valid { diff --git a/ucan/invocation/invocation.go b/ucan/invocation/invocation.go index 60503a2..d790fd4 100644 --- a/ucan/invocation/invocation.go +++ b/ucan/invocation/invocation.go @@ -20,7 +20,6 @@ import ( idm "github.com/fil-forge/ucantone/ucan/invocation/datamodel" "github.com/fil-forge/ucantone/ucan/nonce" "github.com/fil-forge/ucantone/varsig" - varsig_dagcbor "github.com/fil-forge/ucantone/varsig/payload/dagcbor" ) // Validity is the time an invocation is valid for by default. @@ -191,7 +190,7 @@ func (inv *Invocation) UnmarshalCBOR(r io.Reader) error { if sigPayload.TokenPayload1_0_0_rc1 == nil { return errors.New("invalid or unsupported invocation token payload") } - header, err := varsig.Decode(sigPayload.Header) + header, _, err := varsig.Decode(sigPayload.Header) if err != nil { return fmt.Errorf("decoding varsig header: %w", err) } @@ -238,7 +237,7 @@ func (inv *Invocation) UnmarshalDagJSON(r io.Reader) error { if sigPayload.TokenPayload1_0_0_rc1 == nil { return errors.New("invalid or unsupported invocation token payload") } - header, err := varsig.Decode(sigPayload.Header) + header, _, err := varsig.Decode(sigPayload.Header) if err != nil { return fmt.Errorf("decoding varsig header: %w", err) } @@ -292,7 +291,7 @@ func Decode(b []byte) (*Invocation, error) { // directly when the args have a typed cborgen schema; use InvokeMap when you // just want to pass a Go map literal. func InvokeMap( - issuer ucan.Signer, + issuer ucan.Issuer, subject did.DID, command ucan.Command, args map[string]any, @@ -306,7 +305,7 @@ func InvokeMap( // expects, and which encodes as a CBOR map (per the UCAN spec). // Pass nil to encode an empty CBOR map. func Invoke( - issuer ucan.Signer, + issuer ucan.Issuer, subject did.DID, command ucan.Command, args cbg.CBORMarshaler, @@ -317,12 +316,9 @@ func Invoke( opt(&cfg) } - sigAlgo, ok := varsig.GetSignatureAlgorithmCodec(issuer.SignatureAlgorithm()) - if !ok { - return nil, fmt.Errorf("missing codec for signature algorithm: %d", issuer.SignatureAlgorithm().Code()) - } - sigHeader := varsig.NewHeader(sigAlgo, varsig_dagcbor.NewCodec()) - h, err := varsig.Encode(sigHeader) + sigAlgo := issuer.SignatureAlgorithm() + vsig := varsig.New(sigAlgo, varsig.DagCbor) + h, err := vsig.Encode() if err != nil { return nil, fmt.Errorf("encoding varsig header: %w", err) } @@ -397,7 +393,7 @@ func Invoke( } sigBytes := issuer.Sign(sigBuf.Bytes()) - sig := signature.NewSignature(sigHeader, sigBytes) + sig := signature.NewSignature(vsig, sigBytes) envelope := edm.EnvelopeModel{ Signature: sigBytes, diff --git a/ucan/invocation/invocation_test.go b/ucan/invocation/invocation_test.go index 09ffce9..5f8fedd 100644 --- a/ucan/invocation/invocation_test.go +++ b/ucan/invocation/invocation_test.go @@ -5,7 +5,7 @@ import ( "testing" "github.com/fil-forge/ucantone/ipld/datamodel" - "github.com/fil-forge/ucantone/principal/secp256k1" + "github.com/fil-forge/ucantone/multikey/secp256k1" "github.com/fil-forge/ucantone/testutil" hdm "github.com/fil-forge/ucantone/testutil/datamodel" "github.com/fil-forge/ucantone/ucan" @@ -19,7 +19,7 @@ import ( func TestInvoke(t *testing.T) { t.Run("minimal", func(t *testing.T) { - issuer := testutil.RandomSigner(t) + issuer := testutil.RandomIssuer(t) subject := testutil.RandomDID(t) command := testutil.Must(command.Parse("/test/invoke"))(t) arguments := datamodel.Map{} @@ -43,7 +43,7 @@ func TestInvoke(t *testing.T) { }) t.Run("undefined command", func(t *testing.T) { - issuer := testutil.RandomSigner(t) + issuer := testutil.RandomIssuer(t) subject := testutil.RandomDID(t) arguments := datamodel.Map{} @@ -54,7 +54,7 @@ func TestInvoke(t *testing.T) { }) t.Run("no nonce", func(t *testing.T) { - issuer := testutil.RandomSigner(t) + issuer := testutil.RandomIssuer(t) subject := testutil.RandomDID(t) command := testutil.Must(command.Parse("/test/invoke"))(t) arguments := datamodel.Map{} @@ -73,7 +73,7 @@ func TestInvoke(t *testing.T) { }) t.Run("custom nonce", func(t *testing.T) { - issuer := testutil.RandomSigner(t) + issuer := testutil.RandomIssuer(t) subject := testutil.RandomDID(t) command := testutil.Must(command.Parse("/test/invoke"))(t) arguments := datamodel.Map{} @@ -92,7 +92,7 @@ func TestInvoke(t *testing.T) { }) t.Run("no expiration", func(t *testing.T) { - issuer := testutil.RandomSigner(t) + issuer := testutil.RandomIssuer(t) subject := testutil.RandomDID(t) command := testutil.Must(command.Parse("/test/invoke"))(t) arguments := datamodel.Map{} @@ -111,7 +111,7 @@ func TestInvoke(t *testing.T) { }) t.Run("custom expiration", func(t *testing.T) { - issuer := testutil.RandomSigner(t) + issuer := testutil.RandomIssuer(t) subject := testutil.RandomDID(t) command := testutil.Must(command.Parse("/test/invoke"))(t) arguments := datamodel.Map{} @@ -130,7 +130,7 @@ func TestInvoke(t *testing.T) { }) t.Run("custom audience", func(t *testing.T) { - issuer := testutil.RandomSigner(t) + issuer := testutil.RandomIssuer(t) subject := testutil.RandomDID(t) command := testutil.Must(command.Parse("/test/invoke"))(t) arguments := datamodel.Map{} @@ -149,7 +149,7 @@ func TestInvoke(t *testing.T) { }) t.Run("custom auguments", func(t *testing.T) { - issuer := testutil.RandomSigner(t) + issuer := testutil.RandomIssuer(t) subject := testutil.RandomDID(t) command := testutil.Must(command.Parse("/test/invoke"))(t) arguments := testutil.RandomArgs(t) @@ -170,7 +170,7 @@ func TestInvoke(t *testing.T) { }) t.Run("secp256k1", func(t *testing.T) { - issuer := testutil.Must(secp256k1.Generate())(t) + issuer := testutil.Must(secp256k1.GenerateIssuer())(t) subject := testutil.RandomDID(t) command := testutil.Must(command.Parse("/test/invoke"))(t) arguments := datamodel.Map{} @@ -184,8 +184,7 @@ func TestInvoke(t *testing.T) { decoded, err := invocation.Decode(encoded) require.NoError(t, err) - ok, err := token.VerifySignature(decoded, issuer.Verifier()) - require.NoError(t, err) + ok := token.VerifySignature(decoded, issuer.Verifier()) require.True(t, ok) }) } @@ -196,7 +195,7 @@ func TestInvoke(t *testing.T) { // would fail any time the decoded map's key ordering differs from our // canonical re-encode (which is the latent risk Raw eliminates). func TestArgsBytesRoundTrip(t *testing.T) { - issuer := testutil.RandomSigner(t) + issuer := testutil.RandomIssuer(t) subject := testutil.RandomDID(t) command := testutil.Must(command.Parse("/test/invoke"))(t) arguments := testutil.RandomArgs(t) @@ -224,15 +223,14 @@ func TestArgsBytesRoundTrip(t *testing.T) { // 3. Signature verification on the decoded invocation must succeed // (this exercises the Raw-bytes path through VerifySignature). - ok, err := token.VerifySignature(decoded, issuer.Verifier()) - require.NoError(t, err) + ok := token.VerifySignature(decoded, issuer.Verifier()) require.True(t, ok, "signature must verify after decode/re-encode") } // TestInvokeMap exercises the map-literal convenience path and confirms it // produces a decodable invocation byte-equivalent to the typed-args path. func TestInvokeMap(t *testing.T) { - issuer := testutil.RandomSigner(t) + issuer := testutil.RandomIssuer(t) subject := testutil.RandomDID(t) command := testutil.Must(command.Parse("/test/invoke"))(t) @@ -251,7 +249,7 @@ func TestInvokeMap(t *testing.T) { // must encode as a CBOR map. A typed value that encodes as a non-map (e.g. // a CBOR integer) must be rejected at the constructor. func TestInvokeRejectsNonMapArgs(t *testing.T) { - issuer := testutil.RandomSigner(t) + issuer := testutil.RandomIssuer(t) subject := testutil.RandomDID(t) command := testutil.Must(command.Parse("/test/invoke"))(t) @@ -267,7 +265,7 @@ func TestInvokeRejectsNonMapArgs(t *testing.T) { // with any other command MUST NOT decode as a receipt. This pins the // boundary so receipt.Decode can't silently accept arbitrary invocations. func TestNotAReceipt(t *testing.T) { - issuer := testutil.RandomSigner(t) + issuer := testutil.RandomIssuer(t) subject := testutil.RandomDID(t) command := testutil.Must(command.Parse("/test/invoke"))(t) diff --git a/ucan/receipt/receipt.go b/ucan/receipt/receipt.go index 2936289..858ea91 100644 --- a/ucan/receipt/receipt.go +++ b/ucan/receipt/receipt.go @@ -176,17 +176,17 @@ func Decode(b []byte) (*Receipt, error) { // IssueOK creates a receipt attesting to a successful execution. The ok value // is any cborgen-marshalable type whose schema matches what the executed // task's command expects to produce. -func IssueOK(executor ucan.Signer, ran cid.Cid, ok cbg.CBORMarshaler, options ...Option) (*Receipt, error) { +func IssueOK(executor ucan.Issuer, ran cid.Cid, ok cbg.CBORMarshaler, options ...Option) (*Receipt, error) { return issue(executor, ran, ok, nil, options...) } // IssueErr creates a receipt attesting to a failed execution. The err value // is any cborgen-marshalable type representing the failure. -func IssueErr(executor ucan.Signer, ran cid.Cid, err cbg.CBORMarshaler, options ...Option) (*Receipt, error) { +func IssueErr(executor ucan.Issuer, ran cid.Cid, err cbg.CBORMarshaler, options ...Option) (*Receipt, error) { return issue(executor, ran, nil, err, options...) } -func issue(executor ucan.Signer, ran cid.Cid, ok, errVal cbg.CBORMarshaler, options ...Option) (*Receipt, error) { +func issue(executor ucan.Issuer, ran cid.Cid, ok, errVal cbg.CBORMarshaler, options ...Option) (*Receipt, error) { if (ok == nil) == (errVal == nil) { return nil, errors.New("issue requires exactly one of ok or err to be non-nil") } @@ -238,15 +238,6 @@ func issue(executor ucan.Signer, ran cid.Cid, ok, errVal cbg.CBORMarshaler, opti }, nil } -// VerifySignature verifies the receipt's signature against the literal -// signed-payload bytes preserved on decode. -func VerifySignature(rcpt ucan.Receipt, verifier ucan.Verifier) (bool, error) { - if rcpt.Issuer() != verifier.DID() { - return false, nil - } - return verifier.Verify(rcpt.SignedBytes(), rcpt.Signature().Bytes()), nil -} - func marshalToRaw(m cbg.CBORMarshaler) (datamodel.Raw, error) { var buf bytes.Buffer if err := m.MarshalCBOR(&buf); err != nil { diff --git a/ucan/receipt/receipt_test.go b/ucan/receipt/receipt_test.go index 939edc0..ece915b 100644 --- a/ucan/receipt/receipt_test.go +++ b/ucan/receipt/receipt_test.go @@ -16,7 +16,7 @@ import ( ) func TestIssueOK(t *testing.T) { - executor := testutil.RandomSigner(t) + executor := testutil.RandomIssuer(t) ran := testutil.RandomCID(t) ok := cbg.CborInt(42) @@ -42,7 +42,7 @@ func TestIssueOK(t *testing.T) { } func TestIssueErr(t *testing.T) { - executor := testutil.RandomSigner(t) + executor := testutil.RandomIssuer(t) ran := testutil.RandomCID(t) errVal := cbg.CborInt(7) @@ -61,7 +61,7 @@ func TestIssueErr(t *testing.T) { } func TestOptions(t *testing.T) { - executor := testutil.RandomSigner(t) + executor := testutil.RandomIssuer(t) ran := testutil.RandomCID(t) t.Run("WithIssuedAt", func(t *testing.T) { @@ -89,37 +89,12 @@ func TestOptions(t *testing.T) { }) } -func TestVerifySignature(t *testing.T) { - executor := testutil.RandomSigner(t) - other := testutil.RandomSigner(t) - ran := testutil.RandomCID(t) - - ok := cbg.CborInt(42) - rcpt, err := receipt.IssueOK(executor, ran, &ok) - require.NoError(t, err) - - decoded, err := receipt.Decode(testutil.Must(receipt.Encode(rcpt))(t)) - require.NoError(t, err) - - t.Run("correct verifier", func(t *testing.T) { - ok, err := receipt.VerifySignature(decoded, executor.Verifier()) - require.NoError(t, err) - require.True(t, ok) - }) - - t.Run("wrong verifier", func(t *testing.T) { - ok, err := receipt.VerifySignature(decoded, other.Verifier()) - require.NoError(t, err) - require.False(t, ok) - }) -} - // TestNotInvocation asserts a Receipt does NOT satisfy ucan.Invocation. The // wire format is still an invocation, but the Go type is deliberately its own // thing so callers can't accidentally pass a receipt where an invocation is // expected. func TestNotInvocation(t *testing.T) { - executor := testutil.RandomSigner(t) + executor := testutil.RandomIssuer(t) ran := testutil.RandomCID(t) ok := cbg.CborInt(1) rcpt, err := receipt.IssueOK(executor, ran, &ok) @@ -137,7 +112,7 @@ func TestNotInvocation(t *testing.T) { // recoverable from the invocation's args. If the receipt encoding ever drifts // from the invocation encoding, this test fails. func TestWireFormatIsInvocation(t *testing.T) { - executor := testutil.RandomSigner(t) + executor := testutil.RandomIssuer(t) ran := testutil.RandomCID(t) ok := cbg.CborInt(42) @@ -170,8 +145,7 @@ func TestWireFormatIsInvocation(t *testing.T) { require.Equal(t, rcpt.SignedBytes(), inv.SignedBytes()) // The bytes verify as a signed invocation — not merely parse as one. - verified, err := token.VerifySignature(inv, executor.Verifier()) - require.NoError(t, err) + verified := token.VerifySignature(inv, executor.Verifier()) require.True(t, verified, "receipt bytes must verify through the invocation path") // Ran/Out are carried in the invocation's args, value intact. diff --git a/ucan/token/token.go b/ucan/token/token.go index ae68a14..0ba5727 100644 --- a/ucan/token/token.go +++ b/ucan/token/token.go @@ -8,9 +8,6 @@ import ( // signed-payload bytes preserved on decode. No reconstruction of the signing // payload from typed fields — verification operates on the exact bytes the // issuer signed, per the UCAN spec. -func VerifySignature(tok ucan.Token, verifier ucan.Verifier) (bool, error) { - if tok.Issuer() != verifier.DID() { - return false, nil - } - return verifier.Verify(tok.SignedBytes(), tok.Signature().Bytes()), nil +func VerifySignature(tok ucan.Token, verifier ucan.Verifier) bool { + return verifier.Verify(tok.SignedBytes(), tok.Signature().Bytes()) } diff --git a/ucan/ucan.go b/ucan/ucan.go index a96f189..106c047 100644 --- a/ucan/ucan.go +++ b/ucan/ucan.go @@ -29,31 +29,43 @@ type Command = command.Command // an explicit conversion from a known-seconds value. type UnixTimestamp int64 -// Signer is an entity that can sign UCANs on behalf of a DID. +type Principal interface { + DID() did.DID +} + +// Signer is an entity that can sign UCANs type Signer interface { crypto.Signer - // DID returns the DID this signer signs on behalf of. - DID() did.DID - // SignatureAlgorithm identifies the signature algorithm used by this signer // as well as any additional fields needed to configure it. - SignatureAlgorithm() varsig.SignatureAlgorithm + SignatureAlgorithm() varsig.Algorithm + + // Verifier returns a Verifier that can verify signatures from this Signer. + Verifier() Verifier } // Signature encapsulates the bytes that comprise the signature as well as the // details of the signing algorithm and payload encoding. type Signature interface { - Header() varsig.VarsigHeader[varsig.SignatureAlgorithm, varsig.PayloadEncoding] + Header() varsig.Varsig Bytes() []byte } -// Verifier is an entity that can verify UCAN signatures against a DID. +// Issuer can be the issuer of a UCAN. +type Issuer interface { + Principal + Signer + String() string +} + +// Verifier is an entity that can verify UCAN signatures type Verifier interface { signature.Verifier - // DID returns the DID this verifier verifies signatures for. - DID() did.DID + // String returns a human readable string representation of the verifier, for + // use in logging and error messages. + String() string } // Token is the common ancestor of elements of UCAN which make claims, including diff --git a/validator/errors/errors.go b/validator/errors/errors.go index fcd6aef..5701677 100644 --- a/validator/errors/errors.go +++ b/validator/errors/errors.go @@ -14,19 +14,21 @@ import ( const UnavailableProofErrorName = "UnavailableProof" -func NewUnavailableProofError(p cid.Cid, cause error) edm.ErrorModel { - return edm.ErrorModel{ +func NewUnavailableProofError(p cid.Cid, cause error) edm.ErrorModelWithCause { + return edm.ErrorModelWithCause{ ErrorName: UnavailableProofErrorName, - Message: fmt.Sprintf("linked proof %q could not be resolved: %s", p, cause.Error()), + Message: fmt.Sprintf("linked proof %q could not be resolved", p), + Cause: cause, } } const DIDKeyResolutionErrorName = "DIDKeyResolutionError" -func NewDIDKeyResolutionError(d did.DID, cause error) edm.ErrorModel { - return edm.ErrorModel{ +func NewDIDKeyResolutionError(d did.DID, cause error) edm.ErrorModelWithCause { + return edm.ErrorModelWithCause{ ErrorName: DIDKeyResolutionErrorName, - Message: fmt.Sprintf("unable to resolve %q key: %s", d, cause.Error()), + Message: fmt.Sprintf("unable to resolve %q key", d), + Cause: cause, } } @@ -56,31 +58,37 @@ func NewTooEarlyError(t ucan.Delegation) edm.ErrorModel { const InvalidSignatureErrorName = "InvalidSignature" -func NewInvalidSignatureError(token ucan.Token, verifier ucan.Verifier) edm.ErrorModel { +// VMRejection records a verification method that was tried and the reason it +// did not produce a valid signature. +type VMRejection struct { + VM did.VerificationMethod + Reason string +} + +func NewInvalidSignatureError(token ucan.Token, rejections []VMRejection) edm.ErrorModel { issuer := token.Issuer() - key := verifier.DID() - var message string - if issuer.Method() == "key" { - message = fmt.Sprintf(`proof %s does not have a valid signature from %s`, token.Link(), key) - } else { - message = strings.Join([]string{ - fmt.Sprintf("proof %q issued by %q does not have a valid signature from %q", token.Link(), issuer, key), - " ℹ️ Issuer probably signed with a different key, which got rotated, invalidating delegations that were issued with prior keys", - }, "\n") + var message strings.Builder + message.WriteString(strings.Join([]string{ + fmt.Sprintf("proof %q does not have a valid signature from %q", token.Link(), issuer), + " ℹ️ Tried these verification methods:", + }, "\n")) + for _, r := range rejections { + fmt.Fprintf(&message, "\n - %s: %s", r.VM.ID, r.Reason) } return edm.ErrorModel{ ErrorName: InvalidSignatureErrorName, - Message: message, + Message: message.String(), } } const UnverifiableSignatureErrorName = "UnverifiableSignature" -func NewUnverifiableSignatureError(token ucan.Token, cause error) edm.ErrorModel { +func NewUnverifiableSignatureError(token ucan.Token, cause error) edm.ErrorModelWithCause { issuer := token.Issuer() - return edm.ErrorModel{ + return edm.ErrorModelWithCause{ ErrorName: UnverifiableSignatureErrorName, - Message: fmt.Sprintf("proof %q issued by %q cannot be verified: %s", token.Link(), issuer, cause.Error()), + Message: fmt.Sprintf("proof %q issued by %q cannot be verified", token.Link(), issuer), + Cause: cause, } } @@ -114,10 +122,11 @@ func NewSubjectAlignmentError(expectedSubject did.DID, dlg ucan.Delegation) edm. const MalformedArgumentsErrorName = "MalformedArguments" -func NewMalformedArgumentsError(cmd ucan.Command, cause error) edm.ErrorModel { - return edm.ErrorModel{ +func NewMalformedArgumentsError(cmd ucan.Command, cause error) edm.ErrorModelWithCause { + return edm.ErrorModelWithCause{ ErrorName: MalformedArgumentsErrorName, - Message: fmt.Sprintf("malformed arguments for command %q: %s", cmd, cause.Error()), + Message: fmt.Sprintf("malformed arguments for command %q", cmd), + Cause: cause, } } diff --git a/validator/errors/errors_test.go b/validator/errors/errors_test.go new file mode 100644 index 0000000..fc05c9b --- /dev/null +++ b/validator/errors/errors_test.go @@ -0,0 +1,110 @@ +package errors_test + +import ( + "testing" + + "github.com/fil-forge/ucantone/did" + "github.com/fil-forge/ucantone/ipld/datamodel" + "github.com/fil-forge/ucantone/multikey" + "github.com/fil-forge/ucantone/testutil" + "github.com/fil-forge/ucantone/ucan" + "github.com/fil-forge/ucantone/ucan/command" + "github.com/fil-forge/ucantone/ucan/invocation" + verrs "github.com/fil-forge/ucantone/validator/errors" + "github.com/ipfs/go-cid" + "github.com/stretchr/testify/require" +) + +type token struct { + ucan.Token + link cid.Cid +} + +func (t token) Link() cid.Cid { return t.link } + +func TestNewInvalidSignatureError(t *testing.T) { + crankWidget := testutil.Must(command.Parse("/widget/crank"))(t) + + issuer := multikey.NewIssuer( + testutil.Must(did.Parse("did:example:123"))(t), + testutil.RandomMultikeySigner(t), + ) + tok := token{ + link: cid.MustParse("bafkqacyaexampletokenlink"), + Token: testutil.Must(invocation.Invoke(issuer, + issuer.DID(), + crankWidget, + datamodel.Map{}, + ))(t), + } + + keyID := testutil.Must(did.ParseURL("did:example:123#key-1"))(t) + controller := testutil.Must(did.Parse("did:example:123"))(t) + vm := did.VerificationMethod{ + ID: keyID, + Controller: controller, + Type: did.MultikeyVerificationMethodType, + Material: did.GenericMap{did.MultikeyPublicKeyMultibaseProp: "zABC"}, + } + + t.Run("no rejections", func(t *testing.T) { + err := verrs.NewInvalidSignatureError(tok, nil) + require.Equal(t, + `proof "bafkqacyaexampletokenlink" does not have a valid signature from "did:example:123"`+"\n"+ + ` ℹ️ Tried these verification methods:`, + err.Error()) + }) + + t.Run("expired VM", func(t *testing.T) { + err := verrs.NewInvalidSignatureError(tok, []verrs.VMRejection{ + {VM: vm, Reason: "expired"}, + }) + require.Equal(t, + `proof "bafkqacyaexampletokenlink" does not have a valid signature from "did:example:123"`+"\n"+ + ` ℹ️ Tried these verification methods:`+"\n"+ + ` - did:example:123#key-1: expired`, + err.Error()) + }) + + t.Run("revoked VM", func(t *testing.T) { + err := verrs.NewInvalidSignatureError(tok, []verrs.VMRejection{ + {VM: vm, Reason: "revoked"}, + }) + require.Equal(t, + `proof "bafkqacyaexampletokenlink" does not have a valid signature from "did:example:123"`+"\n"+ + ` ℹ️ Tried these verification methods:`+"\n"+ + ` - did:example:123#key-1: revoked`, + err.Error()) + }) + + t.Run("signature mismatch VM", func(t *testing.T) { + err := verrs.NewInvalidSignatureError(tok, []verrs.VMRejection{ + {VM: vm, Reason: "signature mismatch"}, + }) + require.Equal(t, + `proof "bafkqacyaexampletokenlink" does not have a valid signature from "did:example:123"`+"\n"+ + ` ℹ️ Tried these verification methods:`+"\n"+ + ` - did:example:123#key-1: signature mismatch`, + err.Error()) + }) + + t.Run("multiple rejections", func(t *testing.T) { + vm2ID := testutil.Must(did.ParseURL("did:example:123#key-2"))(t) + vm2 := did.VerificationMethod{ + ID: vm2ID, + Controller: controller, + Type: did.MultikeyVerificationMethodType, + Material: did.GenericMap{did.MultikeyPublicKeyMultibaseProp: "zDEF"}, + } + err := verrs.NewInvalidSignatureError(tok, []verrs.VMRejection{ + {VM: vm, Reason: "expired"}, + {VM: vm2, Reason: "signature mismatch"}, + }) + require.Equal(t, + `proof "bafkqacyaexampletokenlink" does not have a valid signature from "did:example:123"`+"\n"+ + ` ℹ️ Tried these verification methods:`+"\n"+ + ` - did:example:123#key-1: expired`+"\n"+ + ` - did:example:123#key-2: signature mismatch`, + err.Error()) + }) +} diff --git a/validator/internal/mkfixtures.go b/validator/internal/mkfixtures.go index 1495068..0f37a0d 100644 --- a/validator/internal/mkfixtures.go +++ b/validator/internal/mkfixtures.go @@ -10,7 +10,8 @@ import ( "github.com/fil-forge/ucantone/did" "github.com/fil-forge/ucantone/ipld/codec/dagcbor" "github.com/fil-forge/ucantone/ipld/datamodel" - "github.com/fil-forge/ucantone/principal/ed25519" + "github.com/fil-forge/ucantone/multikey" + "github.com/fil-forge/ucantone/multikey/ed25519" "github.com/fil-forge/ucantone/ucan" "github.com/fil-forge/ucantone/ucan/command" "github.com/fil-forge/ucantone/ucan/delegation" @@ -22,7 +23,7 @@ import ( verrs "github.com/fil-forge/ucantone/validator/errors" fdm "github.com/fil-forge/ucantone/validator/internal/fixtures/datamodel" "github.com/fil-forge/ucantone/varsig" - "github.com/fil-forge/ucantone/varsig/common" + "github.com/fil-forge/ucantone/varsig/algorithm/eddsa" "github.com/ipfs/go-cid" "github.com/multiformats/go-multihash" ) @@ -36,10 +37,10 @@ const ( ) var ( - alice = must(ed25519.Decode(must(base64.RawStdEncoding.DecodeString(Alice)))) - bob = must(ed25519.Decode(must(base64.RawStdEncoding.DecodeString(Bob)))) - carol = must(ed25519.Decode(must(base64.RawStdEncoding.DecodeString(Carol)))) - dave = must(ed25519.Decode(must(base64.RawStdEncoding.DecodeString(Dave)))) + alice = multikey.KeyIssuer(must(ed25519.Decode(must(base64.RawStdEncoding.DecodeString(Alice))))) + bob = multikey.KeyIssuer(must(ed25519.Decode(must(base64.RawStdEncoding.DecodeString(Bob))))) + carol = multikey.KeyIssuer(must(ed25519.Decode(must(base64.RawStdEncoding.DecodeString(Carol))))) + dave = multikey.KeyIssuer(must(ed25519.Decode(must(base64.RawStdEncoding.DecodeString(Dave))))) ) var ( @@ -635,7 +636,7 @@ func makeInvalidExpiredInvocationFixture() fdm.InvalidModel { } func makeInvalidProofSignatureFixture() fdm.InvalidModel { - h := must(varsig.Encode(common.Ed25519DagCbor)) + h := must(varsig.New(eddsa.Ed25519, varsig.DagCbor).Encode()) tokenPayload := &ddm.TokenPayloadModel1_0_0_rc1{ Iss: bob.DID(), @@ -689,7 +690,7 @@ func makeInvalidProofSignatureFixture() fdm.InvalidModel { } func makeInvalidInvocationSignatureFixture() fdm.InvalidModel { - h := must(varsig.Encode(common.Ed25519DagCbor)) + h := must(varsig.New(eddsa.Ed25519, varsig.DagCbor).Encode()) tokenPayload := &idm.TokenPayloadModel1_0_0_rc1{ Iss: alice.DID(), diff --git a/validator/options.go b/validator/options.go index ce1b80d..dd014f1 100644 --- a/validator/options.go +++ b/validator/options.go @@ -1,15 +1,46 @@ package validator -import "github.com/fil-forge/ucantone/ucan" +import ( + "time" + + "github.com/fil-forge/ucantone/did" + "github.com/fil-forge/ucantone/did/key" + "github.com/fil-forge/ucantone/multikey" + "github.com/fil-forge/ucantone/ucan" +) type validationConfig struct { resolveProof ProofResolverFunc - resolveDIDVerifier DIDVerifierResolverFunc + didResolver did.Resolver + verifierFactories map[string]VerifierFactory validationTime ucan.UnixTimestamp verifyNonStandardSignature NonStandardSignatureVerifierFunc metadata ucan.Container } +// DefaultFactories returns a map pre-populated with factories for the standard +// verification method types (currently Multikey). It is used by the validator +// when no custom registry is supplied via [WithVerifierRegistry]. +func DefaultFactories() map[string]VerifierFactory { + return map[string]VerifierFactory{ + did.MultikeyVerificationMethodType: multikey.DeriveVerifier, + } +} + +func makeCfg(options ...Option) validationConfig { + cfg := validationConfig{ + resolveProof: ProofUnavailable, + didResolver: key.Resolver, + verifierFactories: DefaultFactories(), + validationTime: ucan.UnixTimestamp(time.Now().Unix()), + verifyNonStandardSignature: FailNonStandardSignatureVerification, + } + for _, opt := range options { + opt(&cfg) + } + return cfg +} + // Option is an option configuring the validator. type Option func(*validationConfig) @@ -19,20 +50,17 @@ func WithProofResolver(resolveProof ProofResolverFunc) Option { } } -// WithDIDVerifierResolver sets the function to be used for resolving a DID to a -// verifier. -func WithDIDVerifierResolver(resolveDIDVerifier DIDVerifierResolverFunc) Option { +func WithDIDResolver(resolveDID did.Resolver) Option { return func(vc *validationConfig) { - vc.resolveDIDVerifier = resolveDIDVerifier + vc.didResolver = resolveDID } } -// WithDIDVerifierResolvers is a convenience option for composing a verifier -// resolver from multiple DID method-specific resolvers using -// [NewDIDVerifierResolverByMethod]. -func WithDIDVerifierResolvers(resolvers VerifierResolverMap) Option { +// WithVerifierFactories adds factories for deriving verifiers for a specific +// verification method type. +func WithVerifierFactories(factories map[string]VerifierFactory) Option { return func(vc *validationConfig) { - vc.resolveDIDVerifier = NewDIDVerifierResolverByMethod(resolvers) + vc.verifierFactories = factories } } @@ -58,3 +86,10 @@ func WithMetadata(meta ucan.Container) Option { vc.metadata = meta } } + +// withConfig reuses an entire built [validationConfig]. +func withConfig(cfg validationConfig) Option { + return func(vc *validationConfig) { + *vc = cfg + } +} diff --git a/validator/validator.go b/validator/validator.go index 5a198dd..7d68827 100644 --- a/validator/validator.go +++ b/validator/validator.go @@ -11,9 +11,6 @@ import ( "github.com/fil-forge/ucantone/did" "github.com/fil-forge/ucantone/ipld/datamodel" - _ "github.com/fil-forge/ucantone/principal/ed25519/verifier" - _ "github.com/fil-forge/ucantone/principal/secp256k1/verifier" - "github.com/fil-forge/ucantone/principal/verifier" "github.com/fil-forge/ucantone/ucan" "github.com/fil-forge/ucantone/ucan/token" verrs "github.com/fil-forge/ucantone/validator/errors" @@ -30,18 +27,10 @@ func ValidateInvocation( inv ucan.Invocation, options ...Option, ) error { - cfg := validationConfig{ - resolveProof: ProofUnavailable, - resolveDIDVerifier: ResolveDIDKeyVerifier, - validationTime: ucan.UnixTimestamp(time.Now().Unix()), - verifyNonStandardSignature: FailNonStandardSignatureVerification, - } - for _, opt := range options { - opt(&cfg) - } + cfg := makeCfg(options...) // To be valid, an invocation must be a valid token... - err := ValidateToken(ctx, inv, cfg) + err := ValidateToken(ctx, inv, withConfig(cfg)) if err != nil { return err } @@ -75,7 +64,13 @@ func ValidateInvocation( // time bounds. An [ucan.Invocation] is a token, but has additional // requirements. An invocation may be a valid token but still an invalid // invocation, if its proof chain is insufficient. -func ValidateToken(ctx context.Context, tok ucan.Token, cfg validationConfig) error { +func ValidateToken( + ctx context.Context, + tok ucan.Token, + options ...Option, +) error { + cfg := makeCfg(options...) + // To be valid, a token must have a valid signature from its issuer... err := verifyTokenSignature(ctx, tok, cfg) if err != nil { @@ -106,23 +101,61 @@ func ValidateToken(ctx context.Context, tok ucan.Token, cfg validationConfig) er // verifyTokenSignature verifies the token was signed by the passed verifier. func verifyTokenSignature(ctx context.Context, tok ucan.Token, cfg validationConfig) error { - if tok.Signature().Header().SignatureAlgorithm().Code() == nonstandard.Code { + if tok.Signature().Header().SignatureAlgorithm() == nonstandard.NonStandard { return cfg.verifyNonStandardSignature(ctx, tok, cfg.metadata) } - verifier, err := cfg.resolveDIDVerifier(ctx, tok.Issuer()) + doc, err := cfg.didResolver.Resolve(ctx, tok.Issuer()) if err != nil { return err } - ok, err := token.VerifySignature(tok, verifier) - if err != nil { - return err + // Look at the correct verification relationship in the DID Document. + var verRel *did.VerificationRelationship + switch tok.(type) { + case ucan.Invocation: + verRel = doc.CapabilityInvocation + case ucan.Delegation: + verRel = doc.CapabilityDelegation + default: + return fmt.Errorf("unsupported token type: %T", tok) } - if !ok { - return verrs.NewInvalidSignatureError(tok, verifier) + + // Try each verification method, collecting rejection reasons for the error. + validationTime := time.Unix(int64(cfg.validationTime), 0) + var rejections []verrs.VMRejection + + for _, vm := range verRel.All() { + if vm.ExpiredAt(validationTime) { + rejections = append(rejections, verrs.VMRejection{VM: vm, Reason: "expired"}) + continue + } + if vm.RevokedAt(validationTime) { + rejections = append(rejections, verrs.VMRejection{VM: vm, Reason: "revoked"}) + continue + } + f, ok := cfg.verifierFactories[vm.Type] + if !ok { + err = fmt.Errorf("%w for VM type %q", ErrNoVerifierFactory, vm.Type) + } + var v ucan.Verifier + if err == nil { + v, err = f(ctx, vm.Material) + } + if errors.Is(err, ErrNoVerifierFactory) { + rejections = append(rejections, verrs.VMRejection{VM: vm, Reason: "unsupported verification method type"}) + continue + } + if err != nil { + return err + } + if token.VerifySignature(tok, v) { + return nil + } + rejections = append(rejections, verrs.VMRejection{VM: vm, Reason: "signature mismatch"}) } - return nil + + return verrs.NewInvalidSignatureError(tok, rejections) } func capabilityFromProofChain(ctx context.Context, inv ucan.Invocation, cfg validationConfig) (Capability, error) { @@ -138,7 +171,7 @@ func capabilityFromProofChain(ctx context.Context, inv ucan.Invocation, cfg vali currentAuthority := inv.Subject() currentCapability := NewCapability(inv.Subject()) for i, prf := range prfs { - if err := ValidateToken(ctx, prf, cfg); err != nil { + if err := ValidateToken(ctx, prf, withConfig(cfg)); err != nil { return Capability{}, err } @@ -185,11 +218,6 @@ func capabilityFromProofChain(ctx context.Context, inv ucan.Invocation, cfg vali // ProofResolverFunc finds a delegation corresponding to an external proof link. type ProofResolverFunc func(ctx context.Context, link cid.Cid) (ucan.Delegation, error) -// DIDVerifierResolverFunc is used to resolve the verification methods of a -// DID. It returns a [ucan.Verifier] that can verify signatures from the given -// DID. -type DIDVerifierResolverFunc func(ctx context.Context, did did.DID) (ucan.Verifier, error) - // NonStandardSignatureVerifierFunc is used to verify signatures from // non-standard signature algorithms. It can be passed into a UCAN validator in // order to support delegations signed with non-standard signature algorithms. @@ -216,31 +244,6 @@ func ProofsFromContainer(c ucan.Container) ProofResolverFunc { } } -// ResolveDIDKeyVerifier is a [DIDVerifierResolverFunc] that only supports `did:key` -// DIDs and returns an error for any other DID method. -// -// To support multiple DID methods, use [NewDIDVerifierResolverByMethod] and -// include [ResolveDIDKeyVerifier] in the resolvers map for the "key" method. -func ResolveDIDKeyVerifier(ctx context.Context, d did.DID) (ucan.Verifier, error) { - return verifier.FromDIDKey(d) -} - -type VerifierResolverMap map[string]DIDVerifierResolverFunc - -// NewDIDVerifierResolverByMethod returns a [DIDVerifierResolverFunc] that -// dispatches to different resolver functions based on the method of the DID. If -// a DID is given in with a method that is not present in the resolvers map, the -// resolver will return an error. -func NewDIDVerifierResolverByMethod(resolvers VerifierResolverMap) DIDVerifierResolverFunc { - return func(ctx context.Context, d did.DID) (ucan.Verifier, error) { - resolver, ok := resolvers[d.Method()] - if !ok { - return nil, fmt.Errorf("unsupported DID method: %s", d.Method()) - } - return resolver(ctx, d) - } -} - func ValidateNotExpired(token ucan.Token, now ucan.UnixTimestamp) error { exp := token.Expiration() if exp == nil { diff --git a/validator/validator_test.go b/validator/validator_test.go index 6d78610..a8bc417 100644 --- a/validator/validator_test.go +++ b/validator/validator_test.go @@ -5,15 +5,15 @@ import ( "errors" "os" "testing" + "time" "github.com/ipfs/go-cid" "github.com/stretchr/testify/require" + "github.com/fil-forge/ucantone/absentee" "github.com/fil-forge/ucantone/did" + "github.com/fil-forge/ucantone/did/key" "github.com/fil-forge/ucantone/ipld/datamodel" - "github.com/fil-forge/ucantone/principal/absentee" - "github.com/fil-forge/ucantone/principal/ed25519" - "github.com/fil-forge/ucantone/principal/secp256k1" "github.com/fil-forge/ucantone/testutil" "github.com/fil-forge/ucantone/ucan" "github.com/fil-forge/ucantone/ucan/command" @@ -32,11 +32,11 @@ const ( now ucan.UnixTimestamp = 1746748800 // 2025-05-09 (fixed validation time for tests) ) -// badSigner is a Signer that produces invalid signatures, for testing purposes. -type badSigner struct{ ucan.Signer } +// badIssuer is a Signer that produces invalid signatures, for testing purposes. +type badIssuer struct{ ucan.Issuer } -func (b badSigner) Sign(msg []byte) []byte { - sig := b.Signer.Sign(msg) +func (b badIssuer) Sign(msg []byte) []byte { + sig := b.Issuer.Sign(msg) sig[0] ^= 0xff // flip a bit return sig } @@ -45,7 +45,7 @@ func TestValidate(t *testing.T) { crankWidget := testutil.Must(command.Parse("/widget/crank"))(t) t.Run("validates with root authority", func(t *testing.T) { - subject := testutil.RandomSigner(t) + subject := testutil.RandomIssuer(t) inv, err := invocation.Invoke(subject, subject.DID(), crankWidget, datamodel.Map{}) require.NoError(t, err) @@ -54,7 +54,7 @@ func TestValidate(t *testing.T) { }) t.Run("rejects with a bad signature", func(t *testing.T) { - subject := badSigner{testutil.RandomSigner(t)} + subject := badIssuer{testutil.RandomIssuer(t)} inv, err := invocation.Invoke(subject, subject.DID(), crankWidget, datamodel.Map{}) require.NoError(t, err) @@ -63,8 +63,8 @@ func TestValidate(t *testing.T) { }) t.Run("rejects with unauthorized invoker", func(t *testing.T) { - subject := testutil.RandomSigner(t) - invoker := testutil.RandomSigner(t) + subject := testutil.RandomIssuer(t) + invoker := testutil.RandomIssuer(t) inv, err := invocation.Invoke(subject, invoker.DID(), crankWidget, datamodel.Map{}) require.NoError(t, err) @@ -74,8 +74,8 @@ func TestValidate(t *testing.T) { }) t.Run("validates with subject → invoker", func(t *testing.T) { - subject := testutil.RandomSigner(t) - invoker := testutil.RandomSigner(t) + subject := testutil.RandomIssuer(t) + invoker := testutil.RandomIssuer(t) del, err := delegation.Delegate(subject, invoker.DID(), subject.DID(), crankWidget) require.NoError(t, err) @@ -102,7 +102,7 @@ func TestValidate(t *testing.T) { }) t.Run("rejects an expired invocation", func(t *testing.T) { - subject := testutil.RandomSigner(t) + subject := testutil.RandomIssuer(t) inv, err := invocation.Invoke(subject, subject.DID(), crankWidget, datamodel.Map{}, invocation.WithExpiration(past), ) @@ -113,7 +113,7 @@ func TestValidate(t *testing.T) { }) t.Run("accepts an invocation with a future expiry", func(t *testing.T) { - subject := testutil.RandomSigner(t) + subject := testutil.RandomIssuer(t) inv, err := invocation.Invoke(subject, subject.DID(), crankWidget, datamodel.Map{}, invocation.WithExpiration(future), ) @@ -124,8 +124,8 @@ func TestValidate(t *testing.T) { }) t.Run("rejects a proof that is not yet valid", func(t *testing.T) { - subject := testutil.RandomSigner(t) - invoker := testutil.RandomSigner(t) + subject := testutil.RandomIssuer(t) + invoker := testutil.RandomIssuer(t) del, err := delegation.Delegate(subject, invoker.DID(), subject.DID(), crankWidget, delegation.WithNotBefore(future), @@ -155,9 +155,9 @@ func TestValidate(t *testing.T) { }) t.Run("rejects when final proof audience does not match invoker", func(t *testing.T) { - subject := testutil.RandomSigner(t) - invoker := testutil.RandomSigner(t) - other := testutil.RandomSigner(t) + subject := testutil.RandomIssuer(t) + invoker := testutil.RandomIssuer(t) + other := testutil.RandomIssuer(t) // Delegation goes to other, but invoker invokes del, err := delegation.Delegate(subject, other.DID(), subject.DID(), crankWidget) @@ -185,10 +185,10 @@ func TestValidate(t *testing.T) { }) t.Run("rejects a broken mid-chain (issuer mismatch)", func(t *testing.T) { - subject := testutil.RandomSigner(t) - alice := testutil.RandomSigner(t) - bob := testutil.RandomSigner(t) - eve := testutil.RandomSigner(t) + subject := testutil.RandomIssuer(t) + alice := testutil.RandomIssuer(t) + bob := testutil.RandomIssuer(t) + eve := testutil.RandomIssuer(t) del1, err := delegation.Delegate(subject, alice.DID(), subject.DID(), crankWidget) require.NoError(t, err) @@ -218,9 +218,9 @@ func TestValidate(t *testing.T) { }) t.Run("validates with subject → alice → bob", func(t *testing.T) { - subject := testutil.RandomSigner(t) - alice := testutil.RandomSigner(t) - bob := testutil.RandomSigner(t) + subject := testutil.RandomIssuer(t) + alice := testutil.RandomIssuer(t) + bob := testutil.RandomIssuer(t) del1, err := delegation.Delegate(subject, alice.DID(), subject.DID(), crankWidget) require.NoError(t, err) @@ -249,8 +249,8 @@ func TestValidate(t *testing.T) { }) t.Run("rejects when a referenced proof cannot be resolved", func(t *testing.T) { - subject := testutil.RandomSigner(t) - invoker := testutil.RandomSigner(t) + subject := testutil.RandomIssuer(t) + invoker := testutil.RandomIssuer(t) del, err := delegation.Delegate(subject, invoker.DID(), subject.DID(), crankWidget) require.NoError(t, err) @@ -271,9 +271,9 @@ func TestValidate(t *testing.T) { // https://github.com/ucan-wg/delegation#powerline t.Run("validates with powerline delegation in chain", func(t *testing.T) { - subject := testutil.RandomSigner(t) - alice := testutil.RandomSigner(t) - bob := testutil.RandomSigner(t) + subject := testutil.RandomIssuer(t) + alice := testutil.RandomIssuer(t) + bob := testutil.RandomIssuer(t) del1, err := delegation.Delegate(subject, alice.DID(), subject.DID(), crankWidget) require.NoError(t, err) @@ -306,8 +306,8 @@ func TestValidate(t *testing.T) { // Explicitly disallowed by spec: // https://github.com/ucan-wg/delegation#powerline t.Run("rejects a powerline delegation at root of chain", func(t *testing.T) { - subject := testutil.RandomSigner(t) - invoker := testutil.RandomSigner(t) + subject := testutil.RandomIssuer(t) + invoker := testutil.RandomIssuer(t) // Root delegation has nil subject — invalid per spec. del, err := delegation.Delegate(subject, invoker.DID(), did.Undef, crankWidget) @@ -335,8 +335,8 @@ func TestValidate(t *testing.T) { }) t.Run("accepts a proof with a NotBefore in the past", func(t *testing.T) { - subject := testutil.RandomSigner(t) - invoker := testutil.RandomSigner(t) + subject := testutil.RandomIssuer(t) + invoker := testutil.RandomIssuer(t) del, err := delegation.Delegate(subject, invoker.DID(), subject.DID(), crankWidget, delegation.WithNotBefore(past), @@ -366,8 +366,8 @@ func TestValidate(t *testing.T) { }) t.Run("with a policy on a delegation", func(t *testing.T) { - subject := testutil.RandomSigner(t) - invoker := testutil.RandomSigner(t) + subject := testutil.RandomIssuer(t) + invoker := testutil.RandomIssuer(t) del, err := delegation.Delegate(subject, invoker.DID(), subject.DID(), crankWidget, delegation.WithPolicyBuilder(policy.Equal(".answer", 42)), @@ -416,10 +416,10 @@ func TestValidate(t *testing.T) { }) t.Run("rejects with incorrect subject in chain", func(t *testing.T) { - subject := testutil.RandomSigner(t) - alice := testutil.RandomSigner(t) - bob := testutil.RandomSigner(t) - unrelatedSubject := testutil.RandomSigner(t) + subject := testutil.RandomIssuer(t) + alice := testutil.RandomIssuer(t) + bob := testutil.RandomIssuer(t) + unrelatedSubject := testutil.RandomIssuer(t) del1, err := delegation.Delegate(subject, alice.DID(), subject.DID(), crankWidget) require.NoError(t, err) @@ -448,10 +448,59 @@ func TestValidate(t *testing.T) { require.Error(t, err) }) + t.Run("rejects when the signing key is expired", func(t *testing.T) { + subject := testutil.RandomIssuer(t) + + // Build a DID document with an expired Multikey VM. + expires := did.DateTimeStamp(time.Unix(int64(past), 0)) + resolver := expiredKeyResolver(t, subject, &expires, nil) + + inv, err := invocation.Invoke(subject, subject.DID(), crankWidget, datamodel.Map{}) + require.NoError(t, err) + + err = validator.ValidateInvocation(t.Context(), inv, + validator.WithDIDResolver(resolver), + validator.WithValidationTime(now), + ) + require.ErrorContains(t, err, "expired") + }) + + t.Run("rejects when the signing key is revoked", func(t *testing.T) { + subject := testutil.RandomIssuer(t) + + revoked := did.DateTimeStamp(time.Unix(int64(past), 0)) + resolver := expiredKeyResolver(t, subject, nil, &revoked) + + inv, err := invocation.Invoke(subject, subject.DID(), crankWidget, datamodel.Map{}) + require.NoError(t, err) + + err = validator.ValidateInvocation(t.Context(), inv, + validator.WithDIDResolver(resolver), + validator.WithValidationTime(now), + ) + require.ErrorContains(t, err, "revoked") + }) + + t.Run("accepts when the signing key has not yet expired", func(t *testing.T) { + subject := testutil.RandomIssuer(t) + + expires := did.DateTimeStamp(time.Unix(int64(future), 0)) + resolver := expiredKeyResolver(t, subject, &expires, nil) + + inv, err := invocation.Invoke(subject, subject.DID(), crankWidget, datamodel.Map{}) + require.NoError(t, err) + + err = validator.ValidateInvocation(t.Context(), inv, + validator.WithDIDResolver(resolver), + validator.WithValidationTime(now), + ) + require.NoError(t, err) + }) + t.Run("with non-standard signature in chain", func(t *testing.T) { - subject := testutil.RandomSigner(t) - alice := absentee.From(testutil.Must(did.Parse("did:mailto:web.mail:alice"))(t)) - bob := testutil.RandomSigner(t) + subject := testutil.RandomIssuer(t) + alice := absentee.From(testutil.Must(did.Parse("did:example:alice"))(t)) + bob := testutil.RandomIssuer(t) del1, err := delegation.Delegate(subject, alice.DID(), subject.DID(), crankWidget) require.NoError(t, err) @@ -478,12 +527,12 @@ func TestValidate(t *testing.T) { t.Context(), inv, validator.WithProofResolver(resolveProof), - validator.WithDIDVerifierResolvers(validator.VerifierResolverMap{ - "key": validator.ResolveDIDKeyVerifier, - "mailto": func(ctx context.Context, d did.DID) (ucan.Verifier, error) { + validator.WithDIDResolver(did.ResolverMap{ + "key": key.Resolver, + "example": did.ResolverFunc(func(ctx context.Context, d did.DID) (did.Document, error) { require.Fail(t, "shouldn't try to resolve a verifier for a non-standard signature") - return nil, nil - }, + return did.Document{}, nil + }), }), ) require.ErrorContains(t, err, "no non-standard signature verifier configured") @@ -521,65 +570,32 @@ func TestValidate(t *testing.T) { }) } -func TestResolveDIDKeyVerifier(t *testing.T) { - t.Run("ed25519 did:key returns a verifier matching the DID", func(t *testing.T) { - signer, err := ed25519.Generate() - require.NoError(t, err) - d := signer.Verifier().DID() - - v, err := validator.ResolveDIDKeyVerifier(t.Context(), d) - require.NoError(t, err) - require.NotNil(t, v) - require.Equal(t, d, v.DID()) - }) - - t.Run("ed25519 verifier verifies a signature from the corresponding signer", func(t *testing.T) { - signer, err := ed25519.Generate() - require.NoError(t, err) - d := signer.Verifier().DID() - - v, err := validator.ResolveDIDKeyVerifier(t.Context(), d) - require.NoError(t, err) - require.NotNil(t, v) - - msg := []byte("hello, world") - sig := signer.Sign(msg) - - require.True(t, v.Verify(msg, sig), "verifier should accept a valid signature") - - tampered := []byte("hello, worle") - require.False(t, v.Verify(tampered, sig), "verifier should reject a signature over a different message") - }) - - t.Run("secp256k1 did:key returns a verifier matching the DID", func(t *testing.T) { - signer, err := secp256k1.Generate() - require.NoError(t, err) - d := signer.Verifier().DID() - - v, err := validator.ResolveDIDKeyVerifier(t.Context(), d) - require.NoError(t, err) - require.NotNil(t, v) - require.Equal(t, d, v.DID()) - - msg := []byte("hello, world") - sig := signer.Sign(msg) - require.True(t, v.Verify(msg, sig)) - }) - - t.Run("rejects non-did:key DIDs", func(t *testing.T) { - for _, didStr := range []string{ - "did:web:example.com", - "did:dns:example.com", +// expiredKeyResolver returns a DID resolver that serves a document for the +// issuer's DID with its Multikey VM marked expired or revoked as specified. +func expiredKeyResolver(t *testing.T, issuer ucan.Issuer, expires, revoked *did.DateTimeStamp) did.Resolver { + t.Helper() + return did.ResolverFunc(func(_ context.Context, d did.DID) (did.Document, error) { + doc := did.NewDocument(d) + vm := did.VerificationMethod{ + ID: doc.Fragment(d.Identifier()), + Controller: d, + Expires: expires, + Revoked: revoked, + Type: did.MultikeyVerificationMethodType, + Material: did.GenericMap{did.MultikeyPublicKeyMultibaseProp: d.Identifier()}, + } + if err := doc.VerificationMethods.Add(vm); err != nil { + return did.Document{}, err + } + for _, rel := range []*did.VerificationRelationship{ + doc.Authentication, doc.AssertionMethod, + doc.CapabilityDelegation, doc.CapabilityInvocation, } { - t.Run(didStr, func(t *testing.T) { - d, err := did.Parse(didStr) - require.NoError(t, err) - - v, err := validator.ResolveDIDKeyVerifier(t.Context(), d) - require.Error(t, err) - require.Nil(t, v) - }) + if err := rel.Add(vm); err != nil { + return did.Document{}, err + } } + return doc, nil }) } @@ -596,56 +612,6 @@ func (s StubVerifier) Verify(msg []byte, sig []byte) bool { return false } -func TestNewDIDVerifierResolverByMethod(t *testing.T) { - resolveExample1DID := func(ctx context.Context, d did.DID) (ucan.Verifier, error) { - if d.Method() != "example1" { - return nil, errors.New("unsupported DID method") - } - return StubVerifier{did: d, resolverUsed: "example1"}, nil - } - - resolveExample2DID := func(ctx context.Context, d did.DID) (ucan.Verifier, error) { - if d.Method() != "example2" { - return nil, errors.New("unsupported DID method") - } - return StubVerifier{did: d, resolverUsed: "example2"}, nil - } - - resolver := validator.NewDIDVerifierResolverByMethod(validator.VerifierResolverMap{ - "example1": resolveExample1DID, - "example2": resolveExample2DID, - }) - - t.Run("dispatches to the correct resolver based on DID method", func(t *testing.T) { - d1, err := did.Parse("did:example1:alice") - require.NoError(t, err) - - v1, err := resolver(t.Context(), d1) - require.NoError(t, err) - require.NotNil(t, v1) - require.Equal(t, d1, v1.DID()) - require.Equal(t, "example1", v1.(StubVerifier).resolverUsed) - - d2, err := did.Parse("did:example2:bob") - require.NoError(t, err) - - v2, err := resolver(t.Context(), d2) - require.NoError(t, err) - require.NotNil(t, v2) - require.Equal(t, d2, v2.DID()) - require.Equal(t, "example2", v2.(StubVerifier).resolverUsed) - }) - - t.Run("returns an error for unsupported DID methods", func(t *testing.T) { - d, err := did.Parse("did:unsupported:example") - require.NoError(t, err) - - v, err := resolver(t.Context(), d) - require.Error(t, err) - require.Nil(t, v) - }) -} - type NamedError interface { error Name() string diff --git a/validator/verifiers.go b/validator/verifiers.go new file mode 100644 index 0000000..93908c1 --- /dev/null +++ b/validator/verifiers.go @@ -0,0 +1,18 @@ +package validator + +import ( + "context" + "fmt" + + "github.com/fil-forge/ucantone/did" + "github.com/fil-forge/ucantone/ucan" +) + +// VerifierFactory is a function that produces a [ucan.Verifier] from +// [did.VerificationMaterial] for a specific verification method type. +type VerifierFactory func(context.Context, did.VerificationMaterial) (ucan.Verifier, error) + +// ErrNoVerifierFactory is returned when no factory is registered for a +// verification method's type. Callers that want to skip unsupported VM types +// rather than fail should check for this error. +var ErrNoVerifierFactory = fmt.Errorf("no verifier factory registered") diff --git a/varsig/README.md b/varsig/README.md index a678f87..98f5ba5 100644 --- a/varsig/README.md +++ b/varsig/README.md @@ -46,6 +46,15 @@ func main() { } ``` +## Terminology + +In the Varsig spec, the [signature algorithm](https://github.com/ChainAgnostic/varsig#signature-algorithm) is specified by a prefix discriminant followed by fields required for that prefix. That leaves a semantic ambiguity: is a "signature algorithm" what the prefix selects, or what the entire set of values specifies? There doesn't seem to be a ubiquitous pair of precise terms for these. In this library, the two concepts are named this way: + +* A *Signature Scheme* is what the prefix selects. +* A *Signature Algorithm* is a fully specified algorithm, consisting of a Signature Scheme and any values needed to configure it. + +[ECDSA](https://en.wikipedia.org/wiki/ECDSA) is a Signature Scheme, as is [EdDSA](https://en.wikipedia.org/wiki/EdDSA). [Ed25519](https://en.wikipedia.org/wiki/EdDSA#Ed25519) is a Signature Algorithm (EdDSA using Curve25519 and SHA-512), as is [Ed448](https://en.wikipedia.org/wiki/EdDSA#Ed448) (EdDSA using Curve448 and SHAKE256). + ## Contributing Feel free to join in. All welcome. Please [open an issue](https://github.com/fil-forge/ucantone/issues)! diff --git a/varsig/algorithm/algorithm.go b/varsig/algorithm/algorithm.go new file mode 100644 index 0000000..5f8351f --- /dev/null +++ b/varsig/algorithm/algorithm.go @@ -0,0 +1,11 @@ +package algorithm + +type AlgorithmDef struct { + Code uint64 + Name string + Decoder func(input []byte) (Algorithm, int, error) +} + +type Algorithm interface { + Encode() ([]byte, error) +} diff --git a/varsig/algorithm/ecdsa/codec.go b/varsig/algorithm/ecdsa/codec.go deleted file mode 100644 index 129c818..0000000 --- a/varsig/algorithm/ecdsa/codec.go +++ /dev/null @@ -1,101 +0,0 @@ -package ecdsa - -import ( - "fmt" - - varint "github.com/multiformats/go-varint" -) - -const Code = 0xec - -type SignatureAlgorithm struct { - curve uint64 - hashAlgo uint64 -} - -func New(curve uint64, hashAlgo uint64) SignatureAlgorithm { - return SignatureAlgorithm{curve, hashAlgo} -} - -func (sa SignatureAlgorithm) Code() uint64 { - return Code -} - -func (sa SignatureAlgorithm) Segments() []uint64 { - return []uint64{Code, sa.curve, sa.hashAlgo} -} - -func (sa SignatureAlgorithm) Curve() uint64 { - return sa.curve -} - -func (sa SignatureAlgorithm) HashAlgorithm() uint64 { - return sa.hashAlgo -} - -type Codec struct { - curve uint64 - hashAlgo uint64 -} - -func NewCodec(curve uint64, hashAlgo uint64) Codec { - return Codec{curve, hashAlgo} -} - -func (sac Codec) Code() uint64 { - return Code -} - -func (sac Codec) Segments() []uint64 { - return []uint64{Code, sac.curve, sac.hashAlgo} -} - -func (sac Codec) Curve() uint64 { - return sac.curve -} - -func (sac Codec) HashAlgorithm() uint64 { - return sac.hashAlgo -} - -func (sac Codec) Encode() ([]byte, error) { - size := varint.UvarintSize(Code) - size += varint.UvarintSize(sac.curve) - size += varint.UvarintSize(sac.hashAlgo) - out := make([]byte, size) - offset := varint.PutUvarint(out, Code) - offset += varint.PutUvarint(out[offset:], sac.curve) - varint.PutUvarint(out[offset:], sac.hashAlgo) - return out, nil -} - -func (sac Codec) Decode(input []byte) (SignatureAlgorithm, int, error) { - code, n, err := varint.FromUvarint(input) - if err != nil { - return SignatureAlgorithm{}, 0, err - } - if code != Code { - return SignatureAlgorithm{}, n, fmt.Errorf("signature code is not ECDSA: 0x%02x, expected: 0x%02x", code, Code) - } - offset := n - - curve, n, err := varint.FromUvarint(input[offset:]) - if err != nil { - return SignatureAlgorithm{}, 0, err - } - if curve != sac.curve { - return SignatureAlgorithm{}, n, fmt.Errorf("unexpected curve code: 0x%02x, expected: 0x%02x", curve, sac.curve) - } - offset += n - - hashAlgo, n, err := varint.FromUvarint(input[offset:]) - if err != nil { - return SignatureAlgorithm{}, 0, err - } - if hashAlgo != sac.hashAlgo { - return SignatureAlgorithm{}, n, fmt.Errorf("unexpected hash algorithm code: 0x%02x, expected: 0x%02x", hashAlgo, sac.hashAlgo) - } - offset += n - - return SignatureAlgorithm{curve, hashAlgo}, offset, nil -} diff --git a/varsig/algorithm/ecdsa/ecdsa.go b/varsig/algorithm/ecdsa/ecdsa.go new file mode 100644 index 0000000..7f0a8ca --- /dev/null +++ b/varsig/algorithm/ecdsa/ecdsa.go @@ -0,0 +1,84 @@ +package ecdsa + +import ( + "fmt" + + "github.com/fil-forge/ucantone/varsig/algorithm" + "github.com/multiformats/go-multicodec" + varint "github.com/multiformats/go-varint" +) + +// Varsig discriminant for ECDSA signature algorithms. Note that these +// discriminants are not in the multicodec table. They're [defined directly by +// Varsig](https://github.com/ChainAgnostic/varsig#signature-algorithm). +const Code = 0xEC + +type Algorithm struct { + curve multicodec.Code + hashAlgo multicodec.Code +} + +func New(curve multicodec.Code, hashAlgo multicodec.Code) Algorithm { + return Algorithm{curve, hashAlgo} +} + +func (alg Algorithm) Segments() []uint64 { + return []uint64{Code, uint64(alg.curve), uint64(alg.hashAlgo)} +} + +// Curve returns the multicodec code for the curve used in this algorithm. +func (alg Algorithm) Curve() multicodec.Code { + return alg.curve +} + +// HashAlgorithm returns the multicodec code for the hash algorithm used in this +// algorithm. +func (alg Algorithm) HashAlgorithm() multicodec.Code { + return alg.hashAlgo +} + +func (alg Algorithm) Encode() ([]byte, error) { + size := varint.UvarintSize(Code) + size += varint.UvarintSize(uint64(alg.curve)) + size += varint.UvarintSize(uint64(alg.hashAlgo)) + out := make([]byte, size) + offset := varint.PutUvarint(out, Code) + offset += varint.PutUvarint(out[offset:], uint64(alg.curve)) + varint.PutUvarint(out[offset:], uint64(alg.hashAlgo)) + return out, nil +} + +func Decode(input []byte) (algorithm.Algorithm, int, error) { + code, n, err := varint.FromUvarint(input) + if err != nil { + return nil, 0, err + } + if code != Code { + return nil, n, fmt.Errorf("signature code is not Ecdsa: 0x%02x, expected: 0x%02x", code, Code) + } + offset := n + + curve, n, err := varint.FromUvarint(input[offset:]) + if err != nil { + return nil, 0, err + } + curveCode := multicodec.Code(curve) + if curveCode.Tag() != "key" { + return nil, 0, fmt.Errorf("invalid curve code: 0x%02x (%s, '%s'), expected a multicodec with 'key' tag", curve, curveCode, curveCode.Tag()) + } + offset += n + + hashAlgo, n, err := varint.FromUvarint(input[offset:]) + if err != nil { + return nil, 0, err + } + hashAlgoCode := multicodec.Code(hashAlgo) + if hashAlgoCode.Tag() != "multihash" { + return nil, 0, fmt.Errorf("invalid hash algorithm code: 0x%02x (%s, '%s'), expected a multicodec with 'multihash' tag", hashAlgo, hashAlgoCode, hashAlgoCode.Tag()) + } + offset += n + + return New(curveCode, hashAlgoCode), offset, nil +} + +var Secp256k1 = New(multicodec.Secp256k1Pub, multicodec.Sha2_256) diff --git a/varsig/algorithm/ecdsa/ecdsa_test.go b/varsig/algorithm/ecdsa/ecdsa_test.go new file mode 100644 index 0000000..312a216 --- /dev/null +++ b/varsig/algorithm/ecdsa/ecdsa_test.go @@ -0,0 +1,47 @@ +package ecdsa_test + +import ( + "testing" + + "github.com/fil-forge/ucantone/varsig/algorithm/ecdsa" + "github.com/multiformats/go-multicodec" + "github.com/stretchr/testify/require" +) + +func TestEcdsa(t *testing.T) { + t.Run("round trips", func(t *testing.T) { + alg := ecdsa.New(multicodec.Ed25519Pub, multicodec.Sha2_512) + require.Equal(t, multicodec.Ed25519Pub, alg.Curve()) + require.Equal(t, multicodec.Sha2_512, alg.HashAlgorithm()) + + bytes, err := alg.Encode() + require.NoError(t, err) + + decodedAlg, n, err := ecdsa.Decode(bytes) + decodedEcdsaAlg, ok := decodedAlg.(ecdsa.Algorithm) + require.True(t, ok) + + require.NoError(t, err) + require.Equal(t, len(bytes), n) + require.Equal(t, alg.Curve(), decodedEcdsaAlg.Curve()) + require.Equal(t, alg.HashAlgorithm(), decodedEcdsaAlg.HashAlgorithm()) + }) + + t.Run("fails with invalid curve", func(t *testing.T) { + alg := ecdsa.New(multicodec.Tcp, multicodec.Sha2_512) + bytes, err := alg.Encode() + require.NoError(t, err) + + _, _, err = ecdsa.Decode(bytes) + require.ErrorContains(t, err, "invalid curve code: 0x06 (tcp, 'multiaddr'), expected a multicodec with 'key' tag") + }) + + t.Run("fails with invalid hash algorithm", func(t *testing.T) { + alg := ecdsa.New(multicodec.Ed25519Pub, multicodec.Tcp) + bytes, err := alg.Encode() + require.NoError(t, err) + + _, _, err = ecdsa.Decode(bytes) + require.ErrorContains(t, err, "invalid hash algorithm code: 0x06 (tcp, 'multiaddr'), expected a multicodec with 'multihash' tag") + }) +} diff --git a/varsig/algorithm/ed25519/codec.go b/varsig/algorithm/ed25519/codec.go deleted file mode 100644 index 38a9b51..0000000 --- a/varsig/algorithm/ed25519/codec.go +++ /dev/null @@ -1,25 +0,0 @@ -package ed25519 - -import ( - "github.com/fil-forge/ucantone/varsig" - "github.com/fil-forge/ucantone/varsig/algorithm/eddsa" -) - -const Code = 0xED -const Sha2_512 = 0x13 - -type SignatureAlgorithm = eddsa.SignatureAlgorithm - -func init() { - varsig.RegisterSignatureAlgorithm(NewCodec()) -} - -func New() SignatureAlgorithm { - return eddsa.New(Code, Sha2_512) -} - -type Codec = eddsa.Codec - -func NewCodec() Codec { - return eddsa.NewCodec(Code, Sha2_512) -} diff --git a/varsig/algorithm/eddsa/codec.go b/varsig/algorithm/eddsa/codec.go deleted file mode 100644 index ab8740b..0000000 --- a/varsig/algorithm/eddsa/codec.go +++ /dev/null @@ -1,101 +0,0 @@ -package eddsa - -import ( - "fmt" - - varint "github.com/multiformats/go-varint" -) - -const Code = 0xED - -type SignatureAlgorithm struct { - curve uint64 - hashAlgo uint64 -} - -func New(curve uint64, hashAlgo uint64) SignatureAlgorithm { - return SignatureAlgorithm{curve, hashAlgo} -} - -func (sa SignatureAlgorithm) Code() uint64 { - return Code -} - -func (sa SignatureAlgorithm) Segments() []uint64 { - return []uint64{Code, sa.curve, sa.hashAlgo} -} - -func (sa SignatureAlgorithm) Curve() uint64 { - return sa.curve -} - -func (sa SignatureAlgorithm) HashAlgorithm() uint64 { - return sa.hashAlgo -} - -type Codec struct { - curve uint64 - hashAlgo uint64 -} - -func NewCodec(curve uint64, hashAlgo uint64) Codec { - return Codec{curve, hashAlgo} -} - -func (sac Codec) Code() uint64 { - return Code -} - -func (sac Codec) Segments() []uint64 { - return []uint64{Code, sac.curve, sac.hashAlgo} -} - -func (sac Codec) Curve() uint64 { - return sac.curve -} - -func (sac Codec) HashAlgorithm() uint64 { - return sac.hashAlgo -} - -func (sac Codec) Encode() ([]byte, error) { - size := varint.UvarintSize(Code) - size += varint.UvarintSize(sac.curve) - size += varint.UvarintSize(sac.hashAlgo) - out := make([]byte, size) - offset := varint.PutUvarint(out, Code) - offset += varint.PutUvarint(out[offset:], sac.curve) - varint.PutUvarint(out[offset:], sac.hashAlgo) - return out, nil -} - -func (sac Codec) Decode(input []byte) (SignatureAlgorithm, int, error) { - code, n, err := varint.FromUvarint(input) - if err != nil { - return SignatureAlgorithm{}, 0, err - } - if code != Code { - return SignatureAlgorithm{}, n, fmt.Errorf("signature code is not EdDSA: 0x%02x, expected: 0x%02x", code, Code) - } - offset := n - - curve, n, err := varint.FromUvarint(input[offset:]) - if err != nil { - return SignatureAlgorithm{}, 0, err - } - if curve != sac.curve { - return SignatureAlgorithm{}, n, fmt.Errorf("unexpected curve code: 0x%02x, expected: 0x%02x", curve, sac.curve) - } - offset += n - - hashAlgo, n, err := varint.FromUvarint(input[offset:]) - if err != nil { - return SignatureAlgorithm{}, 0, err - } - if hashAlgo != sac.hashAlgo { - return SignatureAlgorithm{}, n, fmt.Errorf("unexpected hash algorithm code: 0x%02x, expected: 0x%02x", hashAlgo, sac.hashAlgo) - } - offset += n - - return SignatureAlgorithm{curve, hashAlgo}, offset, nil -} diff --git a/varsig/algorithm/eddsa/eddsa.go b/varsig/algorithm/eddsa/eddsa.go new file mode 100644 index 0000000..bf08709 --- /dev/null +++ b/varsig/algorithm/eddsa/eddsa.go @@ -0,0 +1,84 @@ +package eddsa + +import ( + "fmt" + + "github.com/fil-forge/ucantone/varsig/algorithm" + "github.com/multiformats/go-multicodec" + varint "github.com/multiformats/go-varint" +) + +// Varsig discriminant for EdDSA signature algorithms. Note that these +// discriminants are not in the multicodec table. They're [defined directly by +// Varsig](https://github.com/ChainAgnostic/varsig#signature-algorithm). +const Code = 0xED + +type Algorithm struct { + curve multicodec.Code + hashAlgo multicodec.Code +} + +func New(curve multicodec.Code, hashAlgo multicodec.Code) Algorithm { + return Algorithm{curve, hashAlgo} +} + +func (alg Algorithm) Segments() []uint64 { + return []uint64{Code, uint64(alg.curve), uint64(alg.hashAlgo)} +} + +// Curve returns the multicodec code for the curve used in this algorithm. +func (alg Algorithm) Curve() multicodec.Code { + return alg.curve +} + +// HashAlgorithm returns the multicodec code for the hash algorithm used in this +// algorithm. +func (alg Algorithm) HashAlgorithm() multicodec.Code { + return alg.hashAlgo +} + +func (alg Algorithm) Encode() ([]byte, error) { + size := varint.UvarintSize(Code) + size += varint.UvarintSize(uint64(alg.curve)) + size += varint.UvarintSize(uint64(alg.hashAlgo)) + out := make([]byte, size) + offset := varint.PutUvarint(out, Code) + offset += varint.PutUvarint(out[offset:], uint64(alg.curve)) + varint.PutUvarint(out[offset:], uint64(alg.hashAlgo)) + return out, nil +} + +func Decode(input []byte) (algorithm.Algorithm, int, error) { + code, n, err := varint.FromUvarint(input) + if err != nil { + return Algorithm{}, 0, err + } + if code != Code { + return Algorithm{}, n, fmt.Errorf("signature code is not ECDSA: 0x%02x, expected: 0x%02x", code, Code) + } + offset := n + + curve, n, err := varint.FromUvarint(input[offset:]) + if err != nil { + return Algorithm{}, 0, err + } + curveCode := multicodec.Code(curve) + if curveCode.Tag() != "key" { + return Algorithm{}, 0, fmt.Errorf("invalid curve code: 0x%02x (%s, '%s'), expected a multicodec with 'key' tag", curve, curveCode, curveCode.Tag()) + } + offset += n + + hashAlgo, n, err := varint.FromUvarint(input[offset:]) + if err != nil { + return Algorithm{}, 0, err + } + hashAlgoCode := multicodec.Code(hashAlgo) + if hashAlgoCode.Tag() != "multihash" { + return Algorithm{}, 0, fmt.Errorf("invalid hash algorithm code: 0x%02x (%s, '%s'), expected a multicodec with 'multihash' tag", hashAlgo, hashAlgoCode, hashAlgoCode.Tag()) + } + offset += n + + return New(curveCode, hashAlgoCode), offset, nil +} + +var Ed25519 = New(multicodec.Ed25519Pub, multicodec.Sha2_512) diff --git a/varsig/algorithm/eddsa/eddsa_test.go b/varsig/algorithm/eddsa/eddsa_test.go new file mode 100644 index 0000000..1a28faf --- /dev/null +++ b/varsig/algorithm/eddsa/eddsa_test.go @@ -0,0 +1,47 @@ +package eddsa_test + +import ( + "testing" + + "github.com/fil-forge/ucantone/varsig/algorithm/eddsa" + "github.com/multiformats/go-multicodec" + "github.com/stretchr/testify/require" +) + +func TestEddsa(t *testing.T) { + t.Run("round trips", func(t *testing.T) { + alg := eddsa.New(multicodec.Ed25519Pub, multicodec.Sha2_512) + require.Equal(t, multicodec.Ed25519Pub, alg.Curve()) + require.Equal(t, multicodec.Sha2_512, alg.HashAlgorithm()) + + bytes, err := alg.Encode() + require.NoError(t, err) + + decodedAlg, n, err := eddsa.Decode(bytes) + decodedEddsaAlg, ok := decodedAlg.(eddsa.Algorithm) + require.True(t, ok) + + require.NoError(t, err) + require.Equal(t, len(bytes), n) + require.Equal(t, alg.Curve(), decodedEddsaAlg.Curve()) + require.Equal(t, alg.HashAlgorithm(), decodedEddsaAlg.HashAlgorithm()) + }) + + t.Run("fails with invalid curve", func(t *testing.T) { + alg := eddsa.New(multicodec.Tcp, multicodec.Sha2_512) + bytes, err := alg.Encode() + require.NoError(t, err) + + _, _, err = eddsa.Decode(bytes) + require.ErrorContains(t, err, "invalid curve code: 0x06 (tcp, 'multiaddr'), expected a multicodec with 'key' tag") + }) + + t.Run("fails with invalid hash algorithm", func(t *testing.T) { + alg := eddsa.New(multicodec.Ed25519Pub, multicodec.Tcp) + bytes, err := alg.Encode() + require.NoError(t, err) + + _, _, err = eddsa.Decode(bytes) + require.ErrorContains(t, err, "invalid hash algorithm code: 0x06 (tcp, 'multiaddr'), expected a multicodec with 'multihash' tag") + }) +} diff --git a/varsig/algorithm/nonstandard/codec.go b/varsig/algorithm/nonstandard/nonstandard.go similarity index 55% rename from varsig/algorithm/nonstandard/codec.go rename to varsig/algorithm/nonstandard/nonstandard.go index dc87984..9cc2d2b 100644 --- a/varsig/algorithm/nonstandard/codec.go +++ b/varsig/algorithm/nonstandard/nonstandard.go @@ -10,7 +10,11 @@ import ( const Code = 0xd000 func init() { - varsig.RegisterSignatureAlgorithm(NewCodec()) + varsig.RegisterAlgorithmScheme(varsig.AlgorithmSchemeDef{ + Code: Code, + Name: "absentee", + Decoder: Decode, + }) } // SignatureAlgorithm is a signing algorithm that is not a known standard, and @@ -29,37 +33,23 @@ func (sa SignatureAlgorithm) Segments() []uint64 { return []uint64{Code} } -// Codec is a signing algorithm codec that is not a known standard, and -// thus requires interactive verification. -type Codec struct{} - -func NewCodec() Codec { - return Codec{} -} - -func (sac Codec) Code() uint64 { - return Code -} - -func (sac Codec) Segments() []uint64 { - return []uint64{Code} -} - -func (sac Codec) Encode() ([]byte, error) { +func (sa SignatureAlgorithm) Encode() ([]byte, error) { size := varint.UvarintSize(Code) out := make([]byte, size) varint.PutUvarint(out, Code) return out, nil } -func (sac Codec) Decode(input []byte) (SignatureAlgorithm, int, error) { +func Decode(input []byte) (varsig.Algorithm, int, error) { code, n, err := varint.FromUvarint(input) if err != nil { - return SignatureAlgorithm{}, 0, err + return nil, 0, err } if code != Code { - return SignatureAlgorithm{}, n, fmt.Errorf("signature code is not Non-Standard: 0x%02x, expected: 0x%02x", code, Code) + return nil, n, fmt.Errorf("signature code is not Non-Standard: 0x%02x, expected: 0x%02x", code, Code) } offset := n return SignatureAlgorithm{}, offset, nil } + +var NonStandard = New() diff --git a/varsig/algorithm/secp256k1/codec.go b/varsig/algorithm/secp256k1/codec.go deleted file mode 100644 index f3e9ddb..0000000 --- a/varsig/algorithm/secp256k1/codec.go +++ /dev/null @@ -1,25 +0,0 @@ -package secp256k1 - -import ( - "github.com/fil-forge/ucantone/varsig" - "github.com/fil-forge/ucantone/varsig/algorithm/ecdsa" -) - -const Code = 0xe7 -const Sha2_256 = 0x12 - -type SignatureAlgorithm = ecdsa.SignatureAlgorithm - -func init() { - varsig.RegisterSignatureAlgorithm(NewCodec()) -} - -func New() SignatureAlgorithm { - return ecdsa.New(Code, Sha2_256) -} - -type Codec = ecdsa.Codec - -func NewCodec() Codec { - return ecdsa.NewCodec(Code, Sha2_256) -} diff --git a/varsig/algorithms.go b/varsig/algorithms.go new file mode 100644 index 0000000..4521ea6 --- /dev/null +++ b/varsig/algorithms.go @@ -0,0 +1,72 @@ +package varsig + +import ( + "fmt" + + "github.com/fil-forge/ucantone/varsig/algorithm" + "github.com/fil-forge/ucantone/varsig/algorithm/ecdsa" + "github.com/fil-forge/ucantone/varsig/algorithm/eddsa" +) + +func init() { + // Register spec-defined signature algorithms. + RegisterAlgorithmScheme(algorithm.AlgorithmDef{ + Code: eddsa.Code, + Name: "EdDSA", + Decoder: eddsa.Decode, + }) + RegisterAlgorithmScheme(algorithm.AlgorithmDef{ + Code: ecdsa.Code, + Name: "ECDSA", + Decoder: ecdsa.Decode, + }) + + // BLS12_381 and RSASSA-PKCS #1 are not yet implemented. +} + +// AlgorithmScheme represents the choice of an algorithm scheme, the first field +// in the signature algorithm segment of a Varsig. This is the top-level +// discriminant, and determines what other fields should follow. This is NOT a +// multicodec code! These codes are defined directly by Varsig, and are not in +// the multicodec table. +// +// https://github.com/ChainAgnostic/varsig#signature-algorithm +type AlgorithmScheme uint64 + +type AlgorithmSchemeDef = algorithm.AlgorithmDef + +var signatureAlgorithmDefs = map[AlgorithmScheme]AlgorithmSchemeDef{} + +// Algorithm represents the choice of an entire signature algorithm as part of a +// Varsig. It's a code and any additional fields needed to configure it. +type Algorithm = algorithm.Algorithm + +// RegisterAlgorithmScheme registers a signature algorithm definition, which +// includes the name and decoder for the algorithm. Technically, Varsig's list +// of signature algorithms is closed. However, unofficially, codes in +// Multicodec's private use area (0x300000–0x3FFFFF) should be safe to use for +// application-specific purposes (even though these are not actually Multicodec +// codes). +func RegisterAlgorithmScheme(def AlgorithmSchemeDef) { + signatureAlgorithmDefs[AlgorithmScheme(def.Code)] = def +} + +func (pe AlgorithmScheme) String() string { + def, ok := signatureAlgorithmDefs[pe] + if !ok { + return fmt.Sprintf("", uint64(pe)) + } + return def.Name +} + +func (pe AlgorithmScheme) Decode(input []byte) (algorithm.Algorithm, int, error) { + def, ok := signatureAlgorithmDefs[pe] + if !ok { + return nil, 0, fmt.Errorf("unknown signature algorithm scheme: 0x%02x", uint64(pe)) + } + alg, n, err := def.Decoder(input) + if err != nil { + return nil, n, fmt.Errorf("decoding signature algorithm: %w", err) + } + return alg, n, nil +} diff --git a/varsig/common/headers.go b/varsig/common/headers.go deleted file mode 100644 index 3e9c0de..0000000 --- a/varsig/common/headers.go +++ /dev/null @@ -1,17 +0,0 @@ -package common - -import ( - "github.com/fil-forge/ucantone/varsig" - "github.com/fil-forge/ucantone/varsig/algorithm/ed25519" - "github.com/fil-forge/ucantone/varsig/payload/dagcbor" -) - -func init() { - varsig.RegisterSignatureAlgorithm(ed25519.NewCodec()) - varsig.RegisterPayloadEncoding(dagcbor.NewCodec()) -} - -var DagCbor = dagcbor.New() -var Ed25519 = ed25519.New() - -var Ed25519DagCbor = varsig.NewHeader(Ed25519, DagCbor) diff --git a/varsig/payload/dagcbor/codec.go b/varsig/payload/dagcbor/codec.go deleted file mode 100644 index 2f3a2ed..0000000 --- a/varsig/payload/dagcbor/codec.go +++ /dev/null @@ -1,49 +0,0 @@ -package dagcbor - -import ( - "fmt" - - "github.com/fil-forge/ucantone/varsig" - varint "github.com/multiformats/go-varint" -) - -const Code = 0x71 - -type PayloadEncoding struct{} - -func init() { - varsig.RegisterPayloadEncoding(NewCodec()) -} - -func New() PayloadEncoding { - return PayloadEncoding{} -} - -func (dc PayloadEncoding) Code() uint64 { - return Code -} - -type Codec struct{} - -func NewCodec() Codec { - return Codec{} -} - -func (dcc Codec) Code() uint64 { - return Code -} - -func (dcc Codec) Encode() ([]byte, error) { - return varint.ToUvarint(Code), nil -} - -func (dcc Codec) Decode(input []byte) (PayloadEncoding, int, error) { - code, n, err := varint.FromUvarint(input) - if err != nil { - return PayloadEncoding{}, 0, err - } - if code != Code { - return PayloadEncoding{}, n, fmt.Errorf("payload encoding code is not dag-cbor: 0x%02x, expected: 0x%02x", code, Code) - } - return PayloadEncoding{}, n, nil -} diff --git a/varsig/payloadencodings.go b/varsig/payloadencodings.go new file mode 100644 index 0000000..1bc779c --- /dev/null +++ b/varsig/payloadencodings.go @@ -0,0 +1,43 @@ +package varsig + +import "fmt" + +// PayloadEncoding represents the choice of a payload encoding as part of a +// Varsig, describing the encoding used for the payload that is signed. This is +// NOT a multicodec code! These codes are defined directly by Varsig, and are +// not in the multicodec table (though DagCbor and DagJson happen to match). +// +// https://github.com/ChainAgnostic/varsig#payload-encoding +type PayloadEncoding uint64 + +const ( + ByteIdentical = PayloadEncoding(0x5F) + DagCbor = PayloadEncoding(0x71) + DagJson = PayloadEncoding(0x0129) + EIP191 = PayloadEncoding(0xE191) +) + +func (pe PayloadEncoding) String() string { + switch pe { + case ByteIdentical: + return "byte-identical" + case DagCbor: + return "dag-cbor" + case DagJson: + return "dag-json" + case EIP191: + return "eip-191" + default: + return fmt.Sprintf("", uint64(pe)) + } +} + +// Unknown returns true if the payload encoding is not recognized. +func (pe PayloadEncoding) Unknown() bool { + switch pe { + case ByteIdentical, DagCbor, DagJson, EIP191: + return false + default: + return true + } +} diff --git a/varsig/prev/varsig.go b/varsig/prev/varsig.go new file mode 100644 index 0000000..91ea3c4 --- /dev/null +++ b/varsig/prev/varsig.go @@ -0,0 +1,245 @@ +package prev + +import ( + "fmt" + "strconv" + "strings" + + varint "github.com/multiformats/go-varint" +) + +const Prefix = 0x34 +const Version = 0x01 + +type Codec[T any] interface { + Code() uint64 + Encode() ([]byte, error) + Decode([]byte) (T, int, error) +} + +type SignatureAlgorithm interface { + // Discriminant for the signature segments. + Code() uint64 + // Signature segments including the signature algorithm code. + Segments() []uint64 +} + +type SignatureAlgorithmCodec[T SignatureAlgorithm] interface { + SignatureAlgorithm + Codec[T] +} + +// code -> segments -> codec +var signatureAlgorithmCodecs = map[uint64]map[string]SignatureAlgorithmCodec[SignatureAlgorithm]{} + +func signatureAlgorithmSegmentKey(segments []uint64) string { + k := make([]string, 0, len(segments)) + for _, s := range segments { + k = append(k, strconv.FormatUint(s, 16)) + } + return strings.Join(k, "-") +} + +type signatureAlgorithmCodecAdapter[T SignatureAlgorithm] struct { + codec SignatureAlgorithmCodec[T] +} + +func (a signatureAlgorithmCodecAdapter[T]) Code() uint64 { + return a.codec.Code() +} + +func (a signatureAlgorithmCodecAdapter[T]) Segments() []uint64 { + return a.codec.Segments() +} + +func (a signatureAlgorithmCodecAdapter[T]) Encode() ([]byte, error) { + return a.codec.Encode() +} + +func (a signatureAlgorithmCodecAdapter[T]) Decode(input []byte) (SignatureAlgorithm, int, error) { + algo, n, err := a.codec.Decode(input) + if err != nil { + return nil, 0, err + } + return SignatureAlgorithm(algo), n, nil +} + +func RegisterSignatureAlgorithm[T SignatureAlgorithm](codec SignatureAlgorithmCodec[T]) { + codecs, ok := signatureAlgorithmCodecs[codec.Code()] + if !ok { + codecs = map[string]SignatureAlgorithmCodec[SignatureAlgorithm]{} + signatureAlgorithmCodecs[codec.Code()] = codecs + } + codecs[signatureAlgorithmSegmentKey(codec.Segments())] = signatureAlgorithmCodecAdapter[T]{codec} +} + +// GetSignatureAlgorithmCodec returns a registered codec for the given signature algorithm. The boolean will be false if no codec is registered for the algorithm. +func GetSignatureAlgorithmCodec(algo SignatureAlgorithm) (SignatureAlgorithmCodec[SignatureAlgorithm], bool) { + codecs, ok := signatureAlgorithmCodecs[algo.Code()] + if !ok { + return nil, false + } + c, ok := codecs[signatureAlgorithmSegmentKey(algo.Segments())] + return c, ok +} + +type PayloadEncoding interface { + // Discriminant for the payload encoding segments. + Code() uint64 +} + +type PayloadEncodingCodec[T PayloadEncoding] Codec[T] + +var payloadEncodingCodecs = map[uint64]PayloadEncodingCodec[PayloadEncoding]{} + +type payloadEncodingCodecAdapter[T PayloadEncoding] struct { + codec PayloadEncodingCodec[T] +} + +func (a payloadEncodingCodecAdapter[T]) Code() uint64 { + return a.codec.Code() +} + +func (a payloadEncodingCodecAdapter[T]) Encode() ([]byte, error) { + return a.codec.Encode() +} + +func (a payloadEncodingCodecAdapter[T]) Decode(input []byte) (PayloadEncoding, int, error) { + algo, n, err := a.codec.Decode(input) + if err != nil { + return nil, 0, err + } + return PayloadEncoding(algo), n, nil +} + +func RegisterPayloadEncoding[T PayloadEncoding](codec PayloadEncodingCodec[T]) { + payloadEncodingCodecs[codec.Code()] = payloadEncodingCodecAdapter[T]{codec} +} + +// GetPayloadEncodingCodec returns a registered codec for the given payload +// encoding. The boolean will be false if no codec is registered for the encoding. +func GetPayloadEncodingCodec(enc PayloadEncoding) (PayloadEncodingCodec[PayloadEncoding], bool) { + c, ok := payloadEncodingCodecs[enc.Code()] + return c, ok +} + +type VarsigHeader interface { + // A Varsig v1 MUST use the 0x01 version tag. + Version() uint64 + SignatureAlgorithm() SignatureAlgorithm + PayloadEncoding() PayloadEncoding +} + +type Header struct { + signatureAlgorithm SignatureAlgorithm + payloadEncoding PayloadEncoding +} + +func NewHeader(sigAlgo SignatureAlgorithm, payloadEnc PayloadEncoding) Header { + return Header{sigAlgo, payloadEnc} +} + +func (h Header) Version() uint64 { + return Version +} + +func (h Header) SignatureAlgorithm() SignatureAlgorithm { + return h.signatureAlgorithm +} + +func (h Header) PayloadEncoding() PayloadEncoding { + return h.payloadEncoding +} + +var _ VarsigHeader = (*Header)(nil) + +func Encode(header VarsigHeader) ([]byte, error) { + size := varint.UvarintSize(Prefix) + size += varint.UvarintSize(Version) + + sigAlgoCodec, ok := GetSignatureAlgorithmCodec(header.SignatureAlgorithm()) + if !ok { + return nil, fmt.Errorf("missing codec for signature algorithm: %d", header.SignatureAlgorithm().Code()) + } + sigAlgoBytes, err := sigAlgoCodec.Encode() + if err != nil { + return nil, err + } + size += len(sigAlgoBytes) + + payloadEncCodec, ok := GetPayloadEncodingCodec(header.PayloadEncoding()) + if !ok { + return nil, fmt.Errorf("missing codec for payload encoding: %d", header.PayloadEncoding().Code()) + } + payloadEncBytes, err := payloadEncCodec.Encode() + if err != nil { + return nil, err + } + size += len(payloadEncBytes) + + out := make([]byte, size) + offset := varint.PutUvarint(out, Prefix) + offset += varint.PutUvarint(out[offset:], Version) + offset += copy(out[offset:], sigAlgoBytes) + offset += copy(out[offset:], payloadEncBytes) + return out, nil +} + +func Decode(input []byte) (Header, error) { + offset := 0 + prefix, n, err := varint.FromUvarint(input) + if err != nil { + return Header{}, fmt.Errorf("reading prefix: %w", err) + } + if prefix != Prefix { + return Header{}, fmt.Errorf("invalid varsig prefix: 0x%02x, expected: 0x%02x", prefix, Prefix) + } + offset += n + + version, n, err := varint.FromUvarint(input[offset:]) + if err != nil { + return Header{}, fmt.Errorf("reading version: %w", err) + } + if version != Version { + return Header{}, fmt.Errorf("invalid varsig version: 0x%02x, expected: 0x%02x", version, Version) + } + offset += n + + sigAlgoCode, _, err := varint.FromUvarint(input[offset:]) + if err != nil { + return Header{}, fmt.Errorf("reading signature algorithm code: %w", err) + } + + codecs, ok := signatureAlgorithmCodecs[sigAlgoCode] + if !ok { + return Header{}, fmt.Errorf("unsupported signature algorithm codec: 0x%02x", sigAlgoCode) + } + var sigAlgo SignatureAlgorithm + for _, codec := range codecs { + sa, n, err := codec.Decode(input[offset:]) + if err != nil { + continue + } + sigAlgo = sa + offset += n + break + } + if sigAlgo == nil { + return Header{}, fmt.Errorf("unsupported signature algorithm code: 0x%02x", sigAlgoCode) + } + + payloadEncCode, _, err := varint.FromUvarint(input[offset:]) + if err != nil { + return Header{}, fmt.Errorf("reading payload encoding code: %w", err) + } + payloadEncCodec, ok := payloadEncodingCodecs[payloadEncCode] + if !ok { + return Header{}, fmt.Errorf("unsupported payload encoding codec: 0x%02x", payloadEncCode) + } + payloadEnc, _, err := payloadEncCodec.Decode(input[offset:]) + if err != nil { + return Header{}, fmt.Errorf("decoding payload encoding: %w", err) + } + + return Header{sigAlgo, payloadEnc}, nil +} diff --git a/varsig/varsig.go b/varsig/varsig.go index 8853699..f517e0e 100644 --- a/varsig/varsig.go +++ b/varsig/varsig.go @@ -2,244 +2,97 @@ package varsig import ( "fmt" - "strconv" - "strings" - varint "github.com/multiformats/go-varint" + "github.com/fil-forge/ucantone/varsig/algorithm" + "github.com/multiformats/go-varint" ) const Prefix = 0x34 const Version = 0x01 -type Codec[T any] interface { - Code() uint64 - Encode() ([]byte, error) - Decode([]byte) (T, int, error) +// Varsig represents a Varsig v1 +// https://github.com/ChainAgnostic/varsig +type Varsig struct { + sigAlg algorithm.Algorithm + payloadEnc PayloadEncoding } -type SignatureAlgorithm interface { - // Discriminant for the signature segments. - Code() uint64 - // Signature segments including the signature algorithm code. - Segments() []uint64 +func New(sigAlg algorithm.Algorithm, payloadEnc PayloadEncoding) Varsig { + return Varsig{sigAlg: sigAlg, payloadEnc: payloadEnc} } -type SignatureAlgorithmCodec[T SignatureAlgorithm] interface { - SignatureAlgorithm - Codec[T] -} - -// code -> segments -> codec -var signatureAlgorithmCodecs = map[uint64]map[string]SignatureAlgorithmCodec[SignatureAlgorithm]{} - -func signatureAlgorithmSegmentKey(segments []uint64) string { - k := make([]string, 0, len(segments)) - for _, s := range segments { - k = append(k, strconv.FormatUint(s, 16)) - } - return strings.Join(k, "-") -} - -type signatureAlgorithmCodecAdapter[T SignatureAlgorithm] struct { - codec SignatureAlgorithmCodec[T] -} - -func (a signatureAlgorithmCodecAdapter[T]) Code() uint64 { - return a.codec.Code() -} - -func (a signatureAlgorithmCodecAdapter[T]) Segments() []uint64 { - return a.codec.Segments() -} - -func (a signatureAlgorithmCodecAdapter[T]) Encode() ([]byte, error) { - return a.codec.Encode() -} - -func (a signatureAlgorithmCodecAdapter[T]) Decode(input []byte) (SignatureAlgorithm, int, error) { - algo, n, err := a.codec.Decode(input) - if err != nil { - return nil, 0, err - } - return SignatureAlgorithm(algo), n, nil -} - -func RegisterSignatureAlgorithm[T SignatureAlgorithm](codec SignatureAlgorithmCodec[T]) { - codecs, ok := signatureAlgorithmCodecs[codec.Code()] - if !ok { - codecs = map[string]SignatureAlgorithmCodec[SignatureAlgorithm]{} - signatureAlgorithmCodecs[codec.Code()] = codecs - } - codecs[signatureAlgorithmSegmentKey(codec.Segments())] = signatureAlgorithmCodecAdapter[T]{codec} -} - -// GetSignatureAlgorithmCodec returns a registered codec for the given signature algorithm. The boolean will be false if no codec is registered for the algorithm. -func GetSignatureAlgorithmCodec(algo SignatureAlgorithm) (SignatureAlgorithmCodec[SignatureAlgorithm], bool) { - codecs, ok := signatureAlgorithmCodecs[algo.Code()] - if !ok { - return nil, false - } - c, ok := codecs[signatureAlgorithmSegmentKey(algo.Segments())] - return c, ok -} - -type PayloadEncoding interface { - // Discriminant for the payload encoding segments. - Code() uint64 -} - -type PayloadEncodingCodec[T PayloadEncoding] Codec[T] - -var payloadEncodingCodecs = map[uint64]PayloadEncodingCodec[PayloadEncoding]{} - -type payloadEncodingCodecAdapter[T PayloadEncoding] struct { - codec PayloadEncodingCodec[T] +func (v Varsig) Version() uint64 { + return Version } -func (a payloadEncodingCodecAdapter[T]) Code() uint64 { - return a.codec.Code() +func (v Varsig) SignatureAlgorithm() algorithm.Algorithm { + return v.sigAlg } -func (a payloadEncodingCodecAdapter[T]) Encode() ([]byte, error) { - return a.codec.Encode() +func (v Varsig) PayloadEncoding() PayloadEncoding { + return v.payloadEnc } -func (a payloadEncodingCodecAdapter[T]) Decode(input []byte) (PayloadEncoding, int, error) { - algo, n, err := a.codec.Decode(input) +func (v Varsig) Encode() ([]byte, error) { + sigAlgBytes, err := v.sigAlg.Encode() if err != nil { - return nil, 0, err + return nil, fmt.Errorf("encoding signature algorithm: %w", err) } - return PayloadEncoding(algo), n, nil -} - -func RegisterPayloadEncoding[T PayloadEncoding](codec PayloadEncodingCodec[T]) { - payloadEncodingCodecs[codec.Code()] = payloadEncodingCodecAdapter[T]{codec} -} -// GetPayloadEncodingCodec returns a registered codec for the given payload -// encoding. The boolean will be false if no codec is registered for the encoding. -func GetPayloadEncodingCodec(enc PayloadEncoding) (PayloadEncodingCodec[PayloadEncoding], bool) { - c, ok := payloadEncodingCodecs[enc.Code()] - return c, ok -} - -type VarsigHeader[S SignatureAlgorithm, P PayloadEncoding] interface { - // A Varsig v1 MUST use the 0x01 version tag. - Version() uint64 - SignatureAlgorithm() S - PayloadEncoding() P -} - -type Header[S SignatureAlgorithm, P PayloadEncoding] struct { - signatureAlgorithm S - payloadEncoding P -} - -func NewHeader[S SignatureAlgorithm, P PayloadEncoding](sigAlgo S, payloadEnc P) Header[S, P] { - return Header[S, P]{sigAlgo, payloadEnc} -} - -func (h Header[S, P]) Version() uint64 { - return Version -} - -func (h Header[S, P]) SignatureAlgorithm() S { - return h.signatureAlgorithm -} - -func (h Header[S, P]) PayloadEncoding() P { - return h.payloadEncoding -} - -var _ VarsigHeader[SignatureAlgorithm, PayloadEncoding] = (*Header[SignatureAlgorithm, PayloadEncoding])(nil) - -func Encode[S SignatureAlgorithm, P PayloadEncoding](header VarsigHeader[S, P]) ([]byte, error) { size := varint.UvarintSize(Prefix) size += varint.UvarintSize(Version) - - sigAlgoCodec, ok := GetSignatureAlgorithmCodec(header.SignatureAlgorithm()) - if !ok { - return nil, fmt.Errorf("missing codec for signature algorithm: %d", header.SignatureAlgorithm().Code()) - } - sigAlgoBytes, err := sigAlgoCodec.Encode() - if err != nil { - return nil, err - } - size += len(sigAlgoBytes) - - payloadEncCodec, ok := GetPayloadEncodingCodec(header.PayloadEncoding()) - if !ok { - return nil, fmt.Errorf("missing codec for payload encoding: %d", header.PayloadEncoding().Code()) - } - payloadEncBytes, err := payloadEncCodec.Encode() - if err != nil { - return nil, err - } - size += len(payloadEncBytes) + size += len(sigAlgBytes) + size += varint.UvarintSize(uint64(v.PayloadEncoding())) out := make([]byte, size) offset := varint.PutUvarint(out, Prefix) offset += varint.PutUvarint(out[offset:], Version) - offset += copy(out[offset:], sigAlgoBytes) - offset += copy(out[offset:], payloadEncBytes) + offset += copy(out[offset:], sigAlgBytes) + offset += varint.PutUvarint(out[offset:], uint64(v.PayloadEncoding())) return out, nil } -func Decode(input []byte) (Header[SignatureAlgorithm, PayloadEncoding], error) { +func Decode(input []byte) (Varsig, int, error) { offset := 0 prefix, n, err := varint.FromUvarint(input) if err != nil { - return Header[SignatureAlgorithm, PayloadEncoding]{}, fmt.Errorf("reading prefix: %w", err) + return Varsig{}, 0, fmt.Errorf("reading prefix: %w", err) } if prefix != Prefix { - return Header[SignatureAlgorithm, PayloadEncoding]{}, fmt.Errorf("invalid varsig prefix: 0x%02x, expected: 0x%02x", prefix, Prefix) + return Varsig{}, 0, fmt.Errorf("invalid varsig prefix: 0x%02x, expected: 0x%02x", prefix, Prefix) } offset += n version, n, err := varint.FromUvarint(input[offset:]) if err != nil { - return Header[SignatureAlgorithm, PayloadEncoding]{}, fmt.Errorf("reading version: %w", err) + return Varsig{}, 0, fmt.Errorf("reading version: %w", err) } if version != Version { - return Header[SignatureAlgorithm, PayloadEncoding]{}, fmt.Errorf("invalid varsig version: 0x%02x, expected: 0x%02x", version, Version) + return Varsig{}, 0, fmt.Errorf("invalid varsig version: 0x%02x, expected: 0x%02x", version, Version) } offset += n sigAlgoCode, _, err := varint.FromUvarint(input[offset:]) if err != nil { - return Header[SignatureAlgorithm, PayloadEncoding]{}, fmt.Errorf("reading signature algorithm code: %w", err) + return Varsig{}, 0, fmt.Errorf("reading signature algorithm code: %w", err) } - codecs, ok := signatureAlgorithmCodecs[sigAlgoCode] - if !ok { - return Header[SignatureAlgorithm, PayloadEncoding]{}, fmt.Errorf("unsupported signature algorithm codec: 0x%02x", sigAlgoCode) - } - var sigAlgo SignatureAlgorithm - for _, codec := range codecs { - sa, n, err := codec.Decode(input[offset:]) - if err != nil { - continue - } - sigAlgo = sa - offset += n - break - } - if sigAlgo == nil { - return Header[SignatureAlgorithm, PayloadEncoding]{}, fmt.Errorf("unsupported signature algorithm code: 0x%02x", sigAlgoCode) + sigAlgScheme := AlgorithmScheme(sigAlgoCode) + sigAlg, n, err := sigAlgScheme.Decode(input[offset:]) + if err != nil { + return Varsig{}, 0, fmt.Errorf("decoding signature algorithm: %w", err) } + offset += n - payloadEncCode, _, err := varint.FromUvarint(input[offset:]) + payloadEncCode, n, err := varint.FromUvarint(input[offset:]) if err != nil { - return Header[SignatureAlgorithm, PayloadEncoding]{}, fmt.Errorf("reading payload encoding code: %w", err) + return Varsig{}, 0, fmt.Errorf("reading payload encoding code: %w", err) } - payloadEncCodec, ok := payloadEncodingCodecs[payloadEncCode] - if !ok { - return Header[SignatureAlgorithm, PayloadEncoding]{}, fmt.Errorf("unsupported payload encoding codec: 0x%02x", payloadEncCode) - } - payloadEnc, _, err := payloadEncCodec.Decode(input[offset:]) - if err != nil { - return Header[SignatureAlgorithm, PayloadEncoding]{}, fmt.Errorf("decoding payload encoding: %w", err) + payloadEnc := PayloadEncoding(payloadEncCode) + if payloadEnc.Unknown() { + return Varsig{}, 0, fmt.Errorf("unknown payload encoding codec: 0x%02x", payloadEncCode) } + offset += n - return Header[SignatureAlgorithm, PayloadEncoding]{sigAlgo, payloadEnc}, nil + return Varsig{sigAlg: sigAlg, payloadEnc: payloadEnc}, offset, nil } diff --git a/varsig/varsig_test.go b/varsig/varsig_test.go index 1d3a2fe..8387cab 100644 --- a/varsig/varsig_test.go +++ b/varsig/varsig_test.go @@ -5,82 +5,47 @@ import ( "testing" "github.com/fil-forge/ucantone/varsig" - "github.com/fil-forge/ucantone/varsig/algorithm/ed25519" - "github.com/fil-forge/ucantone/varsig/algorithm/secp256k1" - "github.com/fil-forge/ucantone/varsig/payload/dagcbor" + "github.com/fil-forge/ucantone/varsig/algorithm/ecdsa" + "github.com/fil-forge/ucantone/varsig/algorithm/eddsa" "github.com/stretchr/testify/require" ) func TestVarsigEd25519DagCbor(t *testing.T) { - varsig.RegisterSignatureAlgorithm(ed25519.NewCodec()) - varsig.RegisterPayloadEncoding(dagcbor.NewCodec()) + vs := varsig.New(eddsa.Ed25519, varsig.DagCbor) - expectHeader := varsig.NewHeader(ed25519.New(), dagcbor.New()) + require.Equal(t, uint64(1), vs.Version()) + require.Equal(t, eddsa.Ed25519, vs.SignatureAlgorithm()) + require.Equal(t, varsig.DagCbor, vs.PayloadEncoding()) - data, err := varsig.Encode(expectHeader) + data, err := vs.Encode() require.NoError(t, err) t.Log("Encoded (base64):") t.Logf("\t%s", base64.RawStdEncoding.EncodeToString(data)) - header, err := varsig.Decode(data) + decodedVs, n, err := varsig.Decode(data) require.NoError(t, err) + require.Equal(t, len(data), n) - require.Equal(t, expectHeader.Version(), header.Version()) - require.Equal(t, expectHeader.SignatureAlgorithm().Code(), header.SignatureAlgorithm().Code()) - - sigAlgo, ok := header.SignatureAlgorithm().(ed25519.SignatureAlgorithm) - require.True(t, ok) - require.Equal(t, expectHeader.SignatureAlgorithm().Code(), sigAlgo.Code()) - require.Equal(t, expectHeader.SignatureAlgorithm().Curve(), sigAlgo.Curve()) - require.Equal(t, expectHeader.SignatureAlgorithm().HashAlgorithm(), sigAlgo.HashAlgorithm()) - - t.Log("Signature Algorithm:") - t.Logf("\tCode:\t0x%02x", sigAlgo.Code()) - t.Logf("\tCurve:\t0x%02x", sigAlgo.Curve()) - t.Logf("\tHash:\t0x%02x", sigAlgo.HashAlgorithm()) - - payloadEnc, ok := header.PayloadEncoding().(dagcbor.PayloadEncoding) - require.True(t, ok) - require.Equal(t, expectHeader.PayloadEncoding().Code(), payloadEnc.Code()) - - t.Log("Payload Encoing:") - t.Logf("\tCode:\t0x%02x", payloadEnc.Code()) + require.Equal(t, vs, decodedVs) } func TestVarsigSecp256k1DagCbor(t *testing.T) { - varsig.RegisterSignatureAlgorithm(secp256k1.NewCodec()) - varsig.RegisterPayloadEncoding(dagcbor.NewCodec()) + vs := varsig.New(ecdsa.Secp256k1, varsig.DagCbor) - expectHeader := varsig.NewHeader(secp256k1.New(), dagcbor.New()) + require.Equal(t, uint64(1), vs.Version()) + require.Equal(t, ecdsa.Secp256k1, vs.SignatureAlgorithm()) + require.Equal(t, varsig.DagCbor, vs.PayloadEncoding()) - data, err := varsig.Encode(expectHeader) + data, err := vs.Encode() require.NoError(t, err) t.Log("Encoded (base64):") t.Logf("\t%s", base64.RawStdEncoding.EncodeToString(data)) - header, err := varsig.Decode(data) + decodedVs, n, err := varsig.Decode(data) require.NoError(t, err) + require.Equal(t, len(data), n) - require.Equal(t, expectHeader.Version(), header.Version()) - require.Equal(t, expectHeader.SignatureAlgorithm().Code(), header.SignatureAlgorithm().Code()) - - sigAlgo, ok := header.SignatureAlgorithm().(secp256k1.SignatureAlgorithm) - require.True(t, ok) - require.Equal(t, expectHeader.SignatureAlgorithm().Code(), sigAlgo.Code()) - require.Equal(t, expectHeader.SignatureAlgorithm().Curve(), sigAlgo.Curve()) - require.Equal(t, expectHeader.SignatureAlgorithm().HashAlgorithm(), sigAlgo.HashAlgorithm()) - - t.Log("Signature Algorithm:") - t.Logf("\tCode:\t0x%02x", sigAlgo.Code()) - t.Logf("\tCurve:\t0x%02x", sigAlgo.Curve()) - t.Logf("\tHash:\t0x%02x", sigAlgo.HashAlgorithm()) - - payloadEnc, ok := header.PayloadEncoding().(dagcbor.PayloadEncoding) - require.True(t, ok) - require.Equal(t, expectHeader.PayloadEncoding().Code(), payloadEnc.Code()) - - t.Log("Payload Encoing:") - t.Logf("\tCode:\t0x%02x", payloadEnc.Code()) + require.Equal(t, vs, decodedVs) }