From 6973365c1d745571240c3e5f3fb8bdf4ca5e7253 Mon Sep 17 00:00:00 2001 From: b5 Date: Thu, 3 Sep 2020 10:00:05 -0400 Subject: [PATCH] feat(att): first tests proving attenuation, cleanup --- attenuation.go | 76 ++++++++++++++++++++++++++------------- attenuation_test.go | 87 +++++++++++++++++++++++++++++++++++++++++++++ context.go | 4 +-- example_test.go | 21 ++++++++--- proof.go | 17 +++++++++ token.go | 84 +++++++++++++++++++++++++++++-------------- token_test.go | 35 +++++++++++++++--- 7 files changed, 262 insertions(+), 62 deletions(-) create mode 100644 proof.go diff --git a/attenuation.go b/attenuation.go index 3f9c233..545ab81 100644 --- a/attenuation.go +++ b/attenuation.go @@ -2,20 +2,33 @@ package ucan import ( "encoding/json" + "fmt" ) -const CapKey = "cap" - // Attenuations is a list of attenuations type Attenuations []Attenuation +func (att Attenuations) String() string { + str := "" + for _, a := range att { + str += fmt.Sprintf("%s\n", a) + } + return str +} + // Contains is true if all attenuations in b are contained func (att Attenuations) Contains(b Attenuations) bool { + // fmt.Printf("%scontains\n%s?\n\n", att, b) LOOP: - for _, x := range b { - for _, y := range att { - if y.Contains(x) { + for _, bb := range b { + for _, aa := range att { + if aa.Contains(bb) { + // fmt.Printf("%s contains %s\n", aa, bb) continue LOOP + } else if aa.Rsc.Contains(bb.Rsc) { + // fmt.Printf("%s < %s\n", aa, bb) + // fmt.Printf("rscEq:%t rscContains: %t capContains:%t\n", aa.Rsc.Type() == bb.Rsc.Type(), aa.Rsc.Contains(bb.Rsc), aa.Cap.Contains(bb.Cap)) + return false } } return false @@ -23,24 +36,28 @@ LOOP: return true } +// AttenuationConstructor is a function that creates an attenuation from a map +// Users of this package provide an Attenuation Constructor to the parser to +// bind attenuation logic to a UCAN type AttenuationConstructor func(v map[string]interface{}) (Attenuation, error) +// Attenuation is a capability on a resource type Attenuation struct { Cap Capability Rsc Resource } +// String formats an attenuation as a string +func (a Attenuation) String() string { + return fmt.Sprintf("cap:%s %s:%s", a.Cap, a.Rsc.Type(), a.Rsc.Value()) +} + +// Contains returns true if both func (a Attenuation) Contains(b Attenuation) bool { - return a.Rsc.Type() == b.Rsc.Type() && a.Rsc.Contains(b.Rsc) && a.Cap.Contains(b.Cap) -} - -func NewAttenuation(cap Capability, rsc Resource) Attenuation { - return Attenuation{ - Rsc: rsc, - Cap: cap, - } + return a.Rsc.Contains(b.Rsc) && a.Cap.Contains(b.Cap) } +// MarshalJSON implements the json.Marshaller interface func (a Attenuation) MarshalJSON() ([]byte, error) { return json.Marshal(map[string]interface{}{ a.Rsc.Type(): a.Rsc.Value(), @@ -48,22 +65,21 @@ func (a Attenuation) MarshalJSON() ([]byte, error) { }) } -// ResourcePool is a pool of type strings to -var ResourcePool map[string]ResourceConstructor - +// Resource is a unique identifier for a thing, usually stored state. Resources +// are organized by string types type Resource interface { Type() string Value() string Contains(b Resource) bool } -type ResourceConstructor func(typ, val string) Resource - type stringLengthRsc struct { t string v string } +// NewStringLengthResource is a silly implementation of resource to use while +// I figure out what an OR filter on strings is. Don't use this. func NewStringLengthResource(typ, val string) Resource { return stringLengthRsc{ t: typ, @@ -80,17 +96,20 @@ func (r stringLengthRsc) Value() string { } func (r stringLengthRsc) Contains(b Resource) bool { - return len(r.Value()) < len(b.Value()) + return r.Type() == b.Type() && len(r.Value()) <= len(b.Value()) } -// Capability is the interface for an action users can perform +// Capability is an action users can perform type Capability interface { + // A Capability must be expressable as a string String() string + // Capabilities must be comparable to other same-type capabilities Contains(b Capability) bool } // NestedCapabilities is a basic implementation of the Capabilities interface -// based on a hierarchal list of strings +// based on a hierarchal list of strings ordered from most to least capable +// It is both a capability and a capability factory with the .Cap method type NestedCapabilities struct { cap string idx int @@ -100,7 +119,7 @@ type NestedCapabilities struct { // assert at compile-time NestedCapabilities implements Capability var _ Capability = (*NestedCapabilities)(nil) -// NewNestedCapabilities +// NewNestedCapabilities creates a set of NestedCapabilities func NewNestedCapabilities(strs ...string) NestedCapabilities { return NestedCapabilities{ cap: strs[0], @@ -109,13 +128,18 @@ func NewNestedCapabilities(strs ...string) NestedCapabilities { } } +// Cap creates a new capability from the hierarchy func (nc NestedCapabilities) Cap(str string) Capability { idx := -1 for i, c := range *nc.hierarchy { if c == str { idx = i + break } } + if idx == -1 { + panic(fmt.Sprintf("%s is not a nested capability. must be one of: %v", str, *nc.hierarchy)) + } return NestedCapabilities{ cap: str, @@ -124,18 +148,20 @@ func (nc NestedCapabilities) Cap(str string) Capability { } } +// String returns the Capability value as a string func (nc NestedCapabilities) String() string { return nc.cap } +// Contains returns true if cap is equal or less than the NestedCapability value func (nc NestedCapabilities) Contains(cap Capability) bool { str := cap.String() for i, c := range *nc.hierarchy { if c == str { - if i > nc.idx { - return false + if i >= nc.idx { + return true } - return true + return false } } return false diff --git a/attenuation_test.go b/attenuation_test.go index a96ddb5..0d0c3e7 100644 --- a/attenuation_test.go +++ b/attenuation_test.go @@ -1,9 +1,96 @@ package ucan import ( + "encoding/json" + "fmt" "testing" ) func TestAttenuationsContains(t *testing.T) { + aContains := [][2]string{ + { + `[ + { "cap": "SUPER_USER", "dataset": "b5/world_bank_population"}, + { "cap": "OVERWRITE", "api": "https://api.qri.cloud" } + ]`, + `[ + {"cap": "SOFT_DELETE", "dataset": "b5/world_bank_population" } + ]`, + }, + { + `[ + { "cap": "SUPER_USER", "dataset": "b5/world_bank_population"}, + { "cap": "OVERWRITE", "api": "https://api.qri.cloud" } + ]`, + `[ + {"cap": "SUPER_USER", "dataset": "b5/world_bank_population" } + ]`, + }, + } + + for i, c := range aContains { + t.Run(fmt.Sprintf("contains_%d", i), func(t *testing.T) { + a := testAttenuations(c[0]) + b := testAttenuations(c[1]) + if !a.Contains(b) { + t.Errorf("expected a attenuations to contain b attenuations") + } + }) + } + + aNotContains := [][2]string{ + { + `[ + { "cap": "SUPER_USER", "dataset": "b5/world_bank_population"}, + { "cap": "OVERWRITE", "api": "https://api.qri.cloud" } + ]`, + `[ + { "cap": "CREATE", "dataset": "b5" } + ]`, + }, + } + + for i, c := range aNotContains { + t.Run(fmt.Sprintf("not_contains_%d", i), func(t *testing.T) { + a := testAttenuations(c[0]) + b := testAttenuations(c[1]) + if a.Contains(b) { + t.Errorf("expected a attenuations to NOT contain b attenuations") + } + }) + } +} + +func mustJSON(data string, v interface{}) { + if err := json.Unmarshal([]byte(data), v); err != nil { + panic(err) + } +} + +func testAttenuations(data string) Attenuations { + caps := NewNestedCapabilities("SUPER_USER", "OVERWRITE", "SOFT_DELETE", "REVISE", "CREATE") + + v := []map[string]string{} + mustJSON(data, &v) + + var att Attenuations + for _, x := range v { + var cap Capability + var rsc Resource + for key, val := range x { + switch key { + case CapKey: + cap = caps.Cap(val) + default: + rsc = NewStringLengthResource(key, val) + } + } + att = append(att, Attenuation{cap, rsc}) + } + + return att +} + +func TestNestedCapabilities(t *testing.T) { } diff --git a/context.go b/context.go index fb4d2e9..cd18521 100644 --- a/context.go +++ b/context.go @@ -16,9 +16,9 @@ func CtxWithUCAN(ctx context.Context, t UCAN) context.Context { return context.WithValue(ctx, UCANCtxKey, t) } -// UCANFromCtx extracts a Dataset reference from a given +// FromCtx extracts a Dataset reference from a given // context if one is set, returning nil otherwise -func UCANFromCtx(ctx context.Context) *UCAN { +func FromCtx(ctx context.Context) *UCAN { iface := ctx.Value(UCANCtxKey) if ref, ok := iface.(*UCAN); ok { return ref diff --git a/example_test.go b/example_test.go index 3977be2..fde3999 100644 --- a/example_test.go +++ b/example_test.go @@ -23,10 +23,10 @@ func ExampleWalkthrough() { zero := time.Time{} // create a root UCAN - rootToken, err := source.NewRootUCAN(subjectDID, att, nil, zero, zero) + origin, err := source.NewOriginUCAN(subjectDID, att, nil, zero, zero) panicIfError(err) - id, err := rootToken.CID() + id, err := origin.CID() panicIfError(err) fmt.Printf("cid of root UCAN: %s\n", id.String()) @@ -35,17 +35,30 @@ func ExampleWalkthrough() { {caps.Cap("SUPER_USER"), ucan.NewStringLengthResource("dataset", "third:resource")}, } - if _, err = source.NewAttenuatedUCAN(rootToken, subjectDID, att, nil, zero, zero); err != nil { + if _, err = source.NewAttenuatedUCAN(origin, subjectDID, att, nil, zero, zero); err != nil { fmt.Println(err) } + att = ucan.Attenuations{ + {caps.Cap("OVERWRITE"), ucan.NewStringLengthResource("dataset", "b5:world_bank_population:*")}, + } + + derivedToken, err := source.NewAttenuatedUCAN(origin, subjectDID, att, nil, zero, zero) + panicIfError(err) + + id, err = derivedToken.CID() + panicIfError(err) + + fmt.Printf("cid of derived UCAN: %s\n", id.String()) + p := exampleParser() - _, err = p.ParseAndVerify(context.Background(), rootToken.Raw) + _, err = p.ParseAndVerify(context.Background(), origin.Raw) panicIfError(err) // Output: // cid of root UCAN: bafkreidhsvhlctwylgeibl2eeapdvbl3qm3mbqcqhxhvy4grmr25ji77hu // scope of ucan attenuations must be less than it's parent + // cid of derived UCAN: bafkreifglbwtr27fbzmv3uardlygvggr722fckusfvfyfsonwkroca7efu } func panicIfError(err error) { diff --git a/proof.go b/proof.go new file mode 100644 index 0000000..7e7e7df --- /dev/null +++ b/proof.go @@ -0,0 +1,17 @@ +package ucan + +import ( + "github.com/ipfs/go-cid" +) + +// Proof is a string representing a fact. Expected to be either a raw UCAN token +// or the CID of a raw UCAN token +type Proof string + +// IsCID returns true if the Proof string is a CID +func (prf Proof) IsCID() bool { + if _, err := cid.Decode(string(prf)); err == nil { + return true + } + return false +} diff --git a/token.go b/token.go index 38f5775..b221c20 100644 --- a/token.go +++ b/token.go @@ -1,3 +1,11 @@ +// Package ucan implements User-Controlled Authorization Network tokens by +// fission: +// https://whitepaper.fission.codes/access-control/ucan/ucan-tokens +// +// From the paper: +// The UCAN format is designed as an authenticated digraph in some larger +// authorization space. The other way to view this is as a function from a set +// of authorizations (“UCAN proofs“) to a subset output (“UCAN capabilities”). package ucan import ( @@ -24,12 +32,20 @@ const ( UCANVersion = "0.4.0" // UCANVersionKey is the key used in version headers for the UCAN spec UCANVersionKey = "ucv" + // PrfKey denotes "Proofs" in a UCAN. Stored in JWT Claims + PrfKey = "prf" + // FctKey denotes "Facts" in a UCAN. Stored in JWT Claims + FctKey = "fct" + // AttKey denotes "Attenuations" in a UCAN. Stored in JWT Claims + AttKey = "att" + // CapKey indicates a resource Capability. Used in an attenuation + CapKey = "cap" ) +// UCAN is a JSON Web Token (JWT) that contains special keys type UCAN struct { // Entire UCAN as a signed JWT string Raw string - // the "inputs" to this token, a chain UCAN tokens with broader scopes & // deadlines than this token Proofs []Proof `json:"prf,omitempty"` @@ -57,15 +73,22 @@ func (t *UCAN) PrefixCID(pref cid.Prefix) (cid.Cid, error) { return pref.Sum([]byte(t.Raw)) } -type Proof string - -func (prf Proof) IsCID() bool { - if _, err := cid.Decode(string(prf)); err == nil { - return true - } - return false +// Claims is the claims component of a UCAN token. UCAN claims are expressed +// as a standard JWT claims object with additional special fields +type Claims struct { + *jwt.StandardClaims + // the "inputs" to this token, a chain UCAN tokens with broader scopes & + // deadlines than this token + // Proofs are UCAN chains, leading back to a self-evident origin token + Proofs []Proof `json:"prf,omitempty"` + // the "outputs" of this token, an array of heterogenous resources & + // capabilities + Attenuations Attenuations `json:"att,omitempty"` + // Facts are facts, jack. + Facts []Fact `json:"fct,omitempty"` } +// Fact is self-evident statement type Fact struct { cidString string value map[string]interface{} @@ -80,6 +103,9 @@ type Fact struct { // } // } +// CIDBytesResolver is a small interface for turning a CID into the bytes +// they reference. In practice this may be backed by a network connection that +// can fetch CIDs, eg: IPFS. type CIDBytesResolver interface { ResolveCIDBytes(ctx context.Context, id cid.Cid) ([]byte, error) } @@ -90,22 +116,10 @@ type CIDBytesResolver interface { // implementations of UCANSource must conform to the assertion test defined // in the spec subpackage type UCANSource interface { - NewRootUCAN(subjectDID string, att Attenuations, fct []Fact, notBefore, expires time.Time) (*UCAN, error) + NewOriginUCAN(subjectDID string, att Attenuations, fct []Fact, notBefore, expires time.Time) (*UCAN, error) NewAttenuatedUCAN(parent *UCAN, subjectDID string, att Attenuations, fct []Fact, notBefore, expires time.Time) (*UCAN, error) } -type UCANClaims struct { - *jwt.StandardClaims - // the "inputs" to this token, a chain UCAN tokens with broader scopes & - // deadlines than this token - Proofs []Proof `json:"prf,omitempty"` - // the "outputs" of this token, an array of heterogenous resources & - // capabilities - Attenuations Attenuations `json:"att,omitempty"` - // Facts are facts, jack. - Facts []Fact `json:"fct,omitempty"` -} - type pkUCANSource struct { pk crypto.PrivKey issuerDID string @@ -171,7 +185,7 @@ func NewPrivKeyUCANSource(privKey crypto.PrivKey) (UCANSource, error) { }, nil } -func (a *pkUCANSource) NewRootUCAN(subjectDID string, att Attenuations, fct []Fact, nbf, exp time.Time) (*UCAN, error) { +func (a *pkUCANSource) NewOriginUCAN(subjectDID string, att Attenuations, fct []Fact, nbf, exp time.Time) (*UCAN, error) { return a.newUCAN(subjectDID, nil, att, fct, nbf, exp) } @@ -206,7 +220,7 @@ func (a *pkUCANSource) newUCAN(subjectDID string, prf []Proof, att Attenuations, } // set our claims - t.Claims = &UCANClaims{ + t.Claims = &Claims{ StandardClaims: &jwt.StandardClaims{ Issuer: a.issuerDID, Subject: subjectDID, @@ -277,6 +291,10 @@ func NewUCANParser(ap AttenuationConstructor, didr DIDPubKeyResolver, cidr CIDBy // ParseAndVerify will parse, validate and return a token func (p *UCANParser) ParseAndVerify(ctx context.Context, raw string) (*UCAN, error) { + return p.parseAndVerify(ctx, raw, nil) +} + +func (p *UCANParser) parseAndVerify(ctx context.Context, raw string, child *UCAN) (*UCAN, error) { tok, err := jwt.Parse(raw, p.matchVerifyKeyFunc(ctx)) if err != nil { return nil, err @@ -288,7 +306,7 @@ func (p *UCANParser) ParseAndVerify(ctx context.Context, raw string) (*UCAN, err } var att Attenuations - if acci, ok := mc["att"].([]interface{}); ok { + if acci, ok := mc[AttKey].([]interface{}); ok { for i, a := range acci { if mapv, ok := a.(map[string]interface{}); ok { a, err := p.ap(mapv) @@ -297,16 +315,30 @@ func (p *UCANParser) ParseAndVerify(ctx context.Context, raw string) (*UCAN, err } att = append(att, a) } else { - return nil, fmt.Errorf(`"acc[%d]" is not an object`, i) + return nil, fmt.Errorf(`"att[%d]" is not an object`, i) } } } else { - return nil, fmt.Errorf(`"acc" key is not an array`) + return nil, fmt.Errorf(`"att" key is not an array`) + } + + var prf []Proof + if prfi, ok := mc[PrfKey].([]interface{}); ok { + for i, a := range prfi { + if pStr, ok := a.(string); ok { + prf = append(prf, Proof(pStr)) + } else { + return nil, fmt.Errorf(`"prf[%d]" is not a string`, i) + } + } + } else if mc[PrfKey] != nil { + return nil, fmt.Errorf(`"prf" key is not an array`) } return &UCAN{ Raw: raw, Attenuations: att, + Proofs: prf, }, nil } diff --git a/token_test.go b/token_test.go index 049f03e..05a5c87 100644 --- a/token_test.go +++ b/token_test.go @@ -39,8 +39,8 @@ func init() { } } -func TestPrivKeyTokens(t *testing.T) { - tokens, err := ucan.NewPrivKeyUCANSource(keyOne) +func TestPrivKeySource(t *testing.T) { + source, err := ucan.NewPrivKeyUCANSource(keyOne) if err != nil { t.Fatal(err) } @@ -57,14 +57,30 @@ func TestPrivKeyTokens(t *testing.T) { } zero := time.Time{} - token, err := tokens.NewRootUCAN(didStr, att, nil, zero, zero) + root, err := source.NewOriginUCAN(didStr, att, nil, zero, zero) if err != nil { t.Fatal(err) } expect := `eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsInVjdiI6IjAuNC4wIn0.eyJpc3MiOiJkaWQ6a2V5Ok1JSUJJakFOQmdrcWhraUc5dzBCQVFFRkFBT0NBUThBTUlJQkNnS0NBUUVBb2FkUjRtY1U3QzBBbWg1bHRfM0hObVEyYVlDOEotYU5mNTJUNEtrLTBzbHh6LVc1LXhrREJ0NUR4RUZuSmVKNGJTMV9ZWkt3UkxKQjYzU0phcWZjMXhUTUFYMnJmcW44d3NwUmd2MEFReGU4RV9icGkzZTUyNnU2UU1VRjdYbDRKN2JkbVlZT0lCUDVCSk83eU1pX2pfU3FWaVdmOG82Y3BJTEF3dXpUNTY2X0ttUWFOclM5QmVNUHQ5NTJZUk1lejZlMFoycXR0aVRQS3hmalJ3b0VwRklldDVhZTFZY0p2VDBLQnJiZEYwNXhDc2F6RUoxSm52eUlSamNiUE9FYVljUjNPZnAxdW8ySTRKdVczQ2FKeHNqMU8yNnZyLWRUSzlqcGVFVTl5X1dUU1lNOUVsazBwZ0xZZ1M4ZHE4aTYwNDVnejByemU4QzV2YkZoSFZwa1ZRSURBUUFCIiwic3ViIjoiZGlkOmtleTpNSUlCSWpBTkJna3Foa2lHOXcwQkFRRUZBQU9DQVE4QU1JSUJDZ0tDQVFFQW9hZFI0bWNVN0MwQW1oNWx0XzNITm1RMmFZQzhKLWFOZjUyVDRLay0wc2x4ei1XNS14a0RCdDVEeEVGbkplSjRiUzFfWVpLd1JMSkI2M1NKYXFmYzF4VE1BWDJyZnFuOHdzcFJndjBBUXhlOEVfYnBpM2U1MjZ1NlFNVUY3WGw0SjdiZG1ZWU9JQlA1QkpPN3lNaV9qX1NxVmlXZjhvNmNwSUxBd3V6VDU2Nl9LbVFhTnJTOUJlTVB0OTUyWVJNZXo2ZTBaMnF0dGlUUEt4ZmpSd29FcEZJZXQ1YWUxWWNKdlQwS0JyYmRGMDV4Q3NhekVKMUpudnlJUmpjYlBPRWFZY1IzT2ZwMXVvMkk0SnVXM0NhSnhzajFPMjZ2ci1kVEs5anBlRVU5eV9XVFNZTTlFbGswcGdMWWdTOGRxOGk2MDQ1Z3owcnplOEM1dmJGaEhWcGtWUUlEQVFBQiIsImF0dCI6W3siYXBpIjoiKiIsImNhcCI6IlNVUEVSX1VTRVIifSx7ImNhcCI6IlNVUEVSX1VTRVIiLCJkYXRhc2V0IjoiYjU6d29ybGRfYmFua19wb3B1bGF0aW9uOioifV19.Z32-i-pGAtPRsG0JW4ZS8-c17x3mX3kFrmZ0BYhyWk2JH4QMwXFRtkUl8xVQtrC3JigeQeaDiz-WTUSFqJIs5dunL1Xf_SXqq8SZ7NCh6u6OEo2L1BnQkwdO8kDsFoiF42byWDBwzHRog0N-pRXgMhlo8si6Pek4KAZokQ5F-8FuLb3MXXxc9-FnhGRsKgGt_bNWS322h5gXCaXJAzbdAHwGSlORCCJI4CrbWUHs03i4viun2Ht01JO-p4ySlut6YyQ_vW4NGNSAAXGeR-ggkB0B6TGgt695CxX1zgQKV7X6JZx-NF_J-OXCIWngCfr6VdRv1_ADce9s1ODEm2N7eA` - if expect != token.Raw { - t.Errorf("token mismatch. expected: %q.\ngot: %q", expect, token.Raw) + if expect != root.Raw { + t.Errorf("token mismatch. expected: %q.\ngot: %q", expect, root.Raw) + } + + att = ucan.Attenuations{ + {caps.Cap("OVERWRITE"), ucan.NewStringLengthResource("dataset", "b5:world_bank_population:*")}, + } + + derivedToken, err := source.NewAttenuatedUCAN(root, didStr, att, nil, zero, zero) + if err != nil { + t.Fatal(err) + } + + cidStr := mustCidString(t, derivedToken) + expectCID := "bafkreifglbwtr27fbzmv3uardlygvggr722fckusfvfyfsonwkroca7efu" + + if expectCID != cidStr { + t.Errorf("derived token CID mismatch. expected: %q.\ngot: %q", expectCID, cidStr) } // tokenWithExpiryString, err := tokens.CreateToken(pro, time.Hour) @@ -120,3 +136,12 @@ func TestTokenParse(t *testing.T) { t.Error(err) } } + +func mustCidString(t *testing.T, tok *ucan.UCAN) string { + t.Helper() + id, err := tok.CID() + if err != nil { + t.Fatal(err) + } + return id.String() +}