reorg packages, remove outdated server example

This commit is contained in:
Michael Muré
2024-11-28 14:26:27 +01:00
committed by Michael Muré
parent 4f4331b677
commit 4167bf44bd
11 changed files with 1074 additions and 113 deletions

View File

@@ -1,6 +0,0 @@
# HTTP Server middleware example
This package shows an example of an HTTP middleware with the following features:
- decoding a UCAN delegation token from the `Authorization: Bearer` HTTP Header, according to the PriorityConnect Step0 way (which is not the fully correct UCAN way)
- minimal verifications, no policies or args
- retrieval of values passed as token metadata, and insertion in the go context

View File

@@ -0,0 +1,66 @@
## Motivations
UCAN is normally a pure RPC construct, when the entirety of the request's parameters are part of the invocation, in the form of `args`. Those `args` are evaluated against the delegation's [policy](https://github.com/ucan-wg/delegation/tree/v1_ipld?tab=readme-ov-file#policy) to determine if the request is allowed or not, then the request handling happens purely based on those args and the `command`. In that setup, the service would have a single entry point.
Unfortunately, we live in a world of REST APIs, or JSON-RPC. Some adaptations or concessions need to be made.
In this package, we cross the chasm of the pure UCAN world into our practical needs. This can, however, be done in a reasonable way.
## Example
Below is an example of `args` in Dag-Json format, where the values are recomposed server-side from the HTTP request (header and JSONRPC body):
```json
{
"http": {
"scheme": "https",
"method": "POST",
"host": "mainnet.infura.io",
"path": ""
},
"jsonrpc": {
"jsonrpc": "2.0",
"method": "eth_blockbynumber",
"params": [],
"id": 1
}
}
```
Those `args` can be evaluated against a delegation's policy, for example:
```json
{
"cmd": "/infura/jsonrpc",
"pol": [
["==", ".http.host", "mainnet.infura.io"],
["like", ".jsonrpc.method", "eth_*"]
]
}
```
## Security implications
UCAN essentially aims for perfect security. By having external args, we break that security perimeter, and we now need to arbitrate between security and practicality.
First, what are we breaking exactly? Normally, the invocation has all the parameters and is signed by the invoker. This means that an attacker cannot intercept (MITM) a request and change the parameters when relaying it to the server. As they are signed, that would make the request invalid.
If we have external args, now an attacker can intercept the request, change it, and pretend to be that person doing other things than intended. **That may of may not be an actual problem, depending on the situation.**
There is a way to get around that, and have the best of both worlds, but **it comes with a client side complexity**: we can hash the external values and put them into the invocation's `args`. For example:
```json
{
"http": "zQmSnuWmxptJZdLJpKRarxBMS2Ju2oANVrgbr2xWbie9b2D",
"jsonrpc": "zQmP8jTG1m9GSDJLCbeWhVSVgEzCPPwXRdCRuJtQ5Tz9Kc9"
}
```
When the server receives the request, it can now reconstruct the values from the HTTP request, verify the hash and replace it with the real values for evaluation against the policies. **We are now back to the better security model**, but at the price of client-side complexity.
## API design
Therefore, the server-side logic is made to have this hashing optional:
- if present, the server honors the hash and enforces the security
- the client can opt out of passing that hash, and won't benefit from the enforced security
The particular hash selected is SHA2-256 of the DAG-CBOR encoded argument, expressed in the form of a Multihash in raw bytes.
The arguments being hashed are the complete map of values, including the root key being replaced (for example `jsonrpc` or `http`).

View File

@@ -0,0 +1,160 @@
package bearer
import (
"bytes"
"fmt"
"net/http"
"sync"
"github.com/ipld/go-ipld-prime"
"github.com/ipld/go-ipld-prime/codec/dagcbor"
"github.com/ipld/go-ipld-prime/datamodel"
"github.com/ipld/go-ipld-prime/fluent/qp"
"github.com/ipld/go-ipld-prime/node/basicnode"
"github.com/multiformats/go-multihash"
"github.com/ucan-wg/go-ucan/pkg/args"
"github.com/ucan-wg/go-ucan/pkg/policy"
)
// HttpArgsKey is the key in the args, used for:
// - if it exists in the invocation, holds a hash of the args derived from the HTTP request
// - in the final args to be evaluated against the policies, holds the args derived from the HTTP request
const HttpArgsKey = "http"
type HttpBearer struct {
pol policy.Policy
originalArgs args.ReadOnly
req *http.Request
once sync.Once
args *args.Args
argsIpld ipld.Node
}
func NewHttpBearer(pol policy.Policy, originalArgs args.ReadOnly, req *http.Request) *HttpBearer {
return &HttpBearer{pol: pol, originalArgs: originalArgs, req: req}
}
func (hc *HttpBearer) Verify() error {
if err := hc.makeArgs(); err != nil {
return err
}
if err := hc.verifyHash(); err != nil {
return err
}
ok, leaf := hc.pol.PartialMatch(hc.argsIpld)
if !ok {
return fmt.Errorf("the following UCAN policy is not satisfied: %v", leaf.String())
}
return nil
}
func (hc *HttpBearer) Args() (*args.Args, error) {
if err := hc.makeArgs(); err != nil {
return nil, err
}
return hc.args, nil
}
func (hc *HttpBearer) makeArgs() error {
var outerErr error
hc.once.Do(func() {
var err error
hc.args, err = makeHttpArgs(hc.req)
if err != nil {
outerErr = err
return
}
hc.argsIpld, err = hc.args.ToIPLD()
if err != nil {
outerErr = err
return
}
})
return outerErr
}
func (hc *HttpBearer) verifyHash() error {
n, err := hc.originalArgs.GetNode(HttpArgsKey)
if err != nil {
// no hash found, nothing to verify
return nil
}
mhBytes, err := n.AsBytes()
if err != nil {
return fmt.Errorf("http args hash should be a string")
}
data, err := ipld.Encode(hc.argsIpld, dagcbor.Encode)
if err != nil {
return fmt.Errorf("can't encode derived args in dag-cbor: %w", err)
}
sum, err := multihash.Sum(data, multihash.SHA2_256, -1)
if err != nil {
return err
}
if !bytes.Equal(mhBytes, sum) {
return fmt.Errorf("derived args from http request don't match the expected hash")
}
return nil
}
// MakeHttpHash compute the hash of the derived arguments from the HTTP request.
// If that hash is inserted at the HttpArgsKey key in the invocation arguments,
// this increases the security as the UCAN token cannot be used with a different
// HTTP request.
func MakeHttpHash(req *http.Request) ([]byte, error) {
computedArgs, err := makeHttpArgs(req)
if err != nil {
return nil, err
}
n, err := computedArgs.ToIPLD()
if err != nil {
return nil, err
}
data, err := ipld.Encode(n, dagcbor.Encode)
if err != nil {
return nil, err
}
sum, err := multihash.Sum(data, multihash.SHA2_256, -1)
if err != nil {
return nil, err
}
return sum, nil
}
func makeHttpArgs(req *http.Request) (*args.Args, error) {
n, err := qp.BuildMap(basicnode.Prototype.Any, 4, func(ma datamodel.MapAssembler) {
qp.MapEntry(ma, "scheme", qp.String(req.URL.Scheme)) // https
qp.MapEntry(ma, "method", qp.String(req.Method)) // GET
qp.MapEntry(ma, "host", qp.String(req.Host)) // example.com
qp.MapEntry(ma, "path", qp.String(req.URL.Path)) // /foo
qp.MapEntry(ma, "headers", qp.Map(2, func(ma datamodel.MapAssembler) {
qp.MapEntry(ma, "Origin", qp.String(req.Header.Get("Origin")))
qp.MapEntry(ma, "User-Agent", qp.String(req.Header.Get("User-Agent")))
}))
})
if err != nil {
return nil, err
}
res := args.New()
err = res.Add(HttpArgsKey, n)
if err != nil {
return nil, err
}
return res, nil
}

View File

@@ -0,0 +1,185 @@
package bearer
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/multiformats/go-multihash"
"github.com/stretchr/testify/require"
"github.com/ucan-wg/go-ucan/pkg/args"
"github.com/ucan-wg/go-ucan/pkg/policy"
"github.com/ucan-wg/go-ucan/pkg/policy/literal"
)
func TestHttp(t *testing.T) {
pol := policy.MustConstruct(
policy.Equal(".http.scheme", literal.String("http")),
policy.Equal(".http.method", literal.String("GET")),
policy.Equal(".http.host", literal.String("example.com")),
policy.Equal(".http.path", literal.String("/foo")),
policy.Like(".http.headers.User-Agent", "*Mozilla*"),
policy.Equal(".http.headers.Origin", literal.String("dapps.com")),
)
tests := []struct {
name string
method string
target string
headers map[string]string
expected bool
}{
{
name: "happy path",
method: http.MethodGet,
target: "http://example.com/foo",
headers: map[string]string{
"User-Agent": "Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:47.0) Gecko/20100101 Firefox/47.0",
"Origin": "dapps.com",
},
expected: true,
},
{
name: "wrong scheme",
method: http.MethodGet,
target: "https://example.com/foo",
headers: map[string]string{
"User-Agent": "Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:47.0) Gecko/20100101 Firefox/47.0",
"Origin": "dapps.com",
},
expected: false,
},
{
name: "wrong method",
method: http.MethodPost,
target: "http://example.com/foo",
headers: map[string]string{
"User-Agent": "Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:47.0) Gecko/20100101 Firefox/47.0",
"Origin": "dapps.com",
},
expected: false,
},
{
name: "wrong host",
method: http.MethodGet,
target: "http://foo.com/foo",
headers: map[string]string{
"User-Agent": "Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:47.0) Gecko/20100101 Firefox/47.0",
"Origin": "dapps.com",
},
expected: false,
},
{
name: "wrong path",
method: http.MethodGet,
target: "http://example.com/bar",
headers: map[string]string{
"User-Agent": "Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:47.0) Gecko/20100101 Firefox/47.0",
"Origin": "dapps.com",
},
expected: false,
},
{
name: "wrong origin",
method: http.MethodGet,
target: "http://example.com/foo",
headers: map[string]string{
"User-Agent": "Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:47.0) Gecko/20100101 Firefox/47.0",
"Origin": "foo.com",
},
expected: false,
},
{
name: "wrong user-agent",
method: http.MethodGet,
target: "http://example.com/foo",
headers: map[string]string{
"User-Agent": "Chrome/51.0.2704.103 Safari/537.36",
"Origin": "dapps.com",
},
expected: false,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
handler := func(w http.ResponseWriter, r *http.Request) {
// we don't test the args hash here
emptyArgs := args.New().ReadOnly()
ctx := NewHttpBearer(pol, emptyArgs, r)
_, err := ctx.Args()
require.NoError(t, err)
if tc.expected {
require.NoError(t, ctx.Verify())
} else {
require.Error(t, ctx.Verify())
}
}
req := httptest.NewRequest(tc.method, tc.target, nil)
for k, v := range tc.headers {
req.Header.Set(k, v)
}
w := httptest.NewRecorder()
handler(w, req)
})
}
}
func TestHttpHash(t *testing.T) {
req, err := http.NewRequest(http.MethodGet, "http://example.com/foo", nil)
require.NoError(t, err)
req.Header.Add("User-Agent", "Chrome/51.0.2704.103 Safari/537.36")
req.Header.Add("Origin", "dapps.com")
pol := policy.MustConstruct(
policy.Equal(".http.scheme", literal.String("http")),
)
tests := []struct {
name string
hash []byte
expected bool
}{
{
name: "correct hash",
hash: must(MakeHttpHash(req)),
expected: true,
},
{
name: "non-matching hash",
hash: must(multihash.Sum([]byte{1, 2, 3, 4}, multihash.SHA2_256, -1)),
expected: false,
},
{
name: "wrong type of hash",
hash: must(multihash.Sum([]byte{1, 2, 3, 4}, multihash.BLAKE3, -1)),
expected: false,
},
{
name: "no hash",
hash: nil,
expected: false,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
invArgs := args.New()
err := invArgs.Add(HttpArgsKey, tc.hash)
require.NoError(t, err)
ctx := NewHttpBearer(pol, invArgs.ReadOnly(), req)
if tc.expected {
require.NoError(t, ctx.Verify())
} else {
require.Error(t, ctx.Verify())
}
})
}
}

View File

@@ -0,0 +1,168 @@
package bearer
import (
"bytes"
"encoding/json"
"fmt"
"sync"
"github.com/INFURA/go-ethlibs/jsonrpc"
"github.com/ipld/go-ipld-prime"
"github.com/ipld/go-ipld-prime/codec/dagcbor"
"github.com/ipld/go-ipld-prime/datamodel"
"github.com/ipld/go-ipld-prime/fluent/qp"
"github.com/ipld/go-ipld-prime/node/basicnode"
"github.com/multiformats/go-multihash"
"github.com/ucan-wg/go-ucan/pkg/args"
"github.com/ucan-wg/go-ucan/pkg/policy"
"github.com/ucan-wg/go-ucan/pkg/policy/literal"
)
// JsonRpcArgsKey is the key in the args, used for:
// - if it exists in the invocation, holds a hash of the args derived from the JsonRpc request
// - in the final args to be evaluated against the policies, holds the args derived from the JsonRpc request
const JsonRpcArgsKey = "jsonrpc"
type JsonRpcBearer struct {
pol policy.Policy
originalArgs args.ReadOnly
req *jsonrpc.Request
once sync.Once
args *args.Args
argsIpld ipld.Node
}
func NewJsonRpcBearer(pol policy.Policy, originalArgs args.ReadOnly, req *jsonrpc.Request) *JsonRpcBearer {
return &JsonRpcBearer{pol: pol, originalArgs: originalArgs, req: req}
}
func (jrc *JsonRpcBearer) Verify() error {
if err := jrc.makeArgs(); err != nil {
return err
}
if err := jrc.verifyHash(); err != nil {
return err
}
ok, leaf := jrc.pol.PartialMatch(jrc.argsIpld)
if !ok {
return fmt.Errorf("the following UCAN policy is not satisfied: %v", leaf.String())
}
return nil
}
func (jrc *JsonRpcBearer) Args() (*args.Args, error) {
if err := jrc.makeArgs(); err != nil {
return nil, err
}
return jrc.args, nil
}
func (jrc *JsonRpcBearer) makeArgs() error {
var outerErr error
jrc.once.Do(func() {
var err error
jrc.args, err = makeJsonRpcArgs(jrc.req)
if err != nil {
outerErr = err
return
}
jrc.argsIpld, err = jrc.args.ToIPLD()
if err != nil {
outerErr = err
return
}
})
return outerErr
}
func (jrc *JsonRpcBearer) verifyHash() error {
n, err := jrc.originalArgs.GetNode(JsonRpcArgsKey)
if err != nil {
// no hash found, nothing to verify
return nil
}
mhBytes, err := n.AsBytes()
if err != nil {
return fmt.Errorf("jsonrpc args hash should be a string")
}
data, err := ipld.Encode(jrc.argsIpld, dagcbor.Encode)
if err != nil {
return fmt.Errorf("can't encode derived args in dag-cbor: %w", err)
}
sum, err := multihash.Sum(data, multihash.SHA2_256, -1)
if err != nil {
return err
}
if !bytes.Equal(mhBytes, sum) {
return fmt.Errorf("derived args from jsonrpc request don't match the expected hash")
}
return nil
}
// MakeJsonRpcHash compute the hash of the derived arguments from the JsonRPC request.
// If that hash is inserted at the JsonRpcArgsKey key in the invocation arguments,
// this increases the security as the UCAN token cannot be used with a different
// JsonRPC request.
func MakeJsonRpcHash(req *jsonrpc.Request) ([]byte, error) {
computedArgs, err := makeJsonRpcArgs(req)
if err != nil {
return nil, err
}
n, err := computedArgs.ToIPLD()
if err != nil {
return nil, err
}
data, err := ipld.Encode(n, dagcbor.Encode)
if err != nil {
return nil, err
}
sum, err := multihash.Sum(data, multihash.SHA2_256, -1)
if err != nil {
return nil, err
}
return sum, nil
}
func makeJsonRpcArgs(req *jsonrpc.Request) (*args.Args, error) {
deserialized := make([]any, len(req.Params))
for i, param := range req.Params {
err := json.Unmarshal(param, &deserialized[i])
if err != nil {
return nil, err
}
}
params, err := literal.List(deserialized)
if err != nil {
return nil, err
}
n, err := qp.BuildMap(basicnode.Prototype.Any, 3, func(ma datamodel.MapAssembler) {
qp.MapEntry(ma, "jsonrpc", qp.String(req.JSONRPC))
qp.MapEntry(ma, "method", qp.String(req.Method))
qp.MapEntry(ma, "params", qp.Node(params))
})
if err != nil {
return nil, err
}
res := args.New()
err = res.Add(JsonRpcArgsKey, n)
if err != nil {
return nil, err
}
return res, nil
}

View File

@@ -0,0 +1,171 @@
package bearer
import (
"testing"
"github.com/INFURA/go-ethlibs/jsonrpc"
"github.com/multiformats/go-multihash"
"github.com/stretchr/testify/require"
"github.com/ucan-wg/go-ucan/pkg/args"
"github.com/ucan-wg/go-ucan/pkg/policy"
"github.com/ucan-wg/go-ucan/pkg/policy/literal"
)
func TestJsonRpc(t *testing.T) {
tests := []struct {
name string
req *jsonrpc.Request
pol policy.Policy
expected bool
}{
{
name: "or on method, not matching",
req: jsonrpc.MustRequest(1839673506133526, "eth_getBlockByNumber",
"0x599784", true,
),
pol: policy.MustConstruct(
policy.Or(
policy.Equal(".jsonrpc.method", literal.String("eth_getCode")),
policy.Equal(".jsonrpc.method", literal.String("eth_getBalance")),
policy.Equal(".jsonrpc.method", literal.String("eth_call")),
policy.Equal(".jsonrpc.method", literal.String("eth_blockNumber")),
),
),
expected: false,
},
{
name: "or on method, matching",
req: jsonrpc.MustRequest(1839673506133526, "eth_call",
map[string]string{"to": "0xBADBADBADBADBADBADBADBADBADBADBADBADBAD1"},
),
pol: policy.MustConstruct(
policy.Or(
policy.Equal(".jsonrpc.method", literal.String("eth_getCode")),
policy.Equal(".jsonrpc.method", literal.String("eth_getBalance")),
policy.Equal(".jsonrpc.method", literal.String("eth_call")),
policy.Equal(".jsonrpc.method", literal.String("eth_blockNumber")),
),
),
expected: true,
},
{
name: "complex, optional parameter, matching",
req: jsonrpc.MustRequest(1839673506133526, "debug_traceCall",
true, false, 1234, "callTracer",
),
pol: policy.MustConstruct(
policy.Equal(".jsonrpc.method", literal.String("debug_traceCall")),
policy.Or(
policy.Equal(".jsonrpc.params[3]?", literal.String("callTracer")),
policy.Equal(".jsonrpc.params[3]?", literal.String("prestateTracer")),
),
),
expected: true,
},
{
name: "complex, optional parameter, missing parameter",
req: jsonrpc.MustRequest(1839673506133526, "debug_traceCall",
true, false, 1234,
),
pol: policy.MustConstruct(
policy.Equal(".jsonrpc.method", literal.String("debug_traceCall")),
policy.Or(
policy.Equal(".jsonrpc.params[3]?", literal.String("callTracer")),
policy.Equal(".jsonrpc.params[3]?", literal.String("prestateTracer")),
),
),
expected: true,
},
{
name: "complex, parameter not matching",
req: jsonrpc.MustRequest(1839673506133526, "debug_traceCall",
true, false, 1234, "ho_no",
),
pol: policy.MustConstruct(
policy.Equal(".jsonrpc.method", literal.String("debug_traceCall")),
policy.Or(
policy.Equal(".jsonrpc.params[3]?", literal.String("callTracer")),
policy.Equal(".jsonrpc.params[3]?", literal.String("prestateTracer")),
),
),
expected: false,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
// we don't test the args hash here
emptyArgs := args.New().ReadOnly()
ctx := NewJsonRpcBearer(tc.pol, emptyArgs, tc.req)
_, err := ctx.Args()
require.NoError(t, err)
if tc.expected {
require.NoError(t, ctx.Verify())
} else {
require.Error(t, ctx.Verify())
}
})
}
}
func TestJsonRpcHash(t *testing.T) {
req := jsonrpc.MustRequest(1839673506133526, "debug_traceCall",
true, false, 1234, "ho_no",
)
pol := policy.MustConstruct(
policy.Equal(".jsonrpc.method", literal.String("debug_traceCall")),
)
tests := []struct {
name string
hash []byte
expected bool
}{
{
name: "correct hash",
hash: must(MakeJsonRpcHash(req)),
expected: true,
},
{
name: "non-matching hash",
hash: must(multihash.Sum([]byte{1, 2, 3, 4}, multihash.SHA2_256, -1)),
expected: false,
},
{
name: "wrong type of hash",
hash: must(multihash.Sum([]byte{1, 2, 3, 4}, multihash.BLAKE3, -1)),
expected: false,
},
{
name: "no hash",
hash: nil,
expected: false,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
invArgs := args.New()
err := invArgs.Add(JsonRpcArgsKey, tc.hash)
require.NoError(t, err)
ctx := NewJsonRpcBearer(pol, invArgs.ReadOnly(), req)
if tc.expected {
require.NoError(t, ctx.Verify())
} else {
require.Error(t, ctx.Verify())
}
})
}
}
func must[T any](t T, err error) T {
if err != nil {
panic(err)
}
return t
}

View File

@@ -1,27 +0,0 @@
package server
import "context"
type ctxUserIdKey struct{}
// ContextGetUserId return the UserId stored in the context, if it exists.
func ContextGetUserId(ctx context.Context) (string, bool) {
val, ok := ctx.Value(ctxUserIdKey{}).(string)
return val, ok
}
func addUserIdToContext(ctx context.Context, userId string) context.Context {
return context.WithValue(ctx, ctxUserIdKey{}, userId)
}
type ctxProjectIdKey struct{}
// ContextGetProjectId return the ProjectID stored in the context, if it exists.
func ContextGetProjectId(ctx context.Context) (string, bool) {
val, ok := ctx.Value(ctxProjectIdKey{}).(string)
return val, ok
}
func addProjectIdToContext(ctx context.Context, projectId string) context.Context {
return context.WithValue(ctx, ctxProjectIdKey{}, projectId)
}

View File

@@ -0,0 +1,16 @@
package exectx
import "context"
type ctxKey struct{}
// AddUcanCtxToContext insert a UcanCtx into a go context.
func AddUcanCtxToContext(ctx context.Context, ucanCtx *UcanCtx) context.Context {
return context.WithValue(ctx, ctxKey{}, ucanCtx)
}
// FromContext retrieve a UcanCtx from a go context.
func FromContext(ctx context.Context) (*UcanCtx, bool) {
ucanCtx, ok := ctx.Value(ctxKey{}).(*UcanCtx)
return ucanCtx, ok
}

View File

@@ -0,0 +1,146 @@
package exectx
import (
"errors"
"fmt"
"net/http"
"slices"
"github.com/INFURA/go-ethlibs/jsonrpc"
"github.com/ipfs/go-cid"
"github.com/ucan-wg/go-ucan/pkg/args"
"github.com/ucan-wg/go-ucan/pkg/command"
"github.com/ucan-wg/go-ucan/pkg/container"
"github.com/ucan-wg/go-ucan/pkg/meta"
"github.com/ucan-wg/go-ucan/pkg/policy"
"github.com/ucan-wg/go-ucan/token/delegation"
"github.com/ucan-wg/go-ucan/token/invocation"
bearer2 "github.com/INFURA/go-ucan-toolkit/server/bearer"
)
var _ delegation.Loader = UcanCtx{}
// UcanCtx is a UCAN execution context meant to be inserted in the go context while handling a request.
// It allows to handle the control of the execution in multiple steps across different middlewares,
// as well as doing "bearer" types of controls, when arguments are derived from the request itself (HTTP, JsonRpc).
type UcanCtx struct {
inv *invocation.Token
dlgs map[cid.Cid]*delegation.Token
policies policy.Policy // all policies combined
meta *meta.Meta // all meta combined, with no overwriting
// argument sources
http *bearer2.HttpBearer
jsonrpc *bearer2.JsonRpcBearer
}
func FromContainer(cont container.Reader) (*UcanCtx, error) {
inv, err := cont.GetInvocation()
if err != nil {
return nil, fmt.Errorf("no invocation: %w", err)
}
ctx := &UcanCtx{
inv: inv,
dlgs: make(map[cid.Cid]*delegation.Token, len(cont)-1),
meta: meta.NewMeta(),
}
// iterate in reverse, from the root delegation to the leaf
for _, c := range slices.Backward(inv.Proof()) {
// make sure we have the delegation
dlg, err := cont.GetDelegation(c)
if errors.Is(err, delegation.ErrDelegationNotFound) {
return nil, fmt.Errorf("delegation proof %s is missing", c)
}
if err != nil {
return nil, err
}
ctx.dlgs[c] = dlg
// accumulate the policies
ctx.policies = append(ctx.policies, dlg.Policy()...)
// accumulate the meta values, with no overwriting
ctx.meta.Include(dlg.Meta())
}
return ctx, nil
}
// Command returns the command triggered by the invocation.
func (ctn UcanCtx) Command() command.Command {
return ctn.inv.Command()
}
// Invocation returns the invocation.Token.
func (ctn UcanCtx) Invocation() *invocation.Token {
return ctn.inv
}
// GetDelegation returns the delegation.Token matching the given CID.
// If not found, delegation.ErrDelegationNotFound is returned.
func (ctn UcanCtx) GetDelegation(cid cid.Cid) (*delegation.Token, error) {
if dlg, ok := ctn.dlgs[cid]; ok {
return dlg, nil
}
return nil, delegation.ErrDelegationNotFound
}
// Policies return the full set of policy statements to satisfy.
func (ctn UcanCtx) Policies() policy.Policy {
return ctn.policies
}
// Meta returns all the meta values from the delegations.
// They are accumulated from the root delegation to the leaf delegation, with no overwrite.
func (ctn UcanCtx) Meta() meta.ReadOnly {
return ctn.meta.ReadOnly()
}
// VerifyHttp verify the delegation's policies against arguments constructed from the HTTP request.
// This function can only be called once per context.
// After being used, those constructed arguments will be used in ExecutionAllowed as well.
func (ctn UcanCtx) VerifyHttp(req *http.Request) error {
if ctn.http == nil {
panic("only use once per request context")
}
ctn.http = bearer2.NewHttpBearer(ctn.policies, ctn.inv.Arguments(), req)
return ctn.http.Verify()
}
// VerifyJsonRpc verify the delegation's policies against arguments constructed from the JsonRpc request.
// This function can only be called once per context.
// After being used, those constructed arguments will be used in ExecutionAllowed as well.
func (ctn UcanCtx) VerifyJsonRpc(req *jsonrpc.Request) error {
if ctn.jsonrpc != nil {
panic("only use once per request context")
}
ctn.jsonrpc = bearer2.NewJsonRpcBearer(ctn.policies, ctn.inv.Arguments(), req)
return ctn.jsonrpc.Verify()
}
// ExecutionAllowed does the final verification of the invocation.
// If VerifyHttp or VerifyJsonRpc has been used, those arguments are part of the verification.
func (ctn UcanCtx) ExecutionAllowed() error {
return ctn.inv.ExecutionAllowedWithArgsHook(ctn, func(args args.ReadOnly) (*args.Args, error) {
newArgs := args.WriteableClone()
if ctn.http != nil {
httpArgs, err := ctn.http.Args()
if err != nil {
return nil, err
}
newArgs.Include(httpArgs)
}
if ctn.jsonrpc != nil {
jsonRpcArgs, err := ctn.jsonrpc.Args()
if err != nil {
return nil, err
}
newArgs.Include(jsonRpcArgs)
}
return newArgs, nil
})
}

View File

@@ -0,0 +1,162 @@
package exectx_test
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"testing"
"time"
"github.com/INFURA/go-ethlibs/jsonrpc"
"github.com/ipfs/go-cid"
"github.com/stretchr/testify/require"
"github.com/ucan-wg/go-ucan/did/didtest"
"github.com/ucan-wg/go-ucan/pkg/command"
"github.com/ucan-wg/go-ucan/pkg/container"
"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"
"github.com/ucan-wg/go-ucan/token/invocation"
exectx2 "github.com/INFURA/go-ucan-toolkit/server/exectx"
)
func ExampleContext() {
// let's use some pre-made DID+privkey.
// use go-ucan/did to generate or parse them.
service := didtest.PersonaAlice
user := didtest.PersonaBob
// DELEGATION: the service gives some power to the user, in the form of a root UCAN token.
// The command defines the shape of the arguments on which the policies operate, it is specific to that service.
// Policies define what the user can do.
cmd := command.New("foo")
pol := policy.MustConstruct(
// some basic HTTP constraints
policy.Equal(".http.method", literal.String("GET")),
policy.Like(".http.path", "/foo/*"),
// some JsonRpc constraints
policy.Or(
policy.Like(".jsonrpc.method", "eth_*"),
policy.Equal(".jsonrpc.method", literal.String("debug_traceCall")),
),
)
dlg, _ := delegation.Root(service.DID(), user.DID(), cmd, pol,
delegation.WithExpirationIn(24*time.Hour),
)
dlgBytes, dlgCid, _ := dlg.ToSealed(service.PrivKey())
// INVOCATION: the user leverages the delegation (power) to make a request.
inv, _ := invocation.New(user.DID(), service.DID(), cmd, []cid.Cid{dlgCid},
invocation.WithExpirationIn(10*time.Minute),
invocation.WithArgument("myarg", "hello"), // we can specify invocation parameters
)
invBytes, invCid, _ := inv.ToSealed(user.PrivKey())
// PACKAGING: no obligation for the transport, but the user needs to give the service the invocation
// and all the proof delegations. We can use a container for that.
cont := container.NewWriter()
cont.AddSealed(dlgCid, dlgBytes)
cont.AddSealed(invCid, invBytes)
contBytes, _ := cont.ToCborBase64()
// MAKING A REQUEST: we pass the container in the Bearer HTTP header
jrpc := jsonrpc.NewRequest()
jrpc.Method = "eth_call"
jrpc.Params = jsonrpc.MustParams("0x599784", true)
jrpcBytes, _ := jrpc.MarshalJSON()
req, _ := http.NewRequest(http.MethodGet, "/foo/bar", bytes.NewReader(jrpcBytes))
req.Header.Set("Authorization", "Bearer "+string(contBytes))
// SERVER: Auth middleware
// - decode the container
// - create the context
authMw := func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Note: we obviously want something more robust, this is an example
// Note: if an error occur, we'll want to return an HTTP 401 Unauthorized
data := bytes.TrimPrefix([]byte(r.Header.Get("Authorization")), []byte("Bearer "))
cont, _ := container.FromCborBase64(data)
ucanCtx, _ := exectx2.FromContainer(cont)
// insert into the go context
req = req.WithContext(exectx2.AddUcanCtxToContext(req.Context(), ucanCtx))
next.ServeHTTP(w, req)
})
}
// SERVER: http checks
httpMw := func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ucanCtx, _ := exectx2.FromContext(req.Context())
err := ucanCtx.VerifyHttp(r)
if err != nil {
// This will print something like:
// `the following UCAN policy is not satisfied: ["==", ".http.path", "/foo"]`
http.Error(w, err.Error(), http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}
// SERVER: JsonRpc checks
jsonrpcMw := func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ucanCtx, _ := exectx2.FromContext(req.Context())
var jrpc jsonrpc.Request
_ = json.NewDecoder(r.Body).Decode(&jrpc)
err := ucanCtx.VerifyJsonRpc(&jrpc)
if err != nil {
http.Error(w, err.Error(), http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}
// SERVER: final handler
handler := func(w http.ResponseWriter, r *http.Request) {
ucanCtx, _ := exectx2.FromContext(req.Context())
if err := ucanCtx.ExecutionAllowed(); err != nil {
http.Error(w, err.Error(), http.StatusUnauthorized)
return
}
_, _ = fmt.Fprintln(w, "Success!")
}
// Ready to go!
_ = http.ListenAndServe("", authMw(httpMw(jsonrpcMw(http.HandlerFunc(handler)))))
}
func TestGoCtx(t *testing.T) {
ctx := context.Background()
ucanCtx, ok := exectx2.FromContext(ctx)
require.False(t, ok)
require.Nil(t, ucanCtx)
expected := &exectx2.UcanCtx{}
ctx = exectx2.AddUcanCtxToContext(ctx, expected)
got, ok := exectx2.FromContext(ctx)
require.True(t, ok)
require.Equal(t, expected, got)
}

View File

@@ -1,80 +0,0 @@
package server
import (
"encoding/base64"
"io"
"net/http"
"strings"
"github.com/ucan-wg/go-ucan/did"
"github.com/ucan-wg/go-ucan/token/delegation"
)
type Middleware func(http.Handler) http.Handler
func ExampleMiddleware(serviceDID did.DID) Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
tokenReader, ok := extractBearerToken(r)
if !ok {
http.Error(w, "missing or malformed UCAN HTTP Bearer token", http.StatusUnauthorized)
return
}
// decode
// TODO: ultimately, this token should be a container with one invocation and 1+ delegations.
// We are doing something simpler for now.
dlg, err := delegation.FromDagCborReader(tokenReader)
if err != nil {
http.Error(w, "malformed UCAN delegation", http.StatusBadRequest)
return
}
// optional: http-bearer
// validate
if dlg.Subject() != serviceDID {
http.Error(w, "invalid UCAN delegation", http.StatusBadRequest)
return
}
// TODO: policies check + inject in context
// extract values
userId, err := dlg.Meta().GetString("userId")
if err != nil {
http.Error(w, "missing or malformed userId", http.StatusBadRequest)
return
}
projectId, err := dlg.Meta().GetString("projectId")
if err != nil {
http.Error(w, "missing or malformed projectId", http.StatusBadRequest)
return
}
// inject into context
ctx = addUserIdToContext(ctx, userId)
ctx = addProjectIdToContext(ctx, projectId)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
func extractBearerToken(r *http.Request) (io.Reader, bool) {
header := r.Header.Get("Authorization")
if header == "" {
return nil, false
}
if !strings.HasPrefix(header, "Bearer ") {
return nil, false
}
// skip prefix
reader := strings.NewReader(header[len("Bearer "):])
// base64 decode
return base64.NewDecoder(base64.StdEncoding, reader), true
}