diff --git a/toolkit/_example/client/client.go b/toolkit/_example/client/client.go new file mode 100644 index 0000000..7db6c7d --- /dev/null +++ b/toolkit/_example/client/client.go @@ -0,0 +1,151 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "iter" + "log" + "net/http" + "net/url" + "os" + "os/signal" + "syscall" + "time" + + "github.com/ucan-wg/go-ucan/did" + "github.com/ucan-wg/go-ucan/pkg/command" + "github.com/ucan-wg/go-ucan/pkg/container" + "github.com/ucan-wg/go-ucan/token/delegation" + + example "github.com/INFURA/go-ucan-toolkit/_example" + "github.com/INFURA/go-ucan-toolkit/client" + "github.com/INFURA/go-ucan-toolkit/issuer" + "github.com/INFURA/go-ucan-toolkit/server/bearer" +) + +func main() { + ctx, cancel := context.WithCancel(context.Background()) + + // register as handler of the interrupt signal to trigger the teardown + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM, os.Interrupt) + + go func() { + <-quit + cancel() + }() + + err := run(ctx, example.IssuerUrl, example.ServerUrl, example.ServiceDid) + if err != nil { + log.Println(err) + os.Exit(1) + } +} + +var _ client.DelegationRequester = &requester{} + +type requester struct { + issuerURL string +} + +func (r requester) RequestDelegation(ctx context.Context, audience did.DID, cmd command.Command, subject did.DID) (iter.Seq2[*delegation.Bundle, error], error) { + log.Printf("requesting delegation for %s on %s", cmd, subject) + + // we match the simple json protocol of the issuer + data := struct { + Audience string `json:"aud"` + Cmd string `json:"cmd"` + Subject string `json:"sub"` + }{ + Audience: audience.String(), + Cmd: cmd.String(), + Subject: subject.String(), + } + buf := &bytes.Buffer{} + err := json.NewEncoder(buf).Encode(data) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodPost, "http://"+r.issuerURL, buf) + if err != nil { + return nil, err + } + + res, err := http.DefaultClient.Do(req.WithContext(ctx)) + if err != nil { + return nil, err + } + + return issuer.DecodeResponse(res) +} + +func run(ctx context.Context, issuerUrl string, serverUrl string, serviceDid did.DID) error { + // Let's generate a keypair for our client: + priv, d, err := did.GenerateEd25519() + if err != nil { + return err + } + + log.Printf("client DID is %s", d.String()) + + cli, err := client.NewClient(priv, requester{issuerURL: issuerUrl}) + if err != nil { + return err + } + + for { + select { + case <-ctx.Done(): + case <-time.After(5 * time.Second): + proofs, err := cli.PrepareInvoke(ctx, command.MustParse("/foo/bar"), serviceDid) + if err != nil { + return err + } + + err = makeRequest(ctx, d, serverUrl, proofs) + if err != nil { + log.Println(err) + } + } + } +} + +func makeRequest(ctx context.Context, clientDid did.DID, serverUrl string, proofs container.Writer) error { + // we construct a URL that include the client DID as path, as requested by the UCAN policy we get issued + u, err := url.JoinPath("http://"+serverUrl, clientDid.String()) + if err != nil { + return err + } + + req, err := http.NewRequest(http.MethodGet, u, nil) + if err != nil { + return err + } + + err = bearer.AddBearerContainerCompressed(req.Header, proofs) + if err != nil { + return err + } + + res, err := http.DefaultClient.Do(req.WithContext(ctx)) + if err != nil { + return err + } + + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + body, err := io.ReadAll(res.Body) + if err != nil { + return fmt.Errorf("unexpected status code: %d, error reading body: %w", res.StatusCode, err) + } + return fmt.Errorf("unexpected status code: %d, body: %v", res.StatusCode, string(body)) + } + + log.Printf("response status code: %d", res.StatusCode) + + return nil +} diff --git a/toolkit/_example/issuer/issuer.go b/toolkit/_example/issuer/issuer.go new file mode 100644 index 0000000..2cc4890 --- /dev/null +++ b/toolkit/_example/issuer/issuer.go @@ -0,0 +1,119 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/libp2p/go-libp2p/core/crypto" + "github.com/ucan-wg/go-ucan/did" + "github.com/ucan-wg/go-ucan/pkg/command" + "github.com/ucan-wg/go-ucan/pkg/policy" + "github.com/ucan-wg/go-ucan/pkg/policy/literal" + "github.com/ucan-wg/go-ucan/token/delegation" + + example "github.com/INFURA/go-ucan-toolkit/_example" + "github.com/INFURA/go-ucan-toolkit/issuer" +) + +func main() { + ctx, cancel := context.WithCancel(context.Background()) + + // register as handler of the interrupt signal to trigger the teardown + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM, os.Interrupt) + + go func() { + <-quit + cancel() + }() + + err := run(ctx, example.IssuerUrl, example.ServicePrivKey) + if err != nil { + log.Println(err) + os.Exit(1) + } +} + +func run(ctx context.Context, issuerUrl string, servicePrivKey crypto.PrivKey) error { + issuingLogic := func(iss did.DID, aud did.DID, cmd command.Command) (*delegation.Token, error) { + log.Printf("issuing delegation to %v for %v", aud, cmd) + + // We construct an arbitrary policy. + // Here, we enforce that the caller uses its own DID endpoint (an arbitrary construct for this example). + // You will notice that the server doesn't need to know about this logic to enforce it. + policies, err := policy.Construct( + policy.Equal(".http.path", literal.String(fmt.Sprintf("/%s", aud.String()))), + ) + if err != nil { + return nil, err + } + + return delegation.Root(iss, aud, cmd, policies, + // let's add an expiration, this will force the client to renew its token. + delegation.WithExpirationIn(10*time.Second), + ) + } + + rootIssuer, err := issuer.NewRootIssuer(servicePrivKey, issuingLogic) + if err != nil { + return err + } + + handler := issuer.HttpWrapper(rootIssuer, func(r *http.Request) (*issuer.ResolvedRequest, error) { + // Let's make up a simple json protocol + req := struct { + Audience string `json:"aud"` + Cmd string `json:"cmd"` + Subject string `json:"sub"` + }{} + err := json.NewDecoder(r.Body).Decode(&req) + if err != nil { + return nil, err + } + aud, err := did.Parse(req.Audience) + if err != nil { + return nil, err + } + cmd, err := command.Parse(req.Cmd) + if err != nil { + return nil, err + } + sub, err := did.Parse(req.Subject) + if err != nil { + return nil, err + } + return &issuer.ResolvedRequest{ + Audience: aud, + Cmd: cmd, + Subject: sub, + }, nil + }) + + srv := &http.Server{ + Addr: issuerUrl, + Handler: handler, + } + + go func() { + if err := srv.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) { + log.Fatalf("listen: %s\n", err) + } + }() + + log.Printf("listening on %s\n", srv.Addr) + + <-ctx.Done() + + if err := srv.Shutdown(ctx); err != nil && !errors.Is(err, context.Canceled) { + return err + } + return nil +} diff --git a/toolkit/_example/server/server.go b/toolkit/_example/server/server.go new file mode 100644 index 0000000..726042b --- /dev/null +++ b/toolkit/_example/server/server.go @@ -0,0 +1,85 @@ +package main + +import ( + "context" + "errors" + "log" + "net/http" + "os" + "os/signal" + "syscall" + + "github.com/ucan-wg/go-ucan/did" + + example "github.com/INFURA/go-ucan-toolkit/_example" + "github.com/INFURA/go-ucan-toolkit/server/exectx" +) + +func main() { + ctx, cancel := context.WithCancel(context.Background()) + + // register as handler of the interrupt signal to trigger the teardown + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM, os.Interrupt) + + go func() { + <-quit + cancel() + }() + + err := run(ctx, example.ServerUrl, example.ServiceDid) + if err != nil { + log.Println(err) + os.Exit(1) + } +} + +func run(ctx context.Context, serverUrl string, serviceDID did.DID) error { + // we'll make a simple handling pipeline: + // - exectx.ExtractMW to extract and decode the UCAN context, verify the service DID + // - exectx.HttpExtArgsVerify to verify the HTTP policies + // - exectx.EnforceMW to perform the final UCAN checks + // - our handler to execute the commands + + var handler http.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ucanCtx, ok := exectx.FromContext(r.Context()) + if !ok { + http.Error(w, "no ucan-ctx found", http.StatusInternalServerError) + return + } + + switch ucanCtx.Command().String() { + case "/foo/bar": + log.Printf("handled command %v for %v", ucanCtx.Command(), ucanCtx.Invocation().Issuer()) + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("OK")) + default: + http.Error(w, "unknown UCAN commmand", http.StatusBadRequest) + return + } + }) + + handler = exectx.EnforceMW(handler) + handler = exectx.HttpExtArgsVerify(handler) + handler = exectx.ExtractMW(handler, serviceDID) + + srv := &http.Server{ + Addr: serverUrl, + Handler: handler, + } + + go func() { + if err := srv.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) { + log.Fatalf("listen: %s\n", err) + } + }() + + log.Printf("listening on %s\n", srv.Addr) + + <-ctx.Done() + + if err := srv.Shutdown(ctx); err != nil && !errors.Is(err, context.Canceled) { + return err + } + return nil +} diff --git a/toolkit/_example/shared_values.go b/toolkit/_example/shared_values.go new file mode 100644 index 0000000..d71f24d --- /dev/null +++ b/toolkit/_example/shared_values.go @@ -0,0 +1,24 @@ +package example + +import ( + "encoding/base64" + + "github.com/libp2p/go-libp2p/core/crypto" + "github.com/ucan-wg/go-ucan/did" +) + +// Endpoints + +var ServerUrl = ":8080" +var IssuerUrl = ":8081" + +// Service + +var ServicePrivKey crypto.PrivKey +var ServiceDid did.DID + +func init() { + privRaw, _ := base64.StdEncoding.DecodeString("CAESQGs7hPBRBmxH1UmHrdcPrBkecuFUuCWHK0kMJvZYCBqIa35SGxUdXVGuigQDkMpf7xO4C2C2Acl8QTtSrYS7Cnc=") + ServicePrivKey, _ = crypto.UnmarshalPrivateKey(privRaw) + ServiceDid, _ = did.FromPrivKey(ServicePrivKey) +} diff --git a/toolkit/issuer/issuer.go b/toolkit/issuer/dlg_issuer.go similarity index 81% rename from toolkit/issuer/issuer.go rename to toolkit/issuer/dlg_issuer.go index 6403d47..00ac364 100644 --- a/toolkit/issuer/issuer.go +++ b/toolkit/issuer/dlg_issuer.go @@ -2,6 +2,7 @@ package issuer import ( "context" + "fmt" "iter" "time" @@ -14,18 +15,31 @@ import ( "github.com/INFURA/go-ucan-toolkit/client" ) +// DlgIssuingLogic is a function that decides what powers are given to a client. +// - issuer: the DID of our issuer +// - audience: the DID of the client, also the issuer of the invocation token +// - cmd: the command to execute +// - subject: the DID of the resource to operate on, also the subject (or audience if defined) of the invocation token +// Note: you can read it as "(audience) wants to do (cmd) on (subject)". +// Note: you can decide to match the input parameters exactly or issue a broader power, as long as it allows the +// expected action. If you don't want to give that power, return an error instead. +type DlgIssuingLogic func(iss did.DID, aud did.DID, cmd command.Command, subject did.DID) (*delegation.Token, error) + var _ client.DelegationRequester = &Issuer{} +// Issuer is an implementation of a re-delegating issuer. +// Note: Your actual needs for an issuer can easily be different (caching...) than the choices made here. +// Feel free to replace this component with your own flavor. type Issuer struct { did did.DID privKey crypto.PrivKey pool *client.Pool requester client.DelegationRequester - logic IssuingLogic + logic DlgIssuingLogic } -func NewIssuer(privKey crypto.PrivKey, requester client.DelegationRequester, logic IssuingLogic) (*Issuer, error) { +func NewIssuer(privKey crypto.PrivKey, requester client.DelegationRequester, logic DlgIssuingLogic) (*Issuer, error) { d, err := did.FromPrivKey(privKey) if err != nil { return nil, err @@ -39,16 +53,6 @@ func NewIssuer(privKey crypto.PrivKey, requester client.DelegationRequester, log }, nil } -// IssuingLogic is a function that decides what powers are given to a client. -// - issuer: the DID of our issuer -// - audience: the DID of the client, also the issuer of the invocation token -// - cmd: the command to execute -// - subject: the DID of the resource to operate on, also the subject (or audience if defined) of the invocation token -// Note: you can read it as "(audience) wants to do (cmd) on (subject)". -// Note: you can decide to match the input parameters exactly or issue a broader power, as long as it allows the -// expected action. If you don't want to give that power, return an error instead. -type IssuingLogic func(iss did.DID, aud did.DID, cmd command.Command, subject did.DID) (*delegation.Token, error) - // RequestDelegation retrieve chain of delegation for the given parameters. // - audience: the DID of the client, also the issuer of the invocation token // - cmd: the command to execute @@ -56,6 +60,10 @@ type IssuingLogic func(iss did.DID, aud did.DID, cmd command.Command, subject di // Note: you can read it as "(audience) does (cmd) on (subject)". // Note: the returned delegation(s) don't have to match exactly the parameters, as long as they allow them. func (i *Issuer) RequestDelegation(ctx context.Context, audience did.DID, cmd command.Command, subject did.DID) (iter.Seq2[*delegation.Bundle, error], error) { + if subject != i.did { + return nil, fmt.Errorf("subject DID doesn't match the issuer DID") + } + var proof []cid.Cid // is there already a valid proof chain? @@ -86,6 +94,9 @@ func (i *Issuer) RequestDelegation(ctx context.Context, audience did.DID, cmd co if err != nil { return nil, err } + if dlg.IsRoot() { + return nil, fmt.Errorf("issuing logic should return a non-root delegation") + } // sign and cache the new token dlgBytes, dlgCid, err := dlg.ToSealed(i.privKey) diff --git a/toolkit/issuer/http_wrapper.go b/toolkit/issuer/http_wrapper.go new file mode 100644 index 0000000..a0a095f --- /dev/null +++ b/toolkit/issuer/http_wrapper.go @@ -0,0 +1,83 @@ +package issuer + +import ( + "fmt" + "io" + "iter" + "net/http" + + "github.com/ucan-wg/go-ucan/did" + "github.com/ucan-wg/go-ucan/pkg/command" + "github.com/ucan-wg/go-ucan/pkg/container" + "github.com/ucan-wg/go-ucan/token/delegation" + + "github.com/INFURA/go-ucan-toolkit/client" +) + +type RequestResolver func(r *http.Request) (*ResolvedRequest, error) + +type ResolvedRequest struct { + Audience did.DID + Cmd command.Command + Subject did.DID +} + +// HttpWrapper implements an HTTP transport for a UCAN issuer. +// It provides a common response shape, while allowing customisation of the request. +func HttpWrapper(requester client.DelegationRequester, resolver RequestResolver) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) + return + } + + resolved, err := resolver(r) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + dlgs, err := requester.RequestDelegation(r.Context(), resolved.Audience, resolved.Cmd, resolved.Subject) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + cont := container.NewWriter() + for bundle, err := range dlgs { + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + cont.AddSealed(bundle.Sealed) + } + + err = cont.ToBytesGzippedWriter(w) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + }) +} + +func DecodeResponse(res *http.Response) (iter.Seq2[*delegation.Bundle, error], error) { + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + msg, err := io.ReadAll(res.Body) + if err != nil { + return nil, fmt.Errorf("request failed with status %d, then failed to read response body: %w", res.StatusCode, err) + } + return nil, fmt.Errorf("request failed with status %d: %s", res.StatusCode, msg) + } + cont, err := container.FromReader(res.Body) + if err != nil { + return nil, err + } + return func(yield func(*delegation.Bundle, error) bool) { + for bundle := range cont.GetAllDelegations() { + if !yield(&bundle, nil) { + return + } + } + }, nil +} diff --git a/toolkit/issuer/root_issuer.go b/toolkit/issuer/root_issuer.go new file mode 100644 index 0000000..5723a8c --- /dev/null +++ b/toolkit/issuer/root_issuer.go @@ -0,0 +1,78 @@ +package issuer + +import ( + "context" + "fmt" + "iter" + + "github.com/libp2p/go-libp2p/core/crypto" + "github.com/ucan-wg/go-ucan/did" + "github.com/ucan-wg/go-ucan/pkg/command" + "github.com/ucan-wg/go-ucan/token/delegation" + + "github.com/INFURA/go-ucan-toolkit/client" +) + +// RootIssuingLogic is a function that decides what powers are given to a client. +// - issuer: the DID of our issuer +// - audience: the DID of the client, also the issuer of the invocation token +// - cmd: the command to execute +// Note: you can read it as "(audience) wants to do (cmd) on (subject)". +// Note: you can decide to match the input parameters exactly or issue a broader power, as long as it allows the +// expected action. If you don't want to give that power, return an error instead. +type RootIssuingLogic func(iss did.DID, aud did.DID, cmd command.Command) (*delegation.Token, error) + +var _ client.DelegationRequester = &RootIssuer{} + +// RootIssuer is an implementation of a root delegation issuer. +// Note: Your actual needs for an issuer can easily be different (caching...) than the choices made here. +// Feel free to replace this component with your own flavor. +type RootIssuer struct { + did did.DID + privKey crypto.PrivKey + + logic RootIssuingLogic +} + +func NewRootIssuer(privKey crypto.PrivKey, logic RootIssuingLogic) (*RootIssuer, error) { + d, err := did.FromPrivKey(privKey) + if err != nil { + return nil, err + } + return &RootIssuer{ + did: d, + privKey: privKey, + logic: logic, + }, nil +} + +func (r *RootIssuer) RequestDelegation(ctx context.Context, audience did.DID, cmd command.Command, subject did.DID) (iter.Seq2[*delegation.Bundle, error], error) { + if subject != r.did { + return nil, fmt.Errorf("subject DID doesn't match the issuer DID") + } + + // run the custom logic to get what we actually issue + dlg, err := r.logic(r.did, audience, cmd) + if err != nil { + return nil, err + } + if !dlg.IsRoot() { + return nil, fmt.Errorf("issuing logic should return a root delegation") + } + + // sign and cache the new token + dlgBytes, dlgCid, err := dlg.ToSealed(r.privKey) + if err != nil { + return nil, err + } + bundle := &delegation.Bundle{ + Cid: dlgCid, + Decoded: dlg, + Sealed: dlgBytes, + } + + // output the root delegation + return func(yield func(*delegation.Bundle, error) bool) { + yield(bundle, nil) + }, nil +}