23 Commits

Author SHA1 Message Date
Michael Muré
1ca17ea63d delegation: add decode function with an io.Reader 2024-09-17 16:51:26 +02:00
Steve Moyer
658794041e fix(delegation): make Expiration an Option 2024-09-17 08:08:13 -04:00
Steve Moyer
f85ece49fa feat(delegation): add validation/accessors 2024-09-16 17:18:16 -04:00
Steve Moyer
da9f2e7bec chore(ucan): clean up repository root directory 2024-09-16 14:02:01 -04:00
Steve Moyer
bd1775b2f5 Merge branch 'envelope' of github.com:ucan-wg/go-ucan into envelope 2024-09-16 13:55:23 -04:00
Steve Moyer
285cb5f6e7 feat(did): add accessor to report whether this DID is a did:key 2024-09-16 13:54:18 -04:00
Steve Moyer
4c05d866f2 fix(delegation): simplify package API and restore convenient encoding 2024-09-16 13:50:11 -04:00
Michael Muré
646127abe7 policy formatting 2024-09-16 11:07:57 +02:00
Steve Moyer
7cead1bf8d chore(envelope): refactor to decode token.Token to schema.TypedNode 2024-09-13 12:01:51 -04:00
Steve Moyer
16f0f38b43 feat(token): add generator for Go types included in schema 2024-09-11 09:39:32 -04:00
Steve Moyer
2f183aa6f4 docs(token): move schema comment to Go doc for package 2024-09-11 09:38:13 -04:00
Steve Moyer
6d1b7ee01f feat(envelope): no longer generic - handles only token.Token 2024-09-11 09:07:24 -04:00
Steve Moyer
599c5d30b0 feat(token) combined TokenPayload model for both Delegation and Invocation tokens 2024-09-11 08:50:24 -04:00
Steve Moyer
1c2f602f4d feat(did): add ToPubKey() and improve crypto tests 2024-09-11 07:12:54 -04:00
Steve Moyer
8441f99d5d fix(delegation): redelete view.go 2024-09-09 09:44:35 -04:00
Steve Moyer
1525aaa139 chore: fix module imports 2024-09-09 09:27:04 -04:00
Steve Moyer
ab4018d218 chore(v1): merge in other changes 2024-09-09 09:09:17 -04:00
Steve Moyer
39987eadaa feat(varsig): not specification compliant precalculated headers' 2024-09-09 08:56:33 -04:00
Steve Moyer
b77f8d6bb0 feat(ucan): functions to issue/delegate UCAN tokens 2024-09-09 08:55:14 -04:00
Steve Moyer
719837e3cd feat(envelope): wrap a Tokener in an Envelope 2024-09-09 08:52:57 -04:00
Steve Moyer
2205d5d4ce feat(did): add to/from public key 2024-09-09 08:50:15 -04:00
Steve Moyer
3a542ecc85 feat(delegation): use bindnode with typed prototype 2024-09-09 08:49:35 -04:00
Steve Moyer
ba1c45c088 chore(envelope): add codec/crypto dependencies 2024-09-04 08:55:32 -04:00
48 changed files with 1608 additions and 2316 deletions

View File

@@ -20,10 +20,11 @@ type Command struct {
segments []string
}
// New creates a validated command from the provided list of segment strings.
// An error is returned if an invalid Command would be formed
func New(segments ...string) Command {
return Command{segments: segments}
// New creates a validated command from the provided list of segment
// strings. An error is returned if an invalid Command would be
// formed
func New(segments ...string) *Command {
return &Command{segments: segments}
}
// Parse verifies that the provided string contains the required
@@ -31,26 +32,26 @@ func New(segments ...string) Command {
// Command.
//
// [segment structure]: https://github.com/ucan-wg/spec#segment-structure
func Parse(s string) (Command, error) {
func Parse(s string) (*Command, error) {
if !strings.HasPrefix(s, "/") {
return Command{}, ErrRequiresLeadingSlash
return nil, ErrRequiresLeadingSlash
}
if len(s) > 1 && strings.HasSuffix(s, "/") {
return Command{}, ErrDisallowsTrailingSlash
return nil, ErrDisallowsTrailingSlash
}
if s != strings.ToLower(s) {
return Command{}, ErrRequiresLowercase
return nil, ErrRequiresLowercase
}
// The leading slash will result in the first element from strings.Split
// being an empty string which is removed as strings.Join will ignore it.
return Command{strings.Split(s, "/")[1:]}, nil
return &Command{strings.Split(s, "/")[1:]}, nil
}
// MustParse is the same as Parse, but panic() if the parsing fail.
func MustParse(s string) Command {
func MustParse(s string) *Command {
c, err := Parse(s)
if err != nil {
panic(err)
@@ -61,10 +62,11 @@ func MustParse(s string) Command {
// [Top] is the most powerful capability.
//
// This function returns a Command that is a wildcard and therefore represents the
// most powerful ability. As such, it should be handled with care and used sparingly.
// most powerful abilily. As such it should be handle with care and used
// sparingly.
//
// [Top]: https://github.com/ucan-wg/spec#-aka-top
func Top() Command {
func Top() *Command {
return New()
}
@@ -76,18 +78,18 @@ func IsValid(s string) bool {
// Join appends segments to the end of this command using the required
// segment separator.
func (c Command) Join(segments ...string) Command {
return Command{append(c.segments, segments...)}
func (c *Command) Join(segments ...string) *Command {
return &Command{append(c.segments, segments...)}
}
// Segments returns the ordered segments that comprise the Command as a
// slice of strings.
func (c Command) Segments() []string {
func (c *Command) Segments() []string {
return c.segments
}
// String returns the composed representation the command. This is also
// the required wire representation (before IPLD encoding occurs.)
func (c Command) String() string {
func (c *Command) String() string {
return "/" + strings.Join(c.segments, "/")
}

View File

@@ -74,7 +74,7 @@ func TestParseCommand(t *testing.T) {
cmd, err := command.Parse(testcase.inp)
require.ErrorIs(t, err, testcase.err)
require.Equal(t, command.Command{}, cmd)
require.Nil(t, cmd)
})
}
})

View File

@@ -1,79 +0,0 @@
package policy
import "fmt"
type glob string
// parseGlob ensures that the pattern conforms to the spec: only '*' and escaped '\*' are allowed.
func parseGlob(pattern string) (glob, error) {
for i := 0; i < len(pattern); i++ {
if pattern[i] == '*' {
continue
}
if pattern[i] == '\\' && i+1 < len(pattern) && pattern[i+1] == '*' {
i++ // skip the escaped '*'
continue
}
if pattern[i] == '\\' && i+1 < len(pattern) {
i++ // skip the escaped character
continue
}
if pattern[i] == '\\' {
return "", fmt.Errorf("invalid escape sequence")
}
}
return glob(pattern), nil
}
func mustParseGlob(pattern string) glob {
g, err := parseGlob(pattern)
if err != nil {
panic(err)
}
return g
}
// Match matches a string against the glob pattern with * wildcards, handling escaped '\*' literals.
func (pattern glob) Match(str string) bool {
// i is the index for the pattern
// j is the index for the string
var i, j int
// starIdx keeps track of the position of the last * in the pattern.
// matchIdx keeps track of the position in the string where the last * matched.
var starIdx, matchIdx int = -1, -1
for j < len(str) {
if i < len(pattern) && (pattern[i] == str[j] || pattern[i] == '\\' && i+1 < len(pattern) && pattern[i+1] == str[j]) {
// characters match or if there's an escaped character that matches
if pattern[i] == '\\' {
// skip the escape character
i++
}
i++
j++
} else if i < len(pattern) && pattern[i] == '*' {
// there's a * wildcard in the pattern
starIdx = i
matchIdx = j
i++
} else if starIdx != -1 {
// there's a previous * wildcard, backtrack
i = starIdx + 1
matchIdx++
j = matchIdx
} else {
// no match found
return false
}
}
// check for remaining characters in the pattern
for i < len(pattern) && pattern[i] == '*' {
i++
}
// the entire pattern is processed, it's a match
return i == len(pattern)
}

View File

@@ -1,73 +0,0 @@
package policy
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestSimpleGlobMatch(t *testing.T) {
tests := []struct {
pattern string
str string
matches bool
}{
// Basic matching
{"*", "anything", true},
{"a*", "abc", true},
{"*c", "abc", true},
{"a*c", "abc", true},
{"a*c", "abxc", true},
{"a*c", "ac", true},
{"a*c", "a", false},
{"a*c", "ab", false},
// Escaped characters
{"a\\*c", "a*c", true},
{"a\\*c", "abc", false},
// Mixed wildcards and literals
{"a*b*c", "abc", true},
{"a*b*c", "aXbYc", true},
{"a*b*c", "aXbY", false},
{"a*b*c", "abYc", true},
{"a*b*c", "aXbc", true},
{"a*b*c", "aXbYcZ", false},
// Edge cases
{"", "", true},
{"", "a", false},
{"*", "", true},
{"*", "a", true},
{"\\*", "*", true},
{"\\*", "a", false},
// Specified test cases
{"Alice\\*, Bob*, Carol.", "Alice*, Bob, Carol.", true},
{"Alice\\*, Bob*, Carol.", "Alice*, Bob, Dan, Erin, Carol.", true},
{"Alice\\*, Bob*, Carol.", "Alice*, Bob , Carol.", true},
{"Alice\\*, Bob*, Carol.", "Alice*, Bob*, Carol.", true},
{"Alice\\*, Bob*, Carol.", "Alice*, Bob, Carol", false},
{"Alice\\*, Bob*, Carol.", "Alice*, Bob*, Carol!", false},
{"Alice\\*, Bob*, Carol.", "Alice, Bob, Carol.", false},
{"Alice\\*, Bob*, Carol.", "Alice Cooper, Bob, Carol.", false},
{"Alice\\*, Bob*, Carol.", " Alice*, Bob, Carol. ", false},
}
for _, tt := range tests {
t.Run(tt.pattern+"_"+tt.str, func(t *testing.T) {
g, err := parseGlob(tt.pattern)
require.NoError(t, err)
require.Equal(t, tt.matches, g.Match(tt.str))
})
}
}
func BenchmarkGlob(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
g := mustParseGlob("Alice\\*, Bob*, Carol.")
g.Match("Alice*, Bob*, Carol!")
}
}

View File

@@ -232,7 +232,7 @@ func statementToIPLD(statement Statement) (datamodel.Node, error) {
if err != nil {
return nil, err
}
err = listBuilder.AssembleValue().AssignString(string(statement.pattern))
err = listBuilder.AssembleValue().AssignString(statement.pattern)
if err != nil {
return nil, err
}

View File

@@ -16,9 +16,7 @@ func TestIpldRoundTrip(t *testing.T) {
["any", ".tags",
["or", [
["==", ".", "news"],
["==", ".", "press"]
]]
]
["==", ".", "press"]]]
]`
for _, tc := range []struct {

View File

@@ -26,22 +26,11 @@ func matchStatement(statement Statement, node ipld.Node) bool {
switch statement.Kind() {
case KindEqual:
if s, ok := statement.(equality); ok {
one, many, err := selector.Select(s.selector, node)
if err != nil {
one, _, err := selector.Select(s.selector, node)
if err != nil || one == nil {
return false
}
if one != nil {
return datamodel.DeepEqual(s.value, one)
}
if many != nil {
for _, n := range many {
if eq := datamodel.DeepEqual(s.value, n); eq {
return true
}
}
}
return false
return datamodel.DeepEqual(s.value, one)
}
case KindGreaterThan:
if s, ok := statement.(equality); ok {
@@ -112,7 +101,7 @@ func matchStatement(statement Statement, node ipld.Node) bool {
if err != nil {
return false
}
return s.pattern.Match(v)
return s.glob.Match(v)
}
case KindAll:
if s, ok := statement.(quantifier); ok {
@@ -130,25 +119,17 @@ func matchStatement(statement Statement, node ipld.Node) bool {
}
case KindAny:
if s, ok := statement.(quantifier); ok {
one, many, err := selector.Select(s.selector, node)
if err != nil {
// FIXME: line below return a single node, not many
_, many, err := selector.Select(s.selector, node)
if err != nil || many == nil {
return false
}
if one != nil {
ok := matchStatement(s.statement, one)
for _, n := range many {
ok := matchStatement(s.statement, n)
if ok {
return true
}
}
if many != nil {
for _, n := range many {
ok := matchStatement(s.statement, n)
if ok {
return true
}
}
}
return false
}
}

View File

@@ -490,52 +490,3 @@ func TestPolicyExamples(t *testing.T) {
require.True(t, evaluate(`["any", ".a", ["==", ".b", 2]]`, data))
})
}
func FuzzMatch(f *testing.F) {
// Policy + Data examples
f.Add([]byte(`[["==", ".status", "draft"]]`), []byte(`{"status": "draft"}`))
f.Add([]byte(`[["all", ".reviewer", ["like", ".email", "*@example.com"]]]`), []byte(`{"reviewer": [{"email": "alice@example.com"}, {"email": "bob@example.com"}]}`))
f.Add([]byte(`[["any", ".tags", ["or", [["==", ".", "news"], ["==", ".", "press"]]]]]`), []byte(`{"tags": ["news", "press"]}`))
f.Add([]byte(`[["==", ".name", "Alice"]]`), []byte(`{"name": "Alice"}`))
f.Add([]byte(`[[">", ".age", 30]]`), []byte(`{"age": 31}`))
f.Add([]byte(`[["<=", ".height", 180]]`), []byte(`{"height": 170}`))
f.Add([]byte(`[["not", ["==", ".status", "inactive"]]]`), []byte(`{"status": "active"}`))
f.Add([]byte(`[["and", [["==", ".role", "admin"], [">=", ".experience", 5]]]]`), []byte(`{"role": "admin", "experience": 6}`))
f.Add([]byte(`[["or", [["==", ".department", "HR"], ["==", ".department", "Finance"]]]]`), []byte(`{"department": "HR"}`))
f.Add([]byte(`[["like", ".email", "*@company.com"]]`), []byte(`{"email": "user@company.com"}`))
f.Add([]byte(`[["all", ".projects", [">", ".budget", 10000]]]`), []byte(`{"projects": [{"budget": 15000}, {"budget": 8000}]}`))
f.Add([]byte(`[["any", ".skills", ["==", ".", "Go"]]]`), []byte(`{"skills": ["Go", "Python", "JavaScript"]}`))
f.Add(
[]byte(`[["and", [
["==", ".name", "Bob"],
["or", [[">", ".age", 25],["==", ".status", "active"]]],
["all", ".tasks", ["==", ".completed", true]]
]]]`),
[]byte(`{
"name": "Bob",
"age": 26,
"status": "active",
"tasks": [{"completed": true}, {"completed": true}, {"completed": false}]
}`),
)
f.Fuzz(func(t *testing.T, policyBytes []byte, dataBytes []byte) {
policyNode, err := ipld.Decode(policyBytes, dagjson.Decode)
if err != nil {
t.Skip()
}
dataNode, err := ipld.Decode(dataBytes, dagjson.Decode)
if err != nil {
t.Skip()
}
// policy node -> policy object
policy, err := FromIPLD(policyNode)
if err != nil {
t.Skip()
}
Match(policy, dataNode)
})
}

View File

@@ -3,6 +3,7 @@ package policy
// https://github.com/ucan-wg/delegation/blob/4094d5878b58f5d35055a3b93fccda0b8329ebae/README.md#policy
import (
"github.com/gobwas/glob"
"github.com/ipld/go-ipld-prime"
"github.com/ucan-wg/go-ucan/capability/policy/selector"
@@ -89,7 +90,8 @@ func Or(stmts ...Statement) Statement {
type wildcard struct {
selector selector.Selector
pattern glob
pattern string
glob glob.Glob // not serialized
}
func (n wildcard) Kind() string {
@@ -97,12 +99,11 @@ func (n wildcard) Kind() string {
}
func Like(selector selector.Selector, pattern string) (Statement, error) {
g, err := parseGlob(pattern)
g, err := glob.Compile(pattern)
if err != nil {
return nil, err
}
return wildcard{selector: selector, pattern: g}, nil
return wildcard{selector: selector, pattern: pattern, glob: g}, nil
}
type quantifier struct {

View File

@@ -7,7 +7,6 @@ import (
"github.com/ipld/go-ipld-prime"
"github.com/ipld/go-ipld-prime/datamodel"
"github.com/ipld/go-ipld-prime/node/basicnode"
"github.com/ipld/go-ipld-prime/schema"
)
@@ -87,292 +86,126 @@ func resolve(sel Selector, subject ipld.Node, at []string) (ipld.Node, []ipld.No
for i, seg := range sel {
if seg.Identity() {
continue
}
// 1st level: handle the different segment types (iterator, field, slice, index)
// 2nd level: handle different node kinds (list, map, string, bytes)
switch {
case seg.Iterator():
if cur == nil || cur.Kind() == datamodel.Kind_Null {
if seg.Optional() {
// build empty list
nb := basicnode.Prototype.List.NewBuilder()
assembler, err := nb.BeginList(0)
if err != nil {
return nil, nil, err
}
if err = assembler.Finish(); err != nil {
return nil, nil, err
}
return nb.Build(), nil, nil
} else {
return nil, nil, newResolutionError(fmt.Sprintf("can not iterate over kind: %s", kindString(cur)), at)
}
} else {
} else if seg.Iterator() {
if cur != nil && cur.Kind() == datamodel.Kind_List {
var many []ipld.Node
switch cur.Kind() {
case datamodel.Kind_List:
it := cur.ListIterator()
for !it.Done() {
_, v, err := it.Next()
if err != nil {
return nil, nil, err
}
// check if there are more iterator segments
if len(sel) > i+1 && sel[i+1].Iterator() {
if v.Kind() == datamodel.Kind_List {
// recursively resolve the remaining selector segments
var o ipld.Node
var m []ipld.Node
o, m, err = resolve(sel[i+1:], v, at)
if err != nil {
// if the segment is optional and an error occurs, skip the current iteration.
if seg.Optional() {
continue
} else {
return nil, nil, err
}
}
if m != nil {
many = append(many, m...)
} else if o != nil {
many = append(many, o)
}
} else {
// if the current value is not a list and the next segment is optional, skip the current iteration
if sel[i+1].Optional() {
continue
} else {
return nil, nil, newResolutionError(fmt.Sprintf("can not iterate over kind: %s", kindString(v)), at)
}
}
} else {
// if there are no more iterator segments, append the current value to the result
many = append(many, v)
}
it := cur.ListIterator()
for {
if it.Done() {
break
}
case datamodel.Kind_Map:
it := cur.MapIterator()
for !it.Done() {
_, v, err := it.Next()
if err != nil {
return nil, nil, err
}
if len(sel) > i+1 && sel[i+1].Iterator() {
if v.Kind() == datamodel.Kind_List {
var o ipld.Node
var m []ipld.Node
o, m, err = resolve(sel[i+1:], v, at)
if err != nil {
if seg.Optional() {
continue
} else {
return nil, nil, err
}
}
if m != nil {
many = append(many, m...)
} else if o != nil {
many = append(many, o)
}
} else {
if sel[i+1].Optional() {
continue
} else {
return nil, nil, newResolutionError(fmt.Sprintf("can not iterate over kind: %s", kindString(v)), at)
}
}
} else {
many = append(many, v)
}
}
default:
return nil, nil, newResolutionError(fmt.Sprintf("can not iterate over kind: %s", kindString(cur)), at)
}
return nil, many, nil
}
case seg.Field() != "":
at = append(at, seg.Field())
if cur == nil {
if seg.Optional() {
cur = nil
} else {
return nil, nil, newResolutionError(fmt.Sprintf("can not access field: %s on kind: %s", seg.Field(), kindString(cur)), at)
}
} else {
switch cur.Kind() {
case datamodel.Kind_Map:
n, err := cur.LookupByString(seg.Field())
k, v, err := it.Next()
if err != nil {
if isMissing(err) {
if seg.Optional() {
cur = nil
} else {
return nil, nil, newResolutionError(fmt.Sprintf("object has no field named: %s", seg.Field()), at)
}
} else {
return nil, nil, err
}
} else {
cur = n
return nil, nil, err
}
case datamodel.Kind_List:
var many []ipld.Node
it := cur.ListIterator()
for !it.Done() {
_, v, err := it.Next()
if err != nil {
return nil, nil, err
}
if v.Kind() == datamodel.Kind_Map {
n, err := v.LookupByString(seg.Field())
if err == nil {
many = append(many, n)
}
}
}
if len(many) > 0 {
cur = nil
return nil, many, nil
} else if seg.Optional() {
cur = nil
} else {
return nil, nil, newResolutionError(fmt.Sprintf("no elements in list have field named: %s", seg.Field()), at)
}
default:
if seg.Optional() {
cur = nil
} else {
return nil, nil, newResolutionError(fmt.Sprintf("can not access field: %s on kind: %s", seg.Field(), kindString(cur)), at)
}
}
}
case seg.Slice() != nil:
if cur == nil {
if seg.Optional() {
cur = nil
} else {
return nil, nil, newResolutionError(fmt.Sprintf("can not slice on kind: %s", kindString(cur)), at)
key := fmt.Sprintf("%d", k)
o, m, err := resolve(sel[i+1:], v, append(at[:], key))
if err != nil {
return nil, nil, err
}
if m != nil {
many = append(many, m...)
} else {
many = append(many, o)
}
}
return nil, many, nil
} else if cur != nil && cur.Kind() == datamodel.Kind_Map {
var many []ipld.Node
it := cur.MapIterator()
for {
if it.Done() {
break
}
k, v, err := it.Next()
if err != nil {
return nil, nil, err
}
key, _ := k.AsString()
o, m, err := resolve(sel[i+1:], v, append(at[:], key))
if err != nil {
return nil, nil, err
}
if m != nil {
many = append(many, m...)
} else {
many = append(many, o)
}
}
return nil, many, nil
} else if seg.Optional() {
cur = nil
} else {
slice := seg.Slice()
var start, end, length int64
switch cur.Kind() {
case datamodel.Kind_List:
length = cur.Length()
start, end = resolveSliceIndices(slice, length)
case datamodel.Kind_Bytes:
b, _ := cur.AsBytes()
length = int64(len(b))
start, end = resolveSliceIndices(slice, length)
case datamodel.Kind_String:
str, _ := cur.AsString()
length = int64(len(str))
start, end = resolveSliceIndices(slice, length)
default:
return nil, nil, newResolutionError(fmt.Sprintf("can not slice on kind: %s", kindString(cur)), at)
}
if start < 0 || end < start || end > length {
if seg.Optional() {
cur = nil
} else {
return nil, nil, newResolutionError(fmt.Sprintf("slice out of bounds: [%d:%d]", start, end), at)
}
} else {
switch cur.Kind() {
case datamodel.Kind_List:
if end > cur.Length() {
end = cur.Length()
}
nb := basicnode.Prototype.List.NewBuilder()
assembler, _ := nb.BeginList(int64(end - start))
for i := start; i < end; i++ {
item, _ := cur.LookupByIndex(int64(i))
assembler.AssembleValue().AssignNode(item)
}
assembler.Finish()
cur = nb.Build()
case datamodel.Kind_Bytes:
b, _ := cur.AsBytes()
l := int64(len(b))
if end > l {
end = l
}
cur = basicnode.NewBytes(b[start:end])
case datamodel.Kind_String:
str, _ := cur.AsString()
l := int64(len(str))
if end > l {
end = l
}
cur = basicnode.NewString(str[start:end])
}
}
return nil, nil, newResolutionError(fmt.Sprintf("can not iterate over kind: %s", kindString(cur)), at)
}
default:
} else if seg.Field() != "" {
at = append(at, seg.Field())
if cur != nil && cur.Kind() == datamodel.Kind_Map {
n, err := cur.LookupByString(seg.Field())
if err != nil {
if isMissing(err) {
if seg.Optional() {
cur = nil
} else {
return nil, nil, newResolutionError(fmt.Sprintf("object has no field named: %s", seg.Field()), at)
}
} else {
return nil, nil, err
}
}
cur = n
} else if seg.Optional() {
cur = nil
} else {
return nil, nil, newResolutionError(fmt.Sprintf("can not access field: %s on kind: %s", seg.Field(), kindString(cur)), at)
}
} else if seg.Slice() != nil {
if cur != nil && cur.Kind() == datamodel.Kind_List {
return nil, nil, newResolutionError("list slice selection not yet implemented", at)
} else if cur != nil && cur.Kind() == datamodel.Kind_Bytes {
return nil, nil, newResolutionError("bytes slice selection not yet implemented", at)
} else if seg.Optional() {
cur = nil
} else {
return nil, nil, newResolutionError(fmt.Sprintf("can not index: %s on kind: %s", seg.Field(), kindString(cur)), at)
}
} else {
at = append(at, fmt.Sprintf("%d", seg.Index()))
if cur == nil {
if seg.Optional() {
cur = nil
} else {
return nil, nil, newResolutionError(fmt.Sprintf("can not access index: %d on kind: %s", seg.Index(), kindString(cur)), at)
if cur != nil && cur.Kind() == datamodel.Kind_List {
idx := int64(seg.Index())
if idx < 0 {
idx = cur.Length() + idx
}
if idx < 0 {
// necessary until https://github.com/ipld/go-ipld-prime/pull/571
// after, isMissing() below will work
// TODO: remove
return nil, nil, newResolutionError(fmt.Sprintf("index out of bounds: %d", seg.Index()), at)
}
n, err := cur.LookupByIndex(idx)
if err != nil {
if isMissing(err) {
if seg.Optional() {
cur = nil
} else {
return nil, nil, newResolutionError(fmt.Sprintf("index out of bounds: %d", seg.Index()), at)
}
} else {
return nil, nil, err
}
}
cur = n
} else if seg.Optional() {
cur = nil
} else {
idx := seg.Index()
switch cur.Kind() {
case datamodel.Kind_List:
if idx < 0 {
idx = int(cur.Length()) + idx
}
if idx < 0 || idx >= int(cur.Length()) {
if seg.Optional() {
cur = nil
} else {
return nil, nil, newResolutionError(fmt.Sprintf("index out of bounds: %d", seg.Index()), at)
}
} else {
cur, _ = cur.LookupByIndex(int64(idx))
}
case datamodel.Kind_String:
str, _ := cur.AsString()
if idx < 0 {
idx = len(str) + idx
}
if idx < 0 || idx >= len(str) {
if seg.Optional() {
cur = nil
} else {
return nil, nil, newResolutionError(fmt.Sprintf("index out of bounds: %d", seg.Index()), at)
}
} else {
cur = basicnode.NewString(string(str[idx]))
}
case datamodel.Kind_Bytes:
b, _ := cur.AsBytes()
if idx < 0 {
idx = len(b) + idx
}
if idx < 0 || idx >= len(b) {
if seg.Optional() {
cur = nil
} else {
return nil, nil, newResolutionError(fmt.Sprintf("index out of bounds: %d", seg.Index()), at)
}
} else {
cur = basicnode.NewInt(int64(b[idx]))
}
default:
return nil, nil, newResolutionError(fmt.Sprintf("can not access index: %d on kind: %s", seg.Index(), kindString(cur)), at)
}
return nil, nil, newResolutionError(fmt.Sprintf("can not access field: %s on kind: %s", seg.Field(), kindString(cur)), at)
}
}
}
@@ -380,42 +213,6 @@ func resolve(sel Selector, subject ipld.Node, at []string) (ipld.Node, []ipld.No
return cur, nil, nil
}
// resolveSliceIndices resolves the start and end indices for slicing a list or byte array.
//
// It takes the slice indices from the selector segment and the length of the list or byte array,
// and returns the resolved start and end indices. Negative indices are supported.
//
// Parameters:
// - slice: The slice indices from the selector segment.
// - length: The length of the list or byte array being sliced.
//
// Returns:
// - start: The resolved start index for slicing.
// - end: The resolved end index for slicing.
func resolveSliceIndices(slice []int, length int64) (int64, int64) {
start, end := int64(0), length
if len(slice) > 0 {
start = int64(slice[0])
if start < 0 {
start = length + start
if start < 0 {
start = 0
}
}
}
if len(slice) > 1 {
end = int64(slice[1])
if end <= 0 {
end = length + end
if end < start {
end = start
}
}
}
return start, end
}
func kindString(n datamodel.Node) string {
if n == nil {
return "null"

View File

@@ -30,14 +30,14 @@ func TestSupportedForms(t *testing.T) {
for _, testcase := range []Testcase{
{Name: "Identity", Selector: `.`, Input: `{"x":1}`, Output: `{"x":1}`},
{Name: "Iterator", Selector: `.[]`, Input: `[1, 2]`, Output: `[1, 2]`},
{Name: "Optional Null Iterator", Selector: `.[]?`, Input: `null`, Output: `[]`},
{Name: "Optional Null Iterator", Selector: `.[]?`, Input: `null`, Output: `()`},
{Name: "Optional Iterator", Selector: `.[][]?`, Input: `[[1], 2, [3]]`, Output: `[1, 3]`},
{Name: "Object Key", Selector: `.x`, Input: `{"x": 1 }`, Output: `1`},
{Name: "Quoted Key", Selector: `.["x"]`, Input: `{"x": 1}`, Output: `1`},
{Name: "Index", Selector: `.[0]`, Input: `[1, 2]`, Output: `1`},
{Name: "Negative Index", Selector: `.[-1]`, Input: `[1, 2]`, Output: `2`},
{Name: "String Index", Selector: `.[0]`, Input: `"Hi"`, Output: `"H"`},
{Name: "Bytes Index", Selector: `.[0]`, Input: `{"/":{"bytes":"AAE"}}`, Output: `0`},
{Name: "Bytes Index", Selector: `.[0]`, Input: `{"/":{"bytes":"AAE"}`, Output: `0`},
{Name: "Array Slice", Selector: `.[0:2]`, Input: `[0, 1, 2]`, Output: `[0, 1]`},
{Name: "Array Slice", Selector: `.[1:]`, Input: `[0, 1, 2]`, Output: `[1, 2]`},
{Name: "Array Slice", Selector: `.[:2]`, Input: `[0, 1, 2]`, Output: `[0, 1]`},

View File

@@ -0,0 +1,46 @@
package delegation
// Code generated by github.com/selesy/go-options. DO NOT EDIT.
import (
"github.com/ipld/go-ipld-prime/datamodel"
"time"
)
type Option func(c *config) error
func newConfig(options ...Option) (config, error) {
var c config
err := applyConfigOptions(&c, options...)
return c, err
}
func applyConfigOptions(c *config, options ...Option) error {
for _, o := range options {
if err := o(c); err != nil {
return err
}
}
return nil
}
func WithExpiration(o *time.Time) Option {
return func(c *config) error {
c.Expiration = o
return nil
}
}
func WithMeta(o map[string]datamodel.Node) Option {
return func(c *config) error {
c.Meta = o
return nil
}
}
func WithNotBefore(o *time.Time) Option {
return func(c *config) error {
c.NotBefore = o
return nil
}
}

View File

@@ -1,312 +1,225 @@
// Package delegation implements the UCAN [delegation] specification with
// an immutable Token type as well as methods to convert the Token to and
// from the [envelope]-enclosed, signed and DAG-CBOR-encoded form that
// should most commonly be used for transport and storage.
//
// [delegation]: https://github.com/ucan-wg/delegation/tree/v1_ipld
// [envelope]: https://github.com/ucan-wg/spec#envelope
package delegation
// TODO: change the "delegation" link above when the specification is merged
import (
"crypto/rand"
"errors"
"fmt"
"time"
"github.com/ipfs/go-cid"
"github.com/ipld/go-ipld-prime/datamodel"
"github.com/libp2p/go-libp2p/core/crypto"
"github.com/ucan-wg/go-ucan/capability/command"
"github.com/ucan-wg/go-ucan/capability/policy"
"github.com/ucan-wg/go-ucan/did"
"github.com/ucan-wg/go-ucan/pkg/meta"
"github.com/ucan-wg/go-ucan/internal/envelope"
"github.com/ucan-wg/go-ucan/internal/token"
)
// Token is an immutable type that holds the fields of a UCAN delegation.
type Token struct {
// Issuer DID (sender)
issuer did.DID
// Audience DID (receiver)
audience did.DID
// Principal that the chain is about (the Subject)
subject did.DID
// The Command to eventually invoke
command command.Command
// The delegation policy
policy policy.Policy
// A unique, random nonce
nonce []byte
// Arbitrary Metadata
meta *meta.Meta
// "Not before" UTC Unix Timestamp in seconds (valid from), 53-bits integer
notBefore *time.Time
// The timestamp at which the Invocation becomes invalid
expiration *time.Time
// The CID of the Token when enclosed in an Envelope and encoded to DAG-CBOR
cid cid.Cid
const (
Tag = "ucan/dlg@1.0.0-rc.1"
)
type Delegation struct {
envel *envelope.Envelope
}
// New creates a validated Token from the provided parameters and options.
func New(privKey crypto.PrivKey, aud did.DID, cmd command.Command, pol policy.Policy, opts ...Option) (*Token, error) {
iss, err := did.FromPrivKey(privKey)
//go:generate -command options go run github.com/selesy/go-options
//go:generate options -type=config -prefix=With -output=delegatiom_options.go -cmp=false -stringer=false -imports=time,github.com/ipld/go-ipld-prime/datamodel
type config struct {
Expiration *time.Time
Meta map[string]datamodel.Node
NotBefore *time.Time
}
func New(privKey crypto.PrivKey, aud did.DID, sub *did.DID, cmd *command.Command, pol policy.Policy, nonce []byte, opts ...Option) (*Delegation, error) {
cfg, err := newConfig(opts...)
if err != nil {
return nil, err
}
tkn := &Token{
issuer: iss,
audience: aud,
subject: did.Undef,
command: cmd,
policy: pol,
meta: meta.NewMeta(),
nonce: nil,
cid: cid.Undef,
}
for _, opt := range opts {
if err := opt(tkn); err != nil {
return nil, err
}
}
if len(tkn.nonce) == 0 {
tkn.nonce, err = generateNonce()
if err != nil {
return nil, err
}
}
if err := tkn.validate(); err != nil {
issuer, err := did.FromPrivKey(privKey)
if err != nil {
return nil, err
}
return tkn, nil
if !aud.Defined() {
return nil, fmt.Errorf("%w: %s", token.ErrMissingRequiredDID, "aud")
}
audience := aud.String()
var subject *string
if sub != nil {
s := sub.String()
subject = &s
}
policy, err := pol.ToIPLD()
if err != nil {
return nil, err
}
var meta *token.Map__String__Any
if len(cfg.Meta) > 0 {
m := token.ToIPLDMapStringAny(cfg.Meta)
meta = &m
}
var notBefore *int
if cfg.NotBefore != nil {
n := int(cfg.NotBefore.Unix())
notBefore = &n
}
var expiration *int
if cfg.Expiration != nil {
e := int(cfg.Expiration.Unix())
expiration = &e
}
tkn := &token.Token{
Issuer: issuer.String(),
Audience: &audience,
Subject: subject,
Command: cmd.String(),
Policy: &policy,
Nonce: &nonce,
Meta: meta,
NotBefore: notBefore,
Expiration: expiration,
}
envel, err := envelope.New(privKey, tkn, Tag)
if err != nil {
return nil, err
}
dlg := &Delegation{envel: envel}
if err := dlg.Validate(); err != nil {
return nil, err
}
return dlg, nil
}
func Root(privKey crypto.PrivKey, aud did.DID, cmd command.Command, pol policy.Policy, opts ...Option) (*Token, error) {
func Root(privKey crypto.PrivKey, aud did.DID, cmd *command.Command, pol policy.Policy, nonce []byte, opts ...Option) (*Delegation, error) {
sub, err := did.FromPrivKey(privKey)
if err != nil {
return nil, err
}
opts = append(opts, WithSubject(sub))
return New(privKey, aud, cmd, pol, opts...)
return New(privKey, aud, &sub, cmd, pol, nonce, opts...)
}
// Issuer returns the did.DID representing the Token's issuer.
func (t *Token) Issuer() did.DID {
return t.issuer
func (d *Delegation) Audience() did.DID {
id, _ := did.Parse(*d.envel.TokenPayload().Audience)
return id
}
// Audience returns the did.DID representing the Token's audience.
func (t *Token) Audience() did.DID {
return t.audience
func (d *Delegation) Command() *command.Command {
cmd, _ := command.Parse(d.envel.TokenPayload().Command)
return cmd
}
// Subject returns the did.DID representing the Token's subject.
//
// This field may be did.Undef for delegations that are [Powerlined] but
// must be equal to the value returned by the Issuer method for root
// tokens.
func (t *Token) Subject() did.DID {
return t.subject
func (d *Delegation) IsPowerline() bool {
return d.envel.TokenPayload().Subject == nil
}
// Command returns the capability's command.Command.
func (t *Token) Command() command.Command {
return t.command
func (d *Delegation) IsRoot() bool {
return &d.envel.TokenPayload().Issuer == d.envel.TokenPayload().Subject
}
// Policy returns the capability's policy.Policy.
func (t *Token) Policy() policy.Policy {
return t.policy
func (d *Delegation) Issuer() did.DID {
id, _ := did.Parse(d.envel.TokenPayload().Issuer)
return id
}
// Nonce returns the random Nonce encapsulated in this Token.
func (t *Token) Nonce() []byte {
return t.nonce
func (d *Delegation) Meta() map[string]datamodel.Node {
return d.envel.TokenPayload().Meta.Values
}
// Meta returns the Token's metadata.
func (t *Token) Meta() *meta.Meta {
return t.meta
func (d *Delegation) Nonce() []byte {
return *d.envel.TokenPayload().Nonce
}
// NotBefore returns the time at which the Token becomes "active".
func (t *Token) NotBefore() *time.Time {
return t.notBefore
func (d *Delegation) Policy() policy.Policy {
pol, _ := policy.FromIPLD(*d.envel.TokenPayload().Policy)
return pol
}
// Expiration returns the time at which the Token expires.
func (t *Token) Expiration() *time.Time {
return t.expiration
}
// CID returns the content identifier of the Token model when enclosed
// in an Envelope and encoded to DAG-CBOR.
// Returns cid.Undef if the token has not been serialized or deserialized yet.
func (t *Token) CID() cid.Cid {
return t.cid
}
func (t *Token) validate() error {
var errs error
requiredDID := func(id did.DID, fieldname string) {
if !id.Defined() {
errs = errors.Join(errs, fmt.Errorf(`a valid did is required for %s: %s`, fieldname, id.String()))
}
}
requiredDID(t.issuer, "Issuer")
requiredDID(t.audience, "Audience")
if len(t.nonce) < 12 {
errs = errors.Join(errs, fmt.Errorf("token nonce too small"))
}
return errs
}
// Option is a type that allows optional fields to be set during the
// creation of a Token.
type Option func(*Token) error
// WithExpiration set's the Token's optional "expiration" field to the
// value of the provided time.Time.
func WithExpiration(exp time.Time) Option {
return func(t *Token) error {
if exp.Before(time.Now()) {
return fmt.Errorf("a Token's expiration should be set to a time in the future: %s", exp.String())
}
t.expiration = &exp
func (d *Delegation) Subject() *did.DID {
if d.envel.TokenPayload().Subject == nil {
return nil
}
id, _ := did.Parse(*d.envel.TokenPayload().Subject)
return &id
}
// WithMeta adds a key/value pair in the "meta" field.
//
// WithMeta can be used multiple times in the same call.
// Accepted types for the value are: bool, string, int, int32, int64, []byte,
// and ipld.Node.
func WithMeta(key string, val any) Option {
return func(t *Token) error {
return t.meta.Add(key, val)
}
}
// WithNotBefore set's the Token's optional "notBefore" field to the value
// of the provided time.Time.
func WithNotBefore(nbf time.Time) Option {
return func(t *Token) error {
if nbf.Before(time.Now()) {
return fmt.Errorf("a Token's \"not before\" field should be set to a time in the future: %s", nbf.String())
}
t.notBefore = &nbf
return nil
}
}
// WithSubject sets the Tokens's optional "subject" field to the value of
// provided did.DID.
//
// This Option should only be used with the New constructor - since
// Subject is a required parameter when creating a Token via the Root
// constructor, any value provided via this Option will be silently
// overwritten.
func WithSubject(sub did.DID) Option {
return func(t *Token) error {
t.subject = sub
return nil
}
}
// WithNonce sets the Token's nonce with the given value.
// If this option is not used, a random 12-byte nonce is generated for this required field.
func WithNonce(nonce []byte) Option {
return func(t *Token) error {
t.nonce = nonce
return nil
}
}
// tokenFromModel build a decoded view of the raw IPLD data.
// This function also serves as validation.
func tokenFromModel(m tokenPayloadModel) (*Token, error) {
var (
tkn Token
err error
func (d *Delegation) Validate() error {
return errors.Join(
d.validateDID("iss", &d.envel.TokenPayload().Issuer, false),
d.validateDID("aud", d.envel.TokenPayload().Audience, false),
d.validateDID("sub", d.envel.TokenPayload().Subject, true),
d.validateCommand(),
d.validatePolicy(),
d.validateNonce(),
)
}
tkn.issuer, err = did.Parse(m.Iss)
func (d *Delegation) validateCommand() error {
_, err := command.Parse(d.envel.TokenPayload().Command)
return err
}
func (d *Delegation) validateDID(fieldName string, identity *string, nullableOrOptional bool) error {
if identity == nil && !nullableOrOptional {
return fmt.Errorf("a required DID is missing: %s", fieldName)
}
id, err := did.Parse(*identity)
if err != nil {
return nil, fmt.Errorf("parse iss: %w", err)
}
tkn.audience, err = did.Parse(m.Aud)
if err != nil {
return nil, fmt.Errorf("parse audience: %w", err)
if !id.Defined() && !id.Key() {
return fmt.Errorf("a required DID is missing: %s", fieldName)
}
if m.Sub != nil {
tkn.subject, err = did.Parse(*m.Sub)
if err != nil {
return nil, fmt.Errorf("parse subject: %w", err)
}
} else {
tkn.subject = did.Undef
return nil
}
func (d *Delegation) validateNonce() error {
if d.envel.TokenPayload().Nonce == nil || len(*d.envel.TokenPayload().Nonce) < 1 {
return fmt.Errorf("nonce is required: must not be nil or empty")
}
tkn.command, err = command.Parse(m.Cmd)
if err != nil {
return nil, fmt.Errorf("parse command: %w", err)
return nil
}
func (d *Delegation) validatePolicy() error {
if d.envel.TokenPayload().Policy == nil {
return fmt.Errorf("the \"pol\" field is required")
}
tkn.policy, err = policy.FromIPLD(m.Pol)
if err != nil {
return nil, fmt.Errorf("parse policy: %w", err)
}
_, err := policy.FromIPLD(*d.envel.TokenPayload().Policy)
if len(m.Nonce) == 0 {
return nil, fmt.Errorf("nonce is required")
}
tkn.nonce = m.Nonce
return err
}
tkn.meta = &m.Meta
func Nonce() ([]byte, error) {
nonce := make([]byte, 32)
if m.Nbf != nil {
t := time.Unix(*m.Nbf, 0)
tkn.notBefore = &t
}
if m.Exp != nil {
t := time.Unix(*m.Exp, 0)
tkn.expiration = &t
}
if err := tkn.validate(); err != nil {
if _, err := rand.Read(nonce); err != nil {
return nil, err
}
return &tkn, nil
}
// generateNonce creates a 12-byte random nonce.
// TODO: some crypto scheme require more, is that our case?
func generateNonce() ([]byte, error) {
res := make([]byte, 12)
_, err := rand.Read(res)
if err != nil {
return nil, err
}
return res, nil
return nonce, nil
}

View File

@@ -1,29 +0,0 @@
type DID string
# The Delegation payload MUST describe the authorization claims, who is involved, and its validity period.
type Payload struct {
# Issuer DID (sender)
iss DID
# Audience DID (receiver)
aud DID
# Principal that the chain is about (the Subject)
sub optional DID
# The Command to eventually invoke
cmd String
# The delegation policy
# It doesn't seem possible to represent it with a schema.
pol Any
# A unique, random nonce
nonce Bytes
# Arbitrary Metadata
meta {String : Any}
# "Not before" UTC Unix Timestamp in seconds (valid from), 53-bits integer
nbf optional Int
# The timestamp at which the Invocation becomes invalid
exp nullable Int
}

View File

@@ -1,17 +1,19 @@
package delegation_test
import (
"crypto/rand"
"testing"
"time"
"github.com/ipld/go-ipld-prime/datamodel"
"github.com/ipld/go-ipld-prime/node/basicnode"
"github.com/libp2p/go-libp2p/core/crypto"
"github.com/stretchr/testify/require"
"gotest.tools/v3/golden"
"github.com/ucan-wg/go-ucan/capability/command"
"github.com/ucan-wg/go-ucan/capability/policy"
"github.com/ucan-wg/go-ucan/delegation"
"github.com/ucan-wg/go-ucan/did"
"gotest.tools/v3/golden"
)
const (
@@ -63,9 +65,6 @@ const (
]
]
`
newCID = "zdpuAn9JgGPvnt2WCmTaKktZdbuvcVGTg9bUT5kQaufwUtZ6e"
rootCID = "zdpuAkgGmUp5JrXvehGuuw9JA8DLQKDaxtK3R8brDQQVC2i5X"
)
func TestConstructors(t *testing.T) {
@@ -84,20 +83,18 @@ func TestConstructors(t *testing.T) {
pol, err := policy.FromDagJson(subjectPol)
require.NoError(t, err)
exp, err := time.Parse(time.RFC3339, "2200-01-01T00:00:00Z")
require.NoError(t, err)
exp := time.Time{}
meta := map[string]datamodel.Node{
"foo": basicnode.NewString("fooo"),
"bar": basicnode.NewString("barr"),
}
t.Run("New", func(t *testing.T) {
tkn, err := delegation.New(privKey, aud, cmd, pol,
delegation.WithNonce([]byte(nonce)),
delegation.WithSubject(sub),
delegation.WithExpiration(exp),
delegation.WithMeta("foo", "fooo"),
delegation.WithMeta("bar", "barr"),
)
dlg, err := delegation.New(privKey, aud, &sub, cmd, pol, []byte(nonce), delegation.WithExpiration(&exp), delegation.WithMeta(meta))
require.NoError(t, err)
data, err := tkn.ToDagJson(privKey)
data, err := dlg.ToDagJson()
require.NoError(t, err)
t.Log(string(data))
@@ -108,15 +105,10 @@ func TestConstructors(t *testing.T) {
t.Run("Root", func(t *testing.T) {
t.Parallel()
tkn, err := delegation.Root(privKey, aud, cmd, pol,
delegation.WithNonce([]byte(nonce)),
delegation.WithExpiration(exp),
delegation.WithMeta("foo", "fooo"),
delegation.WithMeta("bar", "barr"),
)
dlg, err := delegation.Root(privKey, aud, cmd, pol, []byte(nonce), delegation.WithExpiration(&exp), delegation.WithMeta(meta))
require.NoError(t, err)
data, err := tkn.ToDagJson(privKey)
data, err := dlg.ToDagJson()
require.NoError(t, err)
t.Log(string(data))
@@ -125,7 +117,9 @@ func TestConstructors(t *testing.T) {
})
}
func privKey(t require.TestingT, privKeyCfg string) crypto.PrivKey {
func privKey(t *testing.T, privKeyCfg string) crypto.PrivKey {
t.Helper()
privKeyMar, err := crypto.ConfigDecodeKey(privKeyCfg)
require.NoError(t, err)
@@ -134,3 +128,22 @@ func privKey(t require.TestingT, privKeyCfg string) crypto.PrivKey {
return privKey
}
func TestKey(t *testing.T) {
t.Skip()
priv, _, err := crypto.GenerateEd25519Key(rand.Reader)
require.NoError(t, err)
privMar, err := crypto.MarshalPrivateKey(priv)
require.NoError(t, err)
privCfg := crypto.ConfigEncodeKey(privMar)
t.Log(privCfg)
id, err := did.FromPubKey(priv.GetPublic())
require.NoError(t, err)
t.Log(id)
t.Fail()
}

113
delegation/encoding.go Normal file
View File

@@ -0,0 +1,113 @@
package delegation
import (
"fmt"
"io"
"github.com/ipld/go-ipld-prime"
"github.com/ipld/go-ipld-prime/codec"
"github.com/ipld/go-ipld-prime/codec/dagcbor"
"github.com/ipld/go-ipld-prime/codec/dagjson"
"github.com/ipld/go-ipld-prime/datamodel"
"github.com/ucan-wg/go-ucan/internal/envelope"
)
// Encode marshals a Delegation to the format specified by the provided
// codec.Encoder.
func (d *Delegation) Encode(encFn codec.Encoder) ([]byte, error) {
node, err := d.ToIPLD()
if err != nil {
return nil, err
}
return ipld.Encode(node, encFn)
}
// ToDagCbor marshals the Delegation to the DAG-CBOR format.
func (d *Delegation) ToDagCbor() ([]byte, error) {
return d.Encode(dagcbor.Encode)
}
// ToDagJson marshals the Delegation to the DAG-JSON format.
func (d *Delegation) ToDagJson() ([]byte, error) {
return d.Encode(dagjson.Encode)
}
// ToIPLD wraps the Delegation in an IPLD datamodel.Node.
func (d *Delegation) ToIPLD() (datamodel.Node, error) {
return d.envel.Wrap()
}
// Decode unmarshals the input data using the format specified by the
// provided codec.Decoder into a Delegation.
//
// An error is returned if the conversion fails, or if the resulting
// Delegation is invalid.
func Decode(b []byte, decFn codec.Decoder) (*Delegation, error) {
node, err := ipld.Decode(b, decFn)
if err != nil {
return nil, err
}
return FromIPLD(node)
}
// DecodeReader is the same as Decode, but accept an io.Reader.
func DecodeReader(r io.Reader, decFn codec.Decoder) (*Delegation, error) {
node, err := ipld.DecodeStreaming(r, decFn)
if err != nil {
return nil, err
}
return FromIPLD(node)
}
// FromDagCbor unmarshals the input data into a Delegation.
//
// An error is returned if the conversion fails, or if the resulting
// Delegation is invalid.
func FromDagCbor(data []byte) (*Delegation, error) {
return Decode(data, dagcbor.Decode)
}
// FromDagCborReader is the same as FromDagCbor, but accept an io.Reader.
func FromDagCborReader(r io.Reader) (*Delegation, error) {
return DecodeReader(r, dagcbor.Decode)
}
// FromDagJson unmarshals the input data into a Delegation.
//
// An error is returned if the conversion fails, or if the resulting
// Delegation is invalid.
func FromDagJson(data []byte) (*Delegation, error) {
return Decode(data, dagjson.Decode)
}
// FromDagJsonReader is the same as FromDagJson, but accept an io.Reader.
func FromDagJsonReader(r io.Reader) (*Delegation, error) {
return DecodeReader(r, dagjson.Decode)
}
// FromIPLD unwraps a Delegation from the provided IPLD datamodel.Node
//
// An error is returned if the conversion fails, or if the resulting
// Delegation is invalid.
func FromIPLD(node datamodel.Node) (*Delegation, error) {
envel, err := envelope.Unwrap(node)
if err != nil {
return nil, err
}
if envel.Tag() != Tag {
return nil, fmt.Errorf("wrong tag for TokenPayload: received %s but expected %s", envel.Tag(), Tag)
}
dlg := &Delegation{
envel: envel,
}
if err := dlg.Validate(); err != nil {
return nil, err
}
return dlg, nil
}

101
delegation/encoding_test.go Normal file
View File

@@ -0,0 +1,101 @@
package delegation_test
import (
"fmt"
"testing"
"github.com/stretchr/testify/require"
"github.com/ucan-wg/go-ucan/delegation"
)
func TestEncodingRoundTrip(t *testing.T) {
const delegationJson = `
[
{
"/": {
"bytes": "QWr0Pk+sSWE1nszuBMQzggbHX4ofJb8QRdwrLJK/AGCx2p4s/xaCRieomfstDjsV4ezBzX1HARvcoNgdwDQ8Aw"
}
},
{
"h": {
"/": {
"bytes": "NO0BcQ"
}
},
"ucan/dlg@1.0.0-rc.1": {
"aud": "did:key:z6Mkpzn2n3ZGT2VaqMGSQC3tzmzV4TS9S71iFsDXE1WnoNH2",
"cmd": "/foo/bar",
"exp": -62135596800,
"iss": "did:key:z6Mkpzn2n3ZGT2VaqMGSQC3tzmzV4TS9S71iFsDXE1WnoNH2",
"meta": {
"bar": "barr",
"foo": "fooo"
},
"nbf": -62135596800,
"nonce": {
"/": {
"bytes": "X93ORvN1QIXrKPyEP5m5XoVK9VLX9nX8VV/+HlWrp9c"
}
},
"pol": [
[
"==",
".status",
"draft"
],
[
"all",
".reviewer",
[
"like",
".email",
"*@example.com"
]
],
[
"any",
".tags",
[
"or",
[
[
"==",
".",
"news"
],
[
"==",
".",
"press"
]
]
]
]
],
"sub": "did:key:z6MktA1uBdCpq4uJBqE9jjMiLyxZBg9a6xgPPKJjMqss6Zc2"
}
}
]
`
// format: dagJson --> Delegation --> dagCbor --> Delegation --> dagJson
// function: FromDagJson() ToDagCbor() FromDagCbor() ToDagJson()
p1, err := delegation.FromDagJson([]byte(delegationJson))
require.NoError(t, err)
cborBytes, err := p1.ToDagCbor()
require.NoError(t, err)
fmt.Println("cborBytes length", len(cborBytes))
fmt.Println("cbor", string(cborBytes))
p2, err := delegation.FromDagCbor(cborBytes)
require.NoError(t, err)
fmt.Println("read Cbor", p2)
readJson, err := p2.ToDagJson()
require.NoError(t, err)
fmt.Println("readJson length", len(readJson))
fmt.Println("json: ", string(readJson))
require.JSONEq(t, delegationJson, string(readJson))
}

View File

@@ -1,236 +0,0 @@
package delegation
import (
"io"
"github.com/ipfs/go-cid"
"github.com/ipld/go-ipld-prime"
"github.com/ipld/go-ipld-prime/codec"
"github.com/ipld/go-ipld-prime/codec/dagcbor"
"github.com/ipld/go-ipld-prime/codec/dagjson"
"github.com/ipld/go-ipld-prime/datamodel"
"github.com/libp2p/go-libp2p/core/crypto"
"github.com/ucan-wg/go-ucan/did"
"github.com/ucan-wg/go-ucan/internal/envelope"
)
// ToSealed wraps the delegation token in an envelope, generates the
// signature, encodes the result to DAG-CBOR and calculates the CID of
// the resulting binary data.
func (t *Token) ToSealed(privKey crypto.PrivKey) ([]byte, cid.Cid, error) {
data, err := t.ToDagCbor(privKey)
if err != nil {
return nil, cid.Undef, err
}
id, err := envelope.CIDFromBytes(data)
if err != nil {
return nil, cid.Undef, err
}
return data, id, nil
}
// ToSealedWriter is the same as Seal but accepts an io.Writer.
func (t *Token) ToSealedWriter(w io.Writer, privKey crypto.PrivKey) (cid.Cid, error) {
cidWriter := envelope.NewCIDWriter(w)
if err := t.ToDagCborWriter(cidWriter, privKey); err != nil {
return cid.Undef, err
}
return cidWriter.CID()
}
// FromSealed decodes the provided binary data from the DAG-CBOR format,
// verifies that the envelope's signature is correct based on the public
// key taken from the issuer (iss) field and calculates the CID of the
// incoming data.
func FromSealed(data []byte) (*Token, error) {
tkn, err := FromDagCbor(data)
if err != nil {
return nil, err
}
id, err := envelope.CIDFromBytes(data)
if err != nil {
return nil, err
}
tkn.cid = id
return tkn, nil
}
// FromSealedReader is the same as Unseal but accepts an io.Reader.
func FromSealedReader(r io.Reader) (*Token, error) {
cidReader := envelope.NewCIDReader(r)
tkn, err := FromDagCborReader(cidReader)
if err != nil {
return nil, err
}
id, err := cidReader.CID()
if err != nil {
return nil, err
}
tkn.cid = id
return tkn, nil
}
// Encode marshals a View to the format specified by the provided
// codec.Encoder.
func (t *Token) Encode(privKey crypto.PrivKey, encFn codec.Encoder) ([]byte, error) {
node, err := t.toIPLD(privKey)
if err != nil {
return nil, err
}
return ipld.Encode(node, encFn)
}
// EncodeWriter is the same as Encode but accepts an io.Writer.
func (t *Token) EncodeWriter(w io.Writer, privKey crypto.PrivKey, encFn codec.Encoder) error {
node, err := t.toIPLD(privKey)
if err != nil {
return err
}
return ipld.EncodeStreaming(w, node, encFn)
}
// ToDagCbor marshals the View to the DAG-CBOR format.
func (t *Token) ToDagCbor(privKey crypto.PrivKey) ([]byte, error) {
return t.Encode(privKey, dagcbor.Encode)
}
// ToDagCborWriter is the same as ToDagCbor but it accepts an io.Writer.
func (t *Token) ToDagCborWriter(w io.Writer, privKey crypto.PrivKey) error {
return t.EncodeWriter(w, privKey, dagcbor.Encode)
}
// ToDagJson marshals the View to the DAG-JSON format.
func (t *Token) ToDagJson(privKey crypto.PrivKey) ([]byte, error) {
return t.Encode(privKey, dagjson.Encode)
}
// ToDagJsonWriter is the same as ToDagJson but it accepts an io.Writer.
func (t *Token) ToDagJsonWriter(w io.Writer, privKey crypto.PrivKey) error {
return t.EncodeWriter(w, privKey, dagjson.Encode)
}
// Decode unmarshals the input data using the format specified by the
// provided codec.Decoder into a View.
//
// An error is returned if the conversion fails, or if the resulting
// View is invalid.
func Decode(b []byte, decFn codec.Decoder) (*Token, error) {
node, err := ipld.Decode(b, decFn)
if err != nil {
return nil, err
}
return fromIPLD(node)
}
// DecodeReader is the same as Decode, but accept an io.Reader.
func DecodeReader(r io.Reader, decFn codec.Decoder) (*Token, error) {
node, err := ipld.DecodeStreaming(r, decFn)
if err != nil {
return nil, err
}
return fromIPLD(node)
}
// FromDagCbor unmarshals the input data into a View.
//
// An error is returned if the conversion fails, or if the resulting
// View is invalid.
func FromDagCbor(data []byte) (*Token, error) {
pay, err := envelope.FromDagCbor[*tokenPayloadModel](data)
if err != nil {
return nil, err
}
tkn, err := tokenFromModel(*pay)
if err != nil {
return nil, err
}
return tkn, err
}
// FromDagCborReader is the same as FromDagCbor, but accept an io.Reader.
func FromDagCborReader(r io.Reader) (*Token, error) {
return DecodeReader(r, dagcbor.Decode)
}
// FromDagJson unmarshals the input data into a View.
//
// An error is returned if the conversion fails, or if the resulting
// View is invalid.
func FromDagJson(data []byte) (*Token, error) {
return Decode(data, dagjson.Decode)
}
// FromDagJsonReader is the same as FromDagJson, but accept an io.Reader.
func FromDagJsonReader(r io.Reader) (*Token, error) {
return DecodeReader(r, dagjson.Decode)
}
func fromIPLD(node datamodel.Node) (*Token, error) {
pay, err := envelope.FromIPLD[*tokenPayloadModel](node)
if err != nil {
return nil, err
}
tkn, err := tokenFromModel(*pay)
if err != nil {
return nil, err
}
return tkn, err
}
func (t *Token) toIPLD(privKey crypto.PrivKey) (datamodel.Node, error) {
var sub *string
if t.subject != did.Undef {
s := t.subject.String()
sub = &s
}
pol, err := t.policy.ToIPLD()
if err != nil {
return nil, err
}
var nbf *int64
if t.notBefore != nil {
u := t.notBefore.Unix()
nbf = &u
}
var exp *int64
if t.expiration != nil {
u := t.expiration.Unix()
exp = &u
}
model := &tokenPayloadModel{
Iss: t.issuer.String(),
Aud: t.audience.String(),
Sub: sub,
Cmd: t.command.String(),
Pol: pol,
Nonce: t.nonce,
Meta: *t.meta,
Nbf: nbf,
Exp: exp,
}
return envelope.ToIPLD(privKey, model)
}

View File

@@ -1,85 +0,0 @@
package delegation
import (
_ "embed"
"fmt"
"sync"
"github.com/ipld/go-ipld-prime"
"github.com/ipld/go-ipld-prime/datamodel"
"github.com/ipld/go-ipld-prime/node/bindnode"
"github.com/ipld/go-ipld-prime/schema"
"github.com/ucan-wg/go-ucan/internal/envelope"
"github.com/ucan-wg/go-ucan/pkg/meta"
)
// [Tag] is the string used as a key within the SigPayload that identifies
// that the TokenPayload is a delegation.
//
// [Tag]: https://github.com/ucan-wg/delegation/tree/v1_ipld#type-tag
const Tag = "ucan/dlg@1.0.0-rc.1"
// TODO: update the above Tag URL once the delegation specification is merged.
//go:embed delegation.ipldsch
var schemaBytes []byte
var (
once sync.Once
ts *schema.TypeSystem
err error
)
func mustLoadSchema() *schema.TypeSystem {
once.Do(func() {
ts, err = ipld.LoadSchemaBytes(schemaBytes)
})
if err != nil {
panic(fmt.Errorf("failed to load IPLD schema: %s", err))
}
return ts
}
func payloadType() schema.Type {
return mustLoadSchema().TypeByName("Payload")
}
var _ envelope.Tokener = (*tokenPayloadModel)(nil)
type tokenPayloadModel struct {
// Issuer DID (sender)
Iss string
// Audience DID (receiver)
Aud string
// Principal that the chain is about (the Subject)
// optional: can be nil
Sub *string
// The Command to eventually invoke
Cmd string
// The delegation policy
Pol datamodel.Node
// A unique, random nonce
Nonce []byte
// Arbitrary Metadata
Meta meta.Meta
// "Not before" UTC Unix Timestamp in seconds (valid from), 53-bits integer
// optional: can be nil
Nbf *int64
// The timestamp at which the Invocation becomes invalid
// optional: can be nil
Exp *int64
}
func (e *tokenPayloadModel) Prototype() schema.TypedPrototype {
return bindnode.Prototype((*tokenPayloadModel)(nil), payloadType())
}
func (*tokenPayloadModel) Tag() string {
return Tag
}

View File

@@ -1,177 +0,0 @@
package delegation_test
import (
"bytes"
_ "embed"
"fmt"
"testing"
"github.com/ipld/go-ipld-prime"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gotest.tools/v3/golden"
"github.com/ucan-wg/go-ucan/delegation"
"github.com/ucan-wg/go-ucan/internal/envelope"
)
//go:embed delegation.ipldsch
var schemaBytes []byte
func TestSchemaRoundTrip(t *testing.T) {
t.Parallel()
delegationJson := golden.Get(t, "new.dagjson")
privKey := privKey(t, issuerPrivKeyCfg)
t.Run("via buffers", func(t *testing.T) {
t.Parallel()
// format: dagJson --> PayloadModel --> dagCbor --> PayloadModel --> dagJson
// function: DecodeDagJson() Seal() Unseal() EncodeDagJson()
p1, err := delegation.FromDagJson(delegationJson)
require.NoError(t, err)
cborBytes, id, err := p1.ToSealed(privKey)
require.NoError(t, err)
assert.Equal(t, newCID, envelope.CIDToBase58BTC(id))
fmt.Println("cborBytes length", len(cborBytes))
fmt.Println("cbor", string(cborBytes))
p2, err := delegation.FromSealed(cborBytes)
require.NoError(t, err)
assert.Equal(t, id, p2.CID())
fmt.Println("read Cbor", p2)
readJson, err := p2.ToDagJson(privKey)
require.NoError(t, err)
fmt.Println("readJson length", len(readJson))
fmt.Println("json: ", string(readJson))
assert.JSONEq(t, string(delegationJson), string(readJson))
})
t.Run("via streaming", func(t *testing.T) {
t.Parallel()
buf := bytes.NewBuffer(delegationJson)
// format: dagJson --> PayloadModel --> dagCbor --> PayloadModel --> dagJson
// function: DecodeDagJson() Seal() Unseal() EncodeDagJson()
p1, err := delegation.FromDagJsonReader(buf)
require.NoError(t, err)
cborBytes := &bytes.Buffer{}
id, err := p1.ToSealedWriter(cborBytes, privKey)
t.Log(len(id.Bytes()), id.Bytes())
require.NoError(t, err)
assert.Equal(t, newCID, envelope.CIDToBase58BTC(id))
// buf = bytes.NewBuffer(cborBytes.Bytes())
p2, err := delegation.FromSealedReader(cborBytes)
require.NoError(t, err)
t.Log(len(p2.CID().Bytes()), p2.CID().Bytes())
assert.Equal(t, envelope.CIDToBase58BTC(id), envelope.CIDToBase58BTC(p2.CID()))
readJson := &bytes.Buffer{}
require.NoError(t, p2.ToDagJsonWriter(readJson, privKey))
assert.JSONEq(t, string(delegationJson), readJson.String())
})
}
func BenchmarkSchemaLoad(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_, _ = ipld.LoadSchemaBytes(schemaBytes)
}
}
func BenchmarkRoundTrip(b *testing.B) {
delegationJson := golden.Get(b, "new.dagjson")
privKey := privKey(b, issuerPrivKeyCfg)
b.Run("via buffers", func(b *testing.B) {
p1, _ := delegation.FromDagJson(delegationJson)
cborBytes, _, _ := p1.ToSealed(privKey)
p2, _ := delegation.FromSealed(cborBytes)
b.ResetTimer()
b.Run("FromDagJson", func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_, _ = delegation.FromDagJson(delegationJson)
}
})
b.Run("Seal", func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_, _, _ = p1.ToSealed(privKey)
}
})
b.Run("Unseal", func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_, _ = delegation.FromSealed(cborBytes)
}
})
b.Run("ToDagJson", func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_, _ = p2.ToDagJson(privKey)
}
})
})
b.Run("via streaming", func(b *testing.B) {
p1, _ := delegation.FromDagJsonReader(bytes.NewReader(delegationJson))
cborBuf := &bytes.Buffer{}
_, _ = p1.ToSealedWriter(cborBuf, privKey)
cborBytes := cborBuf.Bytes()
p2, _ := delegation.FromSealedReader(bytes.NewReader(cborBytes))
b.ResetTimer()
b.Run("FromDagJsonReader", func(b *testing.B) {
b.ReportAllocs()
reader := bytes.NewReader(delegationJson)
for i := 0; i < b.N; i++ {
_, _ = reader.Seek(0, 0)
_, _ = delegation.FromDagJsonReader(reader)
}
})
b.Run("SealWriter", func(b *testing.B) {
b.ReportAllocs()
buf := &bytes.Buffer{}
for i := 0; i < b.N; i++ {
buf.Reset()
_, _ = p1.ToSealedWriter(buf, privKey)
}
})
b.Run("UnsealReader", func(b *testing.B) {
b.ReportAllocs()
reader := bytes.NewReader(cborBytes)
for i := 0; i < b.N; i++ {
_, _ = reader.Seek(0, 0)
_, _ = delegation.FromSealedReader(reader)
}
})
b.Run("ToDagJsonReader", func(b *testing.B) {
b.ReportAllocs()
buf := &bytes.Buffer{}
for i := 0; i < b.N; i++ {
buf.Reset()
_ = p2.ToDagJsonWriter(buf, privKey)
}
})
})
}

View File

@@ -1 +1 @@
[{"/":{"bytes":"FM6otj0r/noJWiGAC5WV86xAazxrF173IihuHJgEt35CtSzjeaelrR3UwaSr8xbE9sLpo5xJhUbo0QLI273hDA"}},{"h":{"/":{"bytes":"NO0BcQ"}},"ucan/dlg@1.0.0-rc.1":{"aud":"did:key:z6Mkq5YmbJcTrPExNDi26imrTCpKhepjBFBSHqrBDN2ArPkv","cmd":"/foo/bar","exp":7258118400,"iss":"did:key:z6Mkpzn2n3ZGT2VaqMGSQC3tzmzV4TS9S71iFsDXE1WnoNH2","meta":{"bar":"barr","foo":"fooo"},"nonce":{"/":{"bytes":"NnJvRGhHaTBraU5yaVFBejdKM2QrYk9lb0kvdGo4RU5pa21RTmJ0am5EMA"}},"pol":[["==",".status","draft"],["all",".reviewer",["like",".email","*@example.com"]],["any",".tags",["or",[["==",".","news"],["==",".","press"]]]]],"sub":"did:key:z6MktA1uBdCpq4uJBqE9jjMiLyxZBg9a6xgPPKJjMqss6Zc2"}}]
[{"/":{"bytes":"P2lPLfdMuZuc4NPZ0mbozU+/bn5xoWlJsu+Fvaxi4ICYXVJb9/wiTTht3WJEFqjxXLxfTl4BMZF3J1CNvMPqBg"}},{"h":{"/":{"bytes":"NO0BcQ"}},"ucan/dlg@1.0.0-rc.1":{"aud":"did:key:z6Mkq5YmbJcTrPExNDi26imrTCpKhepjBFBSHqrBDN2ArPkv","cmd":"/foo/bar","exp":-62135596800,"iss":"did:key:z6Mkpzn2n3ZGT2VaqMGSQC3tzmzV4TS9S71iFsDXE1WnoNH2","meta":{"bar":"barr","foo":"fooo"},"nonce":{"/":{"bytes":"NnJvRGhHaTBraU5yaVFBejdKM2QrYk9lb0kvdGo4RU5pa21RTmJ0am5EMA"}},"pol":[["==",".status","draft"],["all",".reviewer",["like",".email","*@example.com"]],["any",".tags",["or",[["==",".","news"],["==",".","press"]]]]],"sub":"did:key:z6MktA1uBdCpq4uJBqE9jjMiLyxZBg9a6xgPPKJjMqss6Zc2"}}]

View File

@@ -1 +1 @@
[{"/":{"bytes":"aYBq08tfm0zQZnPg/5tB9kM5mklRU9PPIkV7CK68jEgbd76JbCGuu75vfLyBu3WTqKzLSJ583pbwu668m/7MBQ"}},{"h":{"/":{"bytes":"NO0BcQ"}},"ucan/dlg@1.0.0-rc.1":{"aud":"did:key:z6Mkq5YmbJcTrPExNDi26imrTCpKhepjBFBSHqrBDN2ArPkv","cmd":"/foo/bar","exp":7258118400,"iss":"did:key:z6Mkpzn2n3ZGT2VaqMGSQC3tzmzV4TS9S71iFsDXE1WnoNH2","meta":{"bar":"barr","foo":"fooo"},"nonce":{"/":{"bytes":"NnJvRGhHaTBraU5yaVFBejdKM2QrYk9lb0kvdGo4RU5pa21RTmJ0am5EMA"}},"pol":[["==",".status","draft"],["all",".reviewer",["like",".email","*@example.com"]],["any",".tags",["or",[["==",".","news"],["==",".","press"]]]]],"sub":"did:key:z6Mkpzn2n3ZGT2VaqMGSQC3tzmzV4TS9S71iFsDXE1WnoNH2"}}]
[{"/":{"bytes":"0sjiwG9BOgpezz6qw5UiD+rqOeqFLn4+Qds1PvbnsUBoc3RhF6IVxIeoOXDh1ufv3RHaI/zg4wjYpUwAMpTACw"}},{"h":{"/":{"bytes":"NO0BcQ"}},"ucan/dlg@1.0.0-rc.1":{"aud":"did:key:z6Mkq5YmbJcTrPExNDi26imrTCpKhepjBFBSHqrBDN2ArPkv","cmd":"/foo/bar","exp":-62135596800,"iss":"did:key:z6Mkpzn2n3ZGT2VaqMGSQC3tzmzV4TS9S71iFsDXE1WnoNH2","meta":{"bar":"barr","foo":"fooo"},"nonce":{"/":{"bytes":"NnJvRGhHaTBraU5yaVFBejdKM2QrYk9lb0kvdGo4RU5pa21RTmJ0am5EMA"}},"pol":[["==",".status","draft"],["all",".reviewer",["like",".email","*@example.com"]],["any",".tags",["or",[["==",".","news"],["==",".","press"]]]]],"sub":"did:key:z6Mkpzn2n3ZGT2VaqMGSQC3tzmzV4TS9S71iFsDXE1WnoNH2"}}]

5
doc.go Normal file
View File

@@ -0,0 +1,5 @@
// Package ucan provides the core functionality required to grant and
// revoke privileges via [UCAN] tokens.
//
// [UCAN]: https://ucan.xyz
package ucan

14
go.mod
View File

@@ -1,17 +1,19 @@
module github.com/ucan-wg/go-ucan
go 1.22
go 1.22.0
toolchain go1.22.4
require (
github.com/gobwas/glob v0.2.3
github.com/ipfs/go-cid v0.4.1
github.com/ipld/go-ipld-prime v0.21.0
github.com/libp2p/go-libp2p v0.36.3
github.com/libp2p/go-libp2p v0.36.2
github.com/multiformats/go-multibase v0.2.0
github.com/multiformats/go-multicodec v0.9.0
github.com/multiformats/go-multihash v0.2.3
github.com/multiformats/go-varint v0.0.7
github.com/selesy/go-options v0.0.0-20240912020512-ed2658318e52
github.com/stretchr/testify v1.9.0
gotest.tools/v3 v3.5.1
)
@@ -19,7 +21,8 @@ require (
require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 // indirect
github.com/google/go-cmp v0.5.9 // indirect
github.com/fatih/structtag v1.2.0 // indirect
github.com/google/go-cmp v0.6.0 // indirect
github.com/klauspost/cpuid/v2 v2.2.8 // indirect
github.com/minio/sha256-simd v1.0.1 // indirect
github.com/mr-tron/base58 v1.2.0 // indirect
@@ -29,7 +32,10 @@ require (
github.com/polydawn/refmt v0.89.0 // indirect
github.com/spaolacci/murmur3 v1.1.0 // indirect
golang.org/x/crypto v0.25.0 // indirect
golang.org/x/sys v0.22.0 // indirect
golang.org/x/mod v0.21.0 // indirect
golang.org/x/sync v0.8.0 // indirect
golang.org/x/sys v0.25.0 // indirect
golang.org/x/tools v0.25.0 // indirect
google.golang.org/protobuf v1.34.2 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
lukechampine.com/blake3 v1.3.0 // indirect

24
go.sum
View File

@@ -6,11 +6,15 @@ github.com/decred/dcrd/crypto/blake256 v1.0.1 h1:7PltbUIQB7u/FfZ39+DGa/ShuMyJ5il
github.com/decred/dcrd/crypto/blake256 v1.0.1/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 h1:rpfIENRNNilwHwZeG5+P150SMrnNEcHYvcCuK6dPZSg=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0=
github.com/fatih/structtag v1.2.0 h1:/OdNE99OxoI/PqaW/SuSK9uxxT3f/tcSZgon/ssNSx4=
github.com/fatih/structtag v1.2.0/go.mod h1:mBJUNpUnHmRKrKlQQlmCrh5PuhftFbNv8Ys4/aAZl94=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/go-yaml/yaml v2.1.0+incompatible/go.mod h1:w2MrLa16VYP0jy6N7M5kHaCkaLENm+P+Tv+MfurjSw0=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
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/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/ipfs/go-cid v0.4.1 h1:A/T3qGvxi4kpKWWcPC/PgbvDA2bjVLO7n4UeVwnbs/s=
@@ -27,8 +31,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/libp2p/go-buffer-pool v0.1.0 h1:oK4mSFcQz7cTQIfqbe4MIj9gLW+mnanjyFtc6cdF0Y8=
github.com/libp2p/go-buffer-pool v0.1.0/go.mod h1:N+vh8gMqimBzdKkSMVuydVDq+UV5QTWy5HSiZacSbPg=
github.com/libp2p/go-libp2p v0.36.3 h1:NHz30+G7D8Y8YmznrVZZla0ofVANrvBl2c+oARfMeDQ=
github.com/libp2p/go-libp2p v0.36.3/go.mod h1:4Y5vFyCUiJuluEPmpnKYf6WFx5ViKPUYs/ixe9ANFZ8=
github.com/libp2p/go-libp2p v0.36.2 h1:BbqRkDaGC3/5xfaJakLV/BrpjlAuYqSB0lRvtzL3B/U=
github.com/libp2p/go-libp2p v0.36.2/go.mod h1:XO3joasRE4Eup8yCTTP/+kX+g92mOgRaadk46LmPhHY=
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=
@@ -54,6 +58,8 @@ github.com/polydawn/refmt v0.89.0/go.mod h1:/zvteZs/GwLtCgZ4BL6CBsk9IKIlexP43ObX
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/selesy/go-options v0.0.0-20240912020512-ed2658318e52 h1:poNWlojS+o3229ZuatLMzK9wFiLuLxo7O170Edggs0o=
github.com/selesy/go-options v0.0.0-20240912020512-ed2658318e52/go.mod h1:Cn8TrnJWCWd3dAmejFTpLN8tNVNKNoVVlZzL8ux5EWQ=
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
github.com/smartystreets/assertions v1.2.0 h1:42S6lae5dvLc7BrLu/0ugRtcFVjoJNMC/N3yZFZkDFs=
github.com/smartystreets/assertions v1.2.0/go.mod h1:tcbTF8ujkAEcZ8TElKY+i30BzYlVhC/LOxJk7iOWnoo=
@@ -71,13 +77,19 @@ golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30=
golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M=
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8=
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
golang.org/x/mod v0.21.0 h1:vvrHzRwRfVKSiLrG+d4FMl/Qi4ukBCE6kZlTUkDYRT0=
golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34=
golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.25.0 h1:oFU9pkj/iJgs+0DT+VMHrx+oBKs/LJMV+Uvg78sl+fE=
golang.org/x/tools v0.25.0/go.mod h1:/vtpO8WL1N9cQC3FN5zPqb//fRXskFHbLKk4OW1Q7rg=
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=

View File

@@ -0,0 +1,54 @@
package main
import (
"bytes"
"log/slog"
"os"
"github.com/ipld/go-ipld-prime"
"github.com/ipld/go-ipld-prime/node/bindnode"
)
const header = `// Code generated by internal/cmd/token - DO NOT EDIT.
package token
import "github.com/ipld/go-ipld-prime/datamodel"
`
func main() {
slog.Info("Generating Go types for token.ipldsch")
if err := Run(); err != nil {
slog.Error(err.Error())
slog.Error("Finished but failed to generate and write token_gen.go")
os.Exit(1)
}
slog.Info("Finished generating and writing token_gen.go")
os.Exit(0)
}
func Run() error {
schema, err := os.ReadFile("token.ipldsch")
if err != nil {
return err
}
slog.Debug(string(schema))
typeSystem, err := ipld.LoadSchemaBytes(schema)
if err != nil {
return err
}
buf := bytes.NewBufferString(header)
if err := bindnode.ProduceGoTypes(buf, typeSystem); err != nil {
return err
}
return os.WriteFile("token_gen.go", buf.Bytes(), 0o600)
}

View File

@@ -1,124 +0,0 @@
package envelope
import (
"crypto/sha256"
"hash"
"io"
"github.com/ipfs/go-cid"
"github.com/multiformats/go-multibase"
"github.com/multiformats/go-multicodec"
"github.com/multiformats/go-multihash"
)
var b58BTCEnc = multibase.MustNewEncoder(multibase.Base58BTC)
// CIDToBase56BTC is a utility method to convert a CIDv1 to the canonical
// string representation used by UCAN.
func CIDToBase58BTC(id cid.Cid) string {
return id.Encode(b58BTCEnc)
}
// CIDFromBytes returns the UCAN content identifier for an arbitrary slice
// of bytes.
func CIDFromBytes(b []byte) (cid.Cid, error) {
return cid.V1Builder{
Codec: uint64(multicodec.DagCbor),
MhType: multihash.SHA2_256,
MhLength: 0,
}.Sum(b)
}
var _ io.Reader = (*CIDReader)(nil)
// CIDReader wraps an io.Reader and includes a hash.Hash that is
// incrementally updated as data is read from the child io.Reader.
type CIDReader struct {
hash hash.Hash
r io.Reader
err error
}
// NewCIDReader initializes a hash.Hash to calculate the CID's hash and
// returns the wrapped io.Reader.
func NewCIDReader(r io.Reader) *CIDReader {
h := sha256.New()
h.Reset()
return &CIDReader{
hash: h,
r: r,
}
}
// CID returns the UCAN-formatted cid.Cid created from the hash calculated
// as bytes were read from the inner io.Reader.
func (r *CIDReader) CID() (cid.Cid, error) {
if r.err != nil {
return cid.Undef, r.err // TODO: Wrap to say it's an error during streaming?
}
return cidFromHash(r.hash)
}
// Read implements io.Reader.
func (r *CIDReader) Read(p []byte) (n int, err error) {
n, err = r.r.Read(p)
if err != nil && err != io.EOF {
r.err = err
return
}
_, _ = r.hash.Write(p[:n])
return
}
var _ io.Writer = (*CIDWriter)(nil)
// CIDWriter wraps an io.Writer and includes a hash.Hash that is
// incrementally updated as data is written to the child io.Writer.
type CIDWriter struct {
hash hash.Hash
w io.Writer
err error
}
// NewCIDWriter initializes a hash.Hash to calculate the CID's hash and
// returns the wrapped io.Writer.
func NewCIDWriter(w io.Writer) *CIDWriter {
h := sha256.New()
h.Reset()
return &CIDWriter{
hash: h,
w: w,
}
}
// CID returns the UCAN-formatted cid.Cid created from the hash calculated
// as bytes were written from the inner io.Reader.
func (w *CIDWriter) CID() (cid.Cid, error) {
return cidFromHash(w.hash)
}
// Write implements io.Writer.
func (w *CIDWriter) Write(p []byte) (n int, err error) {
if _, err = w.hash.Write(p); err != nil {
w.err = err
return
}
return w.w.Write(p)
}
func cidFromHash(hash hash.Hash) (cid.Cid, error) {
mh, err := multihash.Encode(hash.Sum(nil), multihash.SHA2_256)
if err != nil {
return cid.Undef, err
}
return cid.NewCidV1(uint64(multicodec.DagCbor), mh), nil
}

View File

@@ -1,86 +0,0 @@
package envelope_test
import (
"io"
"testing"
"github.com/ipfs/go-cid"
"github.com/multiformats/go-multicodec"
"github.com/multiformats/go-multihash"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gotest.tools/v3/golden"
"github.com/ucan-wg/go-ucan/internal/envelope"
)
func TestCidFromBytes(t *testing.T) {
t.Parallel()
expData := golden.Get(t, "example.dagcbor")
expHash, err := multihash.Sum(expData, uint64(multicodec.Sha2_256), -1)
require.NoError(t, err)
data, err := envelope.ToDagCbor(examplePrivKey(t), newExample(t))
require.NoError(t, err)
id, err := envelope.CIDFromBytes(data)
require.NoError(t, err)
assert.Equal(t, exampleCID, envelope.CIDToBase58BTC(id))
assert.Equal(t, expHash, id.Hash())
}
func TestStreaming(t *testing.T) {
t.Parallel()
expData := []byte("this is a test")
expCID, err := cid.V1Builder{
Codec: uint64(multicodec.DagCbor),
MhType: multihash.SHA2_256,
MhLength: 0,
}.Sum(expData)
require.NoError(t, err)
t.Run("CIDReader()", func(t *testing.T) {
t.Parallel()
r, w := io.Pipe() //nolint:varnamelen
cidReader := envelope.NewCIDReader(r)
go func() {
_, err := w.Write(expData)
assert.NoError(t, err)
assert.NoError(t, w.Close())
}()
actData, err := io.ReadAll(cidReader)
require.NoError(t, err)
assert.Equal(t, expData, actData)
actCID, err := cidReader.CID()
require.NoError(t, err)
assert.Equal(t, expCID, actCID)
})
t.Run("CIDWriter", func(t *testing.T) {
t.Parallel()
r, w := io.Pipe() //nolint:varnamelen
cidWriter := envelope.NewCIDWriter(w)
go func() {
_, err := cidWriter.Write(expData)
assert.NoError(t, err)
assert.NoError(t, w.Close())
}()
actData, err := io.ReadAll(r)
require.NoError(t, err)
assert.Equal(t, expData, actData)
actCID, err := cidWriter.CID()
require.NoError(t, err)
assert.Equal(t, expCID, actCID)
})
}

View File

@@ -0,0 +1,309 @@
package envelope
import (
"bytes"
"errors"
"fmt"
"strings"
"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/ipld/go-ipld-prime/node/bindnode"
crypto "github.com/libp2p/go-libp2p/core/crypto"
"github.com/libp2p/go-libp2p/core/crypto/pb"
"github.com/ucan-wg/go-ucan/did"
"github.com/ucan-wg/go-ucan/internal/token"
"github.com/ucan-wg/go-ucan/internal/varsig"
)
// [Envelope] is a signed enclosure for a UCAN v1 Token.
//
// While the types and functions in this package are not exported,
// the names used for types, fields, variables, etc generally use the
// names from the specification
//
// [Envelope]: https://github.com/ucan-wg/spec#envelope
type Envelope struct {
signature []byte
sigPayload *sigPayload
}
// New creates an Envelope containing a VarsigHeader and Signature for
// the data resulting from wrapping the provided Token in an IPLD
// datamodel.Node and encoding it using DAG-CBOR.
func New(privKey crypto.PrivKey, token *token.Token, tag string) (*Envelope, error) {
sigPayload, err := newSigPayload(privKey.Type(), token, tag)
if err != nil {
return nil, err
}
cbor, err := sigPayload.cbor()
if err != nil {
return nil, err
}
signature, err := privKey.Sign(cbor)
if err != nil {
return nil, err
}
return &Envelope{
signature: signature,
sigPayload: sigPayload,
}, nil
}
// Wrap is syntactic sugar for creating an Envelope and wrapping it as an
// IPLD datamodel.Node in a single operation.
//
// Since the Envelope itself isn't returned, use this method only when
// the IPLD datamodel.Node is used directly. If the Envelope is also
// required, use New followed by Envelope.Wrap to avoid the need to
// unwrap the newly created datamodel.Node.
func Wrap(privKey crypto.PrivKey, token *token.Token, tag string) (datamodel.Node, error) {
env, err := New(privKey, token, tag)
if err != nil {
return nil, err
}
return env.Wrap()
}
// Unwrap attempts to crate an Envelope from a datamodel.Node
//
// There are lots of ways that this can fail and therefore there are
// an almost excessive number of check included here and while
// attempting to extract the token.Token from one of the inner IPLD
// nodes.
func Unwrap(node datamodel.Node) (*Envelope, error) {
signatureNode, err := node.LookupByIndex(0)
if err != nil {
return nil, err
}
signature, err := signatureNode.AsBytes()
if err != nil {
return nil, err
}
sigPayloadNode, err := node.LookupByIndex(1)
if err != nil {
return nil, err
}
sigPayload, err := unwrapSigPayload(sigPayloadNode)
if err != nil {
return nil, err
}
envel := &Envelope{
signature: signature,
sigPayload: sigPayload,
}
if ok, err := envel.Verify(); !ok || err != nil {
return nil, fmt.Errorf("envelope was not signed by issuer")
}
return envel, nil
}
// Signature returns the cryptographic signature of the Envelope's
// SigPayload.
func (e *Envelope) Signature() []byte {
return e.signature
}
// Tag returns the key that's used to reference the TokenPayload within
// this Envelope.
func (e *Envelope) Tag() string {
return e.sigPayload.tag
}
// TokenPayload returns the *token.Token enclosed within this Envelope.
func (e *Envelope) TokenPayload() *token.Token {
return e.sigPayload.tokenPayload
}
// VarsigHeader is an accessor that returns the [VarsigHeader] from the
// underlying [SigPayload] from the [Envelope].
//
// [Envelope]: https://github.com/ucan-wg/spec#envelope
// [SigPayload]: https://github.com/ucan-wg/spec#envelope
// [VarsigHeader]: https://github.com/ucan-wg/spec#envelope
func (e *Envelope) VarsigHeader() []byte {
return e.sigPayload.varsigHeader
}
// Verify checks that the [Envelope]'s signature is correct for the
// data created by encoding the SigPayload as DAG-CBOR and the public
// key passed as the only argument.
//
// Note that for Delegation and Invocation tokens, the public key
// is retrieved from the DID's method specific identifier for the
// Issuer field.
//
// [Envelope]: https://github.com/ucan-wg/spec#envelope
func (e *Envelope) Verify() (bool, error) {
pubKey, err := did.ToPubKey(e.sigPayload.tokenPayload.Issuer)
if err != nil {
return false, err
}
cbor, err := e.sigPayload.cbor()
if err != nil {
return false, err
}
return pubKey.Verify(cbor, e.signature)
}
// Wrap encodes the Envelope as an IPLD datamodel.Node.
func (e *Envelope) Wrap() (datamodel.Node, error) {
spn, err := e.sigPayload.wrap()
if err != nil {
return nil, err
}
return qp.BuildList(basicnode.Prototype.Any, 2, func(la datamodel.ListAssembler) {
qp.ListEntry(la, qp.Bytes(e.signature))
qp.ListEntry(la, qp.Node(spn))
})
}
//
// The types below are strictly to make it easier to Wrap and Unwrap the
// Envelope with an IPLD datamodel.Node. The Envelope itself provides
// accessors to the internals of these types.
//
type sigPayload struct {
varsigHeader []byte
tokenPayload *token.Token
tag string
}
func newSigPayload(keyType pb.KeyType, token *token.Token, tag string) (*sigPayload, error) {
varsigHeader, err := varsig.Encode(keyType)
if err != nil {
return nil, err
}
return &sigPayload{
varsigHeader: varsigHeader,
tokenPayload: token,
tag: tag,
}, nil
}
func unwrapSigPayload(node datamodel.Node) (*sigPayload, error) {
// Normally we could look up the VarsigHeader and TokenPayload using
// node.LookupByString() - this works for the "h" key used for the
// VarsigHeader but not for the TokenPayload's key (tag) as all we
// know is that it starts with "ucan/" and as explained below, must
// decode to a schema.TypedNode for the representation provided by the
// token.Prototype().
// vvv
mi := node.MapIterator()
if mi == nil {
return nil, fmt.Errorf("the SigPayload node is not a map: %s", node.Kind().String())
}
var (
hdrNode datamodel.Node
tknNode datamodel.Node
tag string
)
keyCount := 0
for !mi.Done() {
k, v, err := mi.Next()
if err != nil {
return nil, err
}
kStr, err := k.AsString()
if err != nil {
return nil, fmt.Errorf("the SigPayload keys are not strings: %w", err)
}
keyCount++
if kStr == "h" {
hdrNode = v
continue
}
if strings.HasPrefix(kStr, "ucan/") {
tknNode = v
tag = kStr
}
}
if keyCount != 2 {
return nil, fmt.Errorf("the SigPayload map should have exactly two keys: %d", keyCount)
}
// ^^^
// Replaces the datamodel.Node in tokenPayloadNode with a
// schema.TypedNode so that we can cast it to a *token.Token after
// unwrapping it.
// vvv
nb := token.Prototype().Representation().NewBuilder()
err := nb.AssignNode(tknNode)
if err != nil {
return nil, err
}
tknNode = nb.Build()
// ^^^
tokenPayload := bindnode.Unwrap(tknNode)
if tokenPayload == nil {
return nil, errors.New("failed to Unwrap the TokenPayload")
}
tkn, ok := tokenPayload.(*token.Token)
if !ok {
return nil, errors.New("failed to assert the TokenPayload type as *token.Token")
}
hdr, err := hdrNode.AsBytes()
if err != nil {
return nil, err
}
return &sigPayload{
varsigHeader: hdr,
tokenPayload: tkn,
tag: tag,
}, nil
}
func (sp *sigPayload) cbor() ([]byte, error) {
node, err := sp.wrap()
if err != nil {
return nil, err
}
buf := &bytes.Buffer{}
if err = dagcbor.Encode(node, buf); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
func (sp *sigPayload) wrap() (datamodel.Node, error) {
tpn := bindnode.Wrap(sp.tokenPayload, token.Prototype().Type())
return qp.BuildMap(basicnode.Prototype.Any, 2, func(ma datamodel.MapAssembler) {
qp.MapEntry(ma, "h", qp.Bytes(sp.varsigHeader))
qp.MapEntry(ma, sp.tag, qp.Node(tpn.Representation()))
})
}

View File

@@ -0,0 +1,200 @@
package envelope_test
import (
"encoding/base64"
"testing"
"github.com/ipld/go-ipld-prime"
"github.com/ipld/go-ipld-prime/codec/dagcbor"
"github.com/ipld/go-ipld-prime/codec/dagjson"
"github.com/ipld/go-ipld-prime/datamodel"
"github.com/ipld/go-ipld-prime/fluent/qp"
"github.com/ipld/go-ipld-prime/node/basicnode"
crypto "github.com/libp2p/go-libp2p/core/crypto"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/ucan-wg/go-ucan/did"
"github.com/ucan-wg/go-ucan/internal/envelope"
"github.com/ucan-wg/go-ucan/internal/token"
"gotest.tools/v3/golden"
)
const (
exampleDID = "did:key:z6MkpuK2Amsu1RqcLGgmHHQHhvmeXCCBVsM4XFSg2cCyg4Nh"
examplePrivKeyCfg = "CAESQP9v2uqECTuIi45dyg3znQvsryvf2IXmOF/6aws6aCehm0FVrj0zHR5RZSDxWNjcpcJqsGym3sjCungX9Zt5oA4="
exampleSignatureStr = "PZV6A2aI7n+MlyADqcqmWhkuyNrgUCDz+qSLSnI9bpasOwOhKUTx95m5Nu5CO/INa1LqzHGioD9+PVf6qdtTBg"
exampleTag = "ucan/example@v1.0.0-rc.1"
exampleVarsigHeaderStr = "NO0BcQ"
invalidSignatureStr = "PZV6A2aI7n+MlyADqcqmWhkuyNrgUCDz+qSLSnI9bpasOwOhKUTx95m5Nu5CO/INa1LqzHGioD9+PVf6qdtTBK"
exampleDAGCBORFilename = "example.dagcbor"
exampleDAGJSONFilename = "example.dagjson"
)
func TestNew(t *testing.T) {
t.Parallel()
envel := exampleEnvelope(t)
assert.NotZero(t, envel)
assert.Equal(t, exampleSignature(t), envel.Signature())
assert.Equal(t, exampleTag, envel.Tag())
assert.Equal(t, exampleVarsigHeader(t), envel.VarsigHeader())
assert.EqualValues(t, exampleGoldenTokenPayload(t), envel.TokenPayload())
}
func TestWrap(t *testing.T) {
t.Parallel()
node, err := envelope.Wrap(examplePrivKey(t), exampleToken(t), exampleTag)
require.NoError(t, err)
cbor, err := ipld.Encode(node, dagcbor.Encode)
require.NoError(t, err)
golden.AssertBytes(t, cbor, exampleDAGCBORFilename)
json, err := ipld.Encode(node, dagjson.Encode)
require.NoError(t, err)
golden.Assert(t, string(json), exampleDAGJSONFilename)
}
func TestEnvelope_Verify(t *testing.T) {
t.Parallel()
t.Run("valid signature by issuer", func(t *testing.T) {
t.Parallel()
envel := exampleEnvelope(t)
ok, err := envel.Verify()
require.NoError(t, err)
assert.True(t, ok)
})
t.Run("invalid signature by wrong issuer", func(t *testing.T) {
t.Parallel()
envel, err := envelope.Unwrap(invalidNodeFromGolden(t))
require.NoError(t, err)
ok, _ := envel.Verify()
assert.False(t, ok)
})
}
func TestEnvelope_Wrap(t *testing.T) {
t.Parallel()
envel := exampleEnvelope(t)
node, err := envel.Wrap()
require.NoError(t, err)
cbor, err := ipld.Encode(node, dagcbor.Encode)
require.NoError(t, err)
assert.Equal(t, golden.Get(t, exampleDAGCBORFilename), cbor)
}
func exampleGoldenEnvelope(t *testing.T) *envelope.Envelope {
t.Helper()
envel, err := envelope.Unwrap(exampleGoldenNode(t))
require.NoError(t, err)
return envel
}
func exampleGoldenNode(t *testing.T) datamodel.Node {
t.Helper()
cbor := golden.Get(t, exampleDAGCBORFilename)
node, err := ipld.Decode(cbor, dagcbor.Decode)
require.NoError(t, err)
return node
}
func exampleGoldenTokenPayload(t *testing.T) *token.Token {
t.Helper()
return exampleGoldenEnvelope(t).TokenPayload()
}
func examplePrivKey(t *testing.T) crypto.PrivKey {
t.Helper()
privKeyEnc, err := crypto.ConfigDecodeKey(examplePrivKeyCfg)
require.NoError(t, err)
privKey, err := crypto.UnmarshalPrivateKey(privKeyEnc)
require.NoError(t, err)
return privKey
}
func exampleEnvelope(t *testing.T) *envelope.Envelope {
t.Helper()
envel, err := envelope.New(examplePrivKey(t), exampleToken(t), exampleTag)
require.NoError(t, err)
return envel
}
func examplePubKey(t *testing.T) crypto.PubKey {
t.Helper()
return examplePrivKey(t).GetPublic()
}
func exampleSignature(t *testing.T) []byte {
t.Helper()
sig, err := base64.RawStdEncoding.DecodeString(exampleSignatureStr)
require.NoError(t, err)
return sig
}
func exampleToken(t *testing.T) *token.Token {
t.Helper()
id, err := did.FromPubKey(examplePubKey(t))
require.NoError(t, err)
return &token.Token{
Issuer: id.String(),
}
}
func exampleVarsigHeader(t *testing.T) []byte {
t.Helper()
hdr, err := base64.RawStdEncoding.DecodeString(exampleVarsigHeaderStr)
require.NoError(t, err)
return hdr
}
func invalidNodeFromGolden(t *testing.T) datamodel.Node {
t.Helper()
invalidSig, err := base64.RawStdEncoding.DecodeString(invalidSignatureStr)
require.NoError(t, err)
envelNode := exampleGoldenNode(t)
sigPayloadNode, err := envelNode.LookupByIndex(1)
require.NoError(t, err)
node, err := qp.BuildList(basicnode.Prototype.Any, 2, func(la datamodel.ListAssembler) {
qp.ListEntry(la, qp.Bytes(invalidSig))
qp.ListEntry(la, qp.Node(sigPayloadNode))
})
require.NoError(t, err)
return node
}

View File

@@ -1,137 +0,0 @@
package envelope_test
import (
_ "embed"
"encoding/base64"
"fmt"
"sync"
"testing"
"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/ipld/go-ipld-prime/node/bindnode"
"github.com/ipld/go-ipld-prime/schema"
"github.com/libp2p/go-libp2p/core/crypto"
"github.com/stretchr/testify/require"
"github.com/ucan-wg/go-ucan/internal/envelope"
"gotest.tools/v3/golden"
)
const (
exampleCID = "zdpuAyw6R5HvKSPzztuzXNYFx3ZGoMHMuAsXL6u3xLGQriRXQ"
exampleDID = "did:key:z6MkpuK2Amsu1RqcLGgmHHQHhvmeXCCBVsM4XFSg2cCyg4Nh"
exampleGreeting = "world"
examplePrivKeyCfg = "CAESQP9v2uqECTuIi45dyg3znQvsryvf2IXmOF/6aws6aCehm0FVrj0zHR5RZSDxWNjcpcJqsGym3sjCungX9Zt5oA4="
exampleSignatureStr = "PZV6A2aI7n+MlyADqcqmWhkuyNrgUCDz+qSLSnI9bpasOwOhKUTx95m5Nu5CO/INa1LqzHGioD9+PVf6qdtTBg"
exampleTag = "ucan/example@v1.0.0-rc.1"
exampleTypeName = "Example"
exampleVarsigHeaderStr = "NO0BcQ"
invalidSignatureStr = "PZV6A2aI7n+MlyADqcqmWhkuyNrgUCDz+qSLSnI9bpasOwOhKUTx95m5Nu5CO/INa1LqzHGioD9+PVf6qdtTBK"
exampleDAGCBORFilename = "example.dagcbor"
exampleDAGJSONFilename = "example.dagjson"
)
//go:embed testdata/example.ipldsch
var schemaBytes []byte
var (
once sync.Once
ts *schema.TypeSystem
err error
)
func mustLoadSchema() *schema.TypeSystem {
once.Do(func() {
ts, err = ipld.LoadSchemaBytes(schemaBytes)
})
if err != nil {
panic(fmt.Errorf("failed to load IPLD schema: %s", err))
}
return ts
}
func exampleType() schema.Type {
return mustLoadSchema().TypeByName(exampleTypeName)
}
var _ envelope.Tokener = (*Example)(nil)
type Example struct {
Hello string
Issuer string
}
func newExample(t *testing.T) *Example {
t.Helper()
return &Example{
Hello: exampleGreeting,
Issuer: exampleDID,
}
}
func (e *Example) Prototype() schema.TypedPrototype {
return bindnode.Prototype(e, exampleType())
}
func (*Example) Tag() string {
return exampleTag
}
func exampleGoldenNode(t *testing.T) datamodel.Node {
t.Helper()
cbor := golden.Get(t, exampleDAGCBORFilename)
node, err := ipld.Decode(cbor, dagcbor.Decode)
require.NoError(t, err)
return node
}
func examplePrivKey(t *testing.T) crypto.PrivKey {
t.Helper()
privKeyEnc, err := crypto.ConfigDecodeKey(examplePrivKeyCfg)
require.NoError(t, err)
privKey, err := crypto.UnmarshalPrivateKey(privKeyEnc)
require.NoError(t, err)
return privKey
}
func exampleSignature(t *testing.T) []byte {
t.Helper()
sig, err := base64.RawStdEncoding.DecodeString(exampleSignatureStr)
require.NoError(t, err)
return sig
}
func invalidNodeFromGolden(t *testing.T) datamodel.Node {
t.Helper()
invalidSig, err := base64.RawStdEncoding.DecodeString(invalidSignatureStr)
require.NoError(t, err)
envelNode := exampleGoldenNode(t)
sigPayloadNode, err := envelNode.LookupByIndex(1)
require.NoError(t, err)
node, err := qp.BuildList(basicnode.Prototype.Any, 2, func(la datamodel.ListAssembler) {
qp.ListEntry(la, qp.Bytes(invalidSig))
qp.ListEntry(la, qp.Node(sigPayloadNode))
})
require.NoError(t, err)
return node
}

View File

@@ -1,314 +0,0 @@
// Package envelope provides functions that convert between wire-format
// encoding of a [UCAN] token's [Envelope] and the Go type representing
// a verified [TokenPayload].
//
// Encoding functions in this package require a private key as a
// parameter so the VarsigHeader can be set and so that a
// cryptographic signature can be generated.
//
// Decoding functions in this package likewise perform the signature
// verification using a public key extracted from the TokenPayload as
// described by requirement two below.
//
// Types that wish to be marshaled and unmarshaled from the using
// is package have two requirements.
//
// 1. The type must implement the Tokener interface.
//
// 2. The IPLD Representation of the type must include an "iss"
// field when the TokenPayload is extracted from the Envelope.
// This field must contain the string representation of a
// "did:key" so that a public key can be extracted from the
//
// [Envelope]:https://github.com/ucan-wg/spec#envelope
// [TokenPayload]: https://github.com/ucan-wg/spec#envelope
// [UCAN]: https://ucan.xyz
package envelope
import (
"errors"
"io"
"github.com/ipld/go-ipld-prime"
"github.com/ipld/go-ipld-prime/codec"
"github.com/ipld/go-ipld-prime/codec/dagcbor"
"github.com/ipld/go-ipld-prime/codec/dagjson"
"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/ipld/go-ipld-prime/node/bindnode"
"github.com/ipld/go-ipld-prime/schema"
"github.com/libp2p/go-libp2p/core/crypto"
"github.com/ucan-wg/go-ucan/did"
"github.com/ucan-wg/go-ucan/internal/varsig"
)
const varsigHeaderKey = "h"
// Tokener must be implemented by types that wish to be enclosed in a
// UCAN Envelope (presumbably one of the UCAN token types).
type Tokener interface {
// Prototype provides the schema representation for an IPLD type so
// that the incoming datamodel.Kinds can be mapped to the appropriate
// schema.Kinds.
Prototype() schema.TypedPrototype
// Tag returns the expected key denoting the name of the IPLD node
// that should be processed as the token payload while decoding
// incoming bytes.
Tag() string
}
// Decode unmarshals the input data using the format specified by the
// provided codec.Decoder into a Tokener.
//
// An error is returned if the conversion fails, or if the resulting
// Tokener is invalid.
func Decode[T Tokener](b []byte, decFn codec.Decoder) (T, error) {
node, err := ipld.Decode(b, decFn)
if err != nil {
return *new(T), err
}
return FromIPLD[T](node)
}
// DecodeReader is the same as Decode, but accept an io.Reader.
func DecodeReader[T Tokener](r io.Reader, decFn codec.Decoder) (T, error) {
node, err := ipld.DecodeStreaming(r, decFn)
if err != nil {
return *new(T), err
}
return FromIPLD[T](node)
}
// FromDagCbor unmarshals the input data into a Tokener.
//
// An error is returned if the conversion fails, or if the resulting
// Tokener is invalid.
func FromDagCbor[T Tokener](b []byte) (T, error) {
undef := *new(T)
node, err := ipld.Decode(b, dagcbor.Decode)
if err != nil {
return undef, err
}
tkn, err := fromIPLD[T](node)
if err != nil {
return undef, err
}
return tkn, nil
}
// FromDagCborReader is the same as FromDagCbor, but accept an io.Reader.
func FromDagCborReader[T Tokener](r io.Reader) (T, error) {
return DecodeReader[T](r, dagcbor.Decode)
}
// FromDagJson unmarshals the input data into a Tokener.
//
// An error is returned if the conversion fails, or if the resulting
// Tokener is invalid.
func FromDagJson[T Tokener](b []byte) (T, error) {
return Decode[T](b, dagjson.Decode)
}
// FromDagJsonReader is the same as FromDagJson, but accept an io.Reader.
func FromDagJsonReader[T Tokener](r io.Reader) (T, error) {
return DecodeReader[T](r, dagjson.Decode)
}
// FromIPLD unwraps a Tokener from the provided IPLD datamodel.Node.
//
// An error is returned if the conversion fails, or if the resulting
// Tokener is invalid.
func FromIPLD[T Tokener](node datamodel.Node) (T, error) {
undef := *new(T)
tkn, err := fromIPLD[T](node)
if err != nil {
return undef, err
}
return tkn, nil
}
func fromIPLD[T Tokener](node datamodel.Node) (T, error) {
undef := *new(T)
signatureNode, err := node.LookupByIndex(0)
if err != nil {
return undef, err
}
signature, err := signatureNode.AsBytes()
if err != nil {
return undef, err
}
sigPayloadNode, err := node.LookupByIndex(1)
if err != nil {
return undef, err
}
varsigHeaderNode, err := sigPayloadNode.LookupByString(varsigHeaderKey)
if err != nil {
return undef, err
}
tokenPayloadNode, err := sigPayloadNode.LookupByString(undef.Tag())
if err != nil {
return undef, err
}
// This needs to be done before converting this node to its schema
// representation (afterwards, the field might be renamed os it's safer
// to use the wire name).
issuerNode, err := tokenPayloadNode.LookupByString("iss")
if err != nil {
return undef, err
}
// Replaces the datamodel.Node in tokenPayloadNode with a
// schema.TypedNode so that we can cast it to a *token.Token after
// unwrapping it.
nb := undef.Prototype().Representation().NewBuilder()
err = nb.AssignNode(tokenPayloadNode)
if err != nil {
return undef, err
}
tokenPayloadNode = nb.Build()
tokenPayload := bindnode.Unwrap(tokenPayloadNode)
if tokenPayload == nil {
return undef, errors.New("failed to Unwrap the TokenPayload")
}
tkn, ok := tokenPayload.(T)
if !ok {
return undef, errors.New("failed to assert the TokenPayload type as *token.Token")
}
// Check that the issuer's DID contains a public key with a type that
// matches the VarsigHeader and then verify the SigPayload.
issuer, err := issuerNode.AsString()
if err != nil {
return undef, err
}
issuerDID, err := did.Parse(issuer)
if err != nil {
return undef, err
}
issuerPubKey, err := issuerDID.PubKey()
if err != nil {
return undef, err
}
issuerVarsigHeader, err := varsig.Encode(issuerPubKey.Type())
if err != nil {
return undef, err
}
varsigHeader, err := varsigHeaderNode.AsBytes()
if err != nil {
return undef, err
}
if string(varsigHeader) != string(issuerVarsigHeader) {
return undef, errors.New("the VarsigHeader key type doesn't match the issuer's key type")
}
data, err := ipld.Encode(sigPayloadNode, dagcbor.Encode)
if err != nil {
return undef, err
}
ok, err = issuerPubKey.Verify(data, signature)
if err != nil || !ok {
return undef, errors.New("failed to verify the token's signature")
}
return tkn, nil
}
// Encode marshals a Tokener to the format specified by the provided
// codec.Encoder.
func Encode(privKey crypto.PrivKey, token Tokener, encFn codec.Encoder) ([]byte, error) {
node, err := ToIPLD(privKey, token)
if err != nil {
return nil, err
}
return ipld.Encode(node, encFn)
}
// EncodeWriter is the same as Encode but outputs to an io.Writer instead
// of encoding into a []byte.
func EncodeWriter(w io.Writer, privKey crypto.PrivKey, token Tokener, encFn codec.Encoder) error {
node, err := ToIPLD(privKey, token)
if err != nil {
return err
}
return ipld.EncodeStreaming(w, node, encFn)
}
// ToDagCbor marshals the Tokener to the DAG-CBOR format.
func ToDagCbor(privKey crypto.PrivKey, token Tokener) ([]byte, error) {
return Encode(privKey, token, dagcbor.Encode)
}
// ToDagCborWriter is the same as ToDagCbor but outputs to an io.Writer
// instead of encoding into a []byte.
func ToDagCborWriter(w io.Writer, privKey crypto.PrivKey, token Tokener) error {
return EncodeWriter(w, privKey, token, dagcbor.Encode)
}
// ToDagJson marshals the Tokener to the DAG-JSON format.
func ToDagJson(privKey crypto.PrivKey, token Tokener) ([]byte, error) {
return Encode(privKey, token, dagjson.Encode)
}
// ToDagJsonWriter is the same as ToDagJson but outputs to an io.Writer
// instead of encoding into a []byte.
func ToDagJsonWriter(w io.Writer, privKey crypto.PrivKey, token Tokener) error {
return EncodeWriter(w, privKey, token, dagjson.Encode)
}
// ToIPLD wraps the Tokener in an IPLD datamodel.Node.
func ToIPLD(privKey crypto.PrivKey, token Tokener) (datamodel.Node, error) {
tokenPayloadNode := bindnode.Wrap(token, token.Prototype().Type()).Representation()
varsigHeader, err := varsig.Encode(privKey.Type())
if err != nil {
return nil, err
}
sigPayloadNode, err := qp.BuildMap(basicnode.Prototype.Any, 2, func(ma datamodel.MapAssembler) {
qp.MapEntry(ma, varsigHeaderKey, qp.Bytes(varsigHeader))
qp.MapEntry(ma, token.Tag(), qp.Node(tokenPayloadNode))
})
data, err := ipld.Encode(sigPayloadNode, dagcbor.Encode)
if err != nil {
return nil, err
}
signature, err := privKey.Sign(data)
if err != nil {
return nil, err
}
return qp.BuildList(basicnode.Prototype.Any, 2, func(la datamodel.ListAssembler) {
qp.ListEntry(la, qp.Bytes(signature))
qp.ListEntry(la, qp.Node(sigPayloadNode))
})
}

View File

@@ -1,151 +0,0 @@
package envelope_test
import (
"bytes"
"crypto/sha256"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/ucan-wg/go-ucan/internal/envelope"
"gotest.tools/v3/golden"
)
func TestDecode(t *testing.T) {
t.Parallel()
t.Run("via FromDagCbor", func(t *testing.T) {
t.Parallel()
data := golden.Get(t, "example.dagcbor")
tkn, err := envelope.FromDagCbor[*Example](data)
require.NoError(t, err)
assert.Equal(t, exampleGreeting, tkn.Hello)
assert.Equal(t, exampleDID, tkn.Issuer)
})
t.Run("via FromDagJson", func(t *testing.T) {
t.Parallel()
data := golden.Get(t, "example.dagjson")
tkn, err := envelope.FromDagJson[*Example](data)
require.NoError(t, err)
assert.Equal(t, exampleGreeting, tkn.Hello)
assert.Equal(t, exampleDID, tkn.Issuer)
})
}
func TestEncode(t *testing.T) {
t.Parallel()
t.Run("via ToDagCbor", func(t *testing.T) {
t.Parallel()
data, err := envelope.ToDagCbor(examplePrivKey(t), newExample(t))
require.NoError(t, err)
golden.AssertBytes(t, data, exampleDAGCBORFilename)
})
t.Run("via ToDagJson", func(t *testing.T) {
t.Parallel()
data, err := envelope.ToDagJson(examplePrivKey(t), newExample(t))
require.NoError(t, err)
golden.Assert(t, string(data), exampleDAGJSONFilename)
})
}
func TestRoundtrip(t *testing.T) {
t.Parallel()
t.Run("via FromDagCbor/ToDagCbor", func(t *testing.T) {
t.Parallel()
dataIn := golden.Get(t, exampleDAGCBORFilename)
tkn, err := envelope.FromDagCbor[*Example](dataIn)
require.NoError(t, err)
assert.Equal(t, exampleGreeting, tkn.Hello)
assert.Equal(t, exampleDID, tkn.Issuer)
dataOut, err := envelope.ToDagCbor(examplePrivKey(t), newExample(t))
require.NoError(t, err)
assert.Equal(t, dataIn, dataOut)
})
t.Run("via FromDagCborReader/ToDagCborWriter", func(t *testing.T) {
t.Parallel()
data := golden.Get(t, exampleDAGCBORFilename)
tkn, err := envelope.FromDagCborReader[*Example](bytes.NewReader(data))
require.NoError(t, err)
assert.Equal(t, exampleGreeting, tkn.Hello)
assert.Equal(t, exampleDID, tkn.Issuer)
w := &bytes.Buffer{}
require.NoError(t, envelope.ToDagCborWriter(w, examplePrivKey(t), newExample(t)))
assert.Equal(t, data, w.Bytes())
})
t.Run("via FromDagJson/ToDagJson", func(t *testing.T) {
t.Parallel()
dataIn := golden.Get(t, exampleDAGJSONFilename)
tkn, err := envelope.FromDagJson[*Example](dataIn)
require.NoError(t, err)
assert.Equal(t, exampleGreeting, tkn.Hello)
assert.Equal(t, exampleDID, tkn.Issuer)
dataOut, err := envelope.ToDagJson(examplePrivKey(t), newExample(t))
require.NoError(t, err)
assert.Equal(t, dataIn, dataOut)
})
t.Run("via FromDagJsonReader/ToDagJsonrWriter", func(t *testing.T) {
t.Parallel()
data := golden.Get(t, exampleDAGJSONFilename)
tkn, err := envelope.FromDagJsonReader[*Example](bytes.NewReader(data))
require.NoError(t, err)
assert.Equal(t, exampleGreeting, tkn.Hello)
assert.Equal(t, exampleDID, tkn.Issuer)
w := &bytes.Buffer{}
require.NoError(t, envelope.ToDagJsonWriter(w, examplePrivKey(t), newExample(t)))
assert.Equal(t, data, w.Bytes())
})
}
func TestFromIPLD_with_invalid_signature(t *testing.T) {
t.Parallel()
node := invalidNodeFromGolden(t)
tkn, err := envelope.FromIPLD[*Example](node)
assert.Nil(t, tkn)
require.EqualError(t, err, "failed to verify the token's signature")
}
func TestHash(t *testing.T) {
t.Parallel()
msg := []byte("this is a test")
hash1 := sha256.Sum256(msg)
hasher := sha256.New()
for _, b := range msg {
hasher.Write([]byte{b})
}
hash2 := hasher.Sum(nil)
hash3 := hasher.Sum(nil)
require.Equal(t, hash1[:], hash2)
require.Equal(t, hash1[:], hash3)
}

View File

@@ -1 +1 @@
X@|úŸÀ½â–ðõ+ÁŠ­!µ.®ÿhéÍúGO- ü¬”jÉssY¨quëiþ“ä°¬Íuý#ò¼’ç˜ c¢ahD4íqxucan/example@v1.0.0-rc.1¢cissx8did:key:z6MkpuK2Amsu1RqcLGgmHHQHhvmeXCCBVsM4XFSg2cCyg4Nhehelloeworld
X@=•zfˆîŒ— ©Ê¦Z.ÈÚàP óú¤Jr=n¬;¡)Dñ÷™¹6îB;ò

View File

@@ -1 +1,20 @@
[{"/":{"bytes":"fPqfwL3iFpbw9SvBiq0DIbUurv9o6c36R08tC/yslGrJcwV51ghzWahxdetpEf6T5LCszXX9I/K8khvnmAxjAg"}},{"h":{"/":{"bytes":"NO0BcQ"}},"ucan/example@v1.0.0-rc.1":{"hello":"world","iss":"did:key:z6MkpuK2Amsu1RqcLGgmHHQHhvmeXCCBVsM4XFSg2cCyg4Nh"}}]
[
{
"/": {
"bytes": "PZV6A2aI7n+MlyADqcqmWhkuyNrgUCDz+qSLSnI9bpasOwOhKUTx95m5Nu5CO/INa1LqzHGioD9+PVf6qdtTBg"
}
},
{
"h": {
"/": {
"bytes": "NO0BcQ"
}
},
"ucan/example@v1.0.0-rc.1": {
"cmd": "",
"exp": null,
"iss": "did:key:z6MkpuK2Amsu1RqcLGgmHHQHhvmeXCCBVsM4XFSg2cCyg4Nh",
"sub": null
}
}
]

View File

@@ -1,6 +0,0 @@
type DID string
type Example struct {
hello String
issuer DID (rename "iss")
}

View File

@@ -0,0 +1,24 @@
package token
import (
"github.com/ipld/go-ipld-prime/datamodel"
)
func ToIPLDMapStringAny(m map[string]datamodel.Node) Map__String__Any {
keys := make([]string, len(m))
i := 0
for k := range m {
keys[i] = k
i++
}
return Map__String__Any{
Keys: keys,
Values: m,
}
}
func FromIPLDMapStringAny(m Map__String__Any) map[string]datamodel.Node {
return m.Values
}

33
internal/token/doc.go Normal file
View File

@@ -0,0 +1,33 @@
// Package token provides a generic model of the [TokenPayload] required
// within an Envelope.
//
// # Field requirements
//
// While the Token object represents the wire format of both a UCAN
// Delegation token and a UCAN Invocation token, the delegation and
// invocation packages are, respectively, responsible for making sure
// required fields are included when creating a new Token or when
// validating the contents of an Envelope as it's received from
// another party. The following table shows the current (as of
// 2024-09-11) relationship between optional and nullable fields in
// the delegation and invocation views and the payload model:
//
// | Name | Delegation | Invocation | Token |
// | | Required | Nullable | Required | Nullable | |
// | ----- | -------- | -------- | -------- | -------- | -------- |
// | iss | Yes | No | Yes | No | |
// | aud | Yes | No | No | N/A | Optional |
// | sub | Yes | Yes | Yes | No | Nullable |
// | cmd | Yes | No | Yes | No | |
// | pol | Yes | No | X | X | Optional |
// | nonce | Yes | No | No | N/A | Optional |
// | meta | No | N/A | No | N/A | Optional |
// | nbf | No | N/A | X | X | Optional |
// | exp | Yes | Yes | Yes | Yes | |
// | args | X | X | Yes | No | Optional |
// | prf | X | X | Yes | No | Optional |
// | iat | X | X | No | N/A | Optional |
// | cause | X | X | No | N/A | Optional |
//
// [TokenPayload]: https://github.com/ucan-wg/spec?tab=readme-ov-file#envelope
package token

11
internal/token/errors.go Normal file
View File

@@ -0,0 +1,11 @@
package token
import "errors"
var ErrFailedSchemaLoad = errors.New("failed to load IPLD Schema")
var ErrNoSchemaType = errors.New("schema does not contain type")
var ErrNodeNotToken = errors.New("IPLD node is not a Token")
var ErrMissingRequiredDID = errors.New("a required DID is missing")

46
internal/token/schema.go Normal file
View File

@@ -0,0 +1,46 @@
package token
import (
_ "embed"
"fmt"
"sync"
"github.com/ipld/go-ipld-prime"
"github.com/ipld/go-ipld-prime/node/bindnode"
"github.com/ipld/go-ipld-prime/schema"
)
const tokenTypeName = "Token"
//go:embed token.ipldsch
var schemaBytes []byte
var (
once sync.Once
ts *schema.TypeSystem
err error
)
func mustLoadSchema() *schema.TypeSystem {
once.Do(func() {
ts, err = ipld.LoadSchemaBytes(schemaBytes)
})
if err != nil {
panic(fmt.Errorf("%w: %w", ErrFailedSchemaLoad, err))
}
tknType := ts.TypeByName(tokenTypeName)
if tknType == nil {
panic(fmt.Errorf("%w: %s", ErrNoSchemaType, tokenTypeName))
}
return ts
}
func tokenType() schema.Type {
return mustLoadSchema().TypeByName(tokenTypeName)
}
func Prototype() schema.TypedPrototype {
return bindnode.Prototype((*Token)(nil), tokenType())
}

View File

@@ -0,0 +1,84 @@
package token_test
import (
_ "embed"
"fmt"
"testing"
"github.com/ipld/go-ipld-prime"
"github.com/ipld/go-ipld-prime/codec/dagcbor"
"github.com/ipld/go-ipld-prime/codec/dagjson"
"github.com/stretchr/testify/require"
"github.com/ucan-wg/go-ucan/internal/token"
)
//go:embed token.ipldsch
var schemaBytes []byte
func TestSchemaRoundTrip(t *testing.T) {
const delegationJson = `
{
"aud":"did:key:def456",
"cmd":"/foo/bar",
"exp":123456,
"iss":"did:key:abc123",
"meta":{
"bar":"baaar",
"foo":"fooo"
},
"nbf":123456,
"nonce":{
"/":{
"bytes":"c3VwZXItcmFuZG9t"
}
},
"pol":[
["==", ".status", "draft"],
["all", ".reviewer", [
["like", ".email", "*@example.com"]]
],
["any", ".tags", [
["or", [
["==", ".", "news"],
["==", ".", "press"]]
]]
]
],
"sub":""
}
`
// format: dagJson --> IPLD node --> token --> dagCbor --> IPLD node --> dagJson
// function: Unwrap() Wrap()
n1, err := ipld.DecodeUsingPrototype([]byte(delegationJson), dagjson.Decode, token.Prototype())
require.NoError(t, err)
cborBytes, err := ipld.Encode(n1, dagcbor.Encode)
require.NoError(t, err)
fmt.Println("cborBytes length", len(cborBytes))
fmt.Println("cbor", string(cborBytes))
n2, err := ipld.DecodeUsingPrototype(cborBytes, dagcbor.Decode, token.Prototype())
require.NoError(t, err)
fmt.Println("read Cbor", n2)
t1, err := token.Unwrap(n2)
require.NoError(t, err)
n3 := t1.Wrap()
readJson, err := ipld.Encode(n3, dagjson.Encode)
require.NoError(t, err)
fmt.Println("readJson length", len(readJson))
fmt.Println("json: ", string(readJson))
require.JSONEq(t, delegationJson, string(readJson))
}
func BenchmarkSchemaLoad(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_, _ = ipld.LoadSchemaBytes(schemaBytes)
}
}

33
internal/token/token.go Normal file
View File

@@ -0,0 +1,33 @@
package token
import (
"github.com/ipld/go-ipld-prime/datamodel"
"github.com/ipld/go-ipld-prime/node/bindnode"
)
//go:generate go run ../cmd/token/...
// Unwrap creates a Token from an arbitrary IPLD node or returns an
// error if at least the required model fields are not present.
//
// It is the responsibility of the Delegation and Invocation views
// to further validate the presence of the required fields and the
// content as needed.
func Unwrap(node datamodel.Node) (*Token, error) {
iface := bindnode.Unwrap(node)
if iface == nil {
return nil, ErrNodeNotToken
}
tkn, ok := iface.(*Token)
if !ok {
return nil, ErrNodeNotToken
}
return tkn, nil
}
// Wrap creates an IPLD node representing the Token.
func (t *Token) Wrap() datamodel.Node {
return bindnode.Wrap(t, tokenType())
}

View File

@@ -0,0 +1,63 @@
type CID string
type Command string
type DID string
# Field requirements:
#
# | Name | Delegation | Invocation | Token |
# | | Required | Nullable | Required | Nullable | |
# | ----- | -------- | -------- | -------- | -------- | -------- |
# | iss | Yes | No | Yes | No | |
# | aud | Yes | No | No | N/A | Optional |
# | sub | Yes | Yes | Yes | No | Nullable |
# | cmd | Yes | No | Yes | No | |
# | pol | Yes | No | X | X | Optional |
# | nonce | Yes | No | No | N/A | Optional |
# | meta | No | N/A | No | N/A | Optional |
# | nbf | No | N/A | X | X | Optional |
# | exp | Yes | Yes | Yes | Yes | Nullable |
# | args | X | X | Yes | No | Optional |
# | prf | X | X | Yes | No | Optional |
# | iat | X | X | No | N/A | Optional |
# | cause | X | X | No | N/A | Optional |
type Token struct {
# Issuer DID (sender)
issuer DID (rename "iss")
# Audience DID (receiver)
audience optional DID (rename "aud")
# Principal that the chain is about (the Subject)
subject nullable DID (rename "sub")
# The Command to eventually invoke
command Command (rename "cmd")
# The delegation policy
# It doesn't seem possible to represent it with a schema.
policy optional Any (rename "pol")
# The invocation's arguments
arguments optional {String: Any} (rename "args")
# Delegations that prove the chain of authority
Proofs optional [CID] (rename "prf")
# A unique, random nonce
nonce optional Bytes
# Arbitrary Metadata
meta optional {String : Any}
# "Not before" UTC Unix Timestamp in seconds (valid from), 53-bits integer
notBefore optional Int (rename "nbf")
# The timestamp at which the delegation becomes invalid
expiration nullable Int (rename "exp")
# The timestamp at which the invocation was created
issuedAt optional Int
# An optional CID of the receipt that enqueued this invocation
cause optional CID
}

View File

@@ -0,0 +1,31 @@
// Code generated by internal/cmd/token - DO NOT EDIT.
package token
import "github.com/ipld/go-ipld-prime/datamodel"
type Map struct {
Keys []string
Values map[string]datamodel.Node
}
type List []datamodel.Node
type Map__String__Any struct {
Keys []string
Values map[string]datamodel.Node
}
type List__CID []string
type Token struct {
Issuer string
Audience *string
Subject *string
Command string
Policy *datamodel.Node
Arguments *Map__String__Any
Proofs *List__CID
Nonce *[]uint8
Meta *Map__String__Any
NotBefore *int
Expiration *int
IssuedAt *int
Cause *string
}

View File

@@ -0,0 +1,55 @@
package token_test
import (
"testing"
"github.com/ipld/go-ipld-prime"
"github.com/ipld/go-ipld-prime/codec/dagjson"
"github.com/stretchr/testify/require"
"github.com/ucan-wg/go-ucan/internal/token"
)
func TestEncode(t *testing.T) {
t.Parallel()
tkn := &token.Token{}
node := tkn.Wrap()
json, err := ipld.Encode(node, dagjson.Encode)
require.NoError(t, err)
t.Log(string(json))
t.Fail()
}
func TestPrototype(t *testing.T) {
t.Parallel()
tkn := &token.Token{
Issuer: "blah",
}
n1 := tkn.Wrap()
json, err := ipld.Encode(n1, dagjson.Encode)
require.NoError(t, err)
t.Log(string(json))
n2, err := ipld.Decode(json, dagjson.Decode)
require.NoError(t, err)
nb := token.Prototype().Representation().NewBuilder()
require.NoError(t, nb.AssignNode(n2))
n3 := nb.Build()
tkn2, err := token.Unwrap(n3)
require.NoError(t, err)
t.Log(tkn2)
require.Equal(t, tkn, tkn2)
t.Fail()
}

7
internal/tools/tools.go Normal file
View File

@@ -0,0 +1,7 @@
//go:build tools
package tools
import (
_ "github.com/selesy/go-options"
)

View File

@@ -24,6 +24,7 @@
package varsig
import (
"encoding/base64"
"encoding/binary"
"errors"
"fmt"
@@ -57,7 +58,7 @@ var (
//
// [go-libp2p/core/crypto]: github.com/libp2p/go-libp2p/core/crypto
func Decode(header []byte) (pb.KeyType, error) {
keyType, ok := decMap[string(header)]
keyType, ok := decMap[base64.RawStdEncoding.EncodeToString(header)]
if !ok {
return -1, fmt.Errorf("%w: %s", ErrUnknownHeader, header)
}
@@ -81,10 +82,10 @@ func Encode(keyType pb.KeyType) ([]byte, error) {
return []byte(header), nil
}
func keyTypeToHeader() map[pb.KeyType]string {
func keyTypeToHeader() map[pb.KeyType][]byte {
const rsaSigLen = 0x100
return map[pb.KeyType]string{
return map[pb.KeyType][]byte{
pb.KeyType_RSA: header(
Prefix,
multicodec.RsaPub,
@@ -116,18 +117,18 @@ func headerToKeyType() map[string]pb.KeyType {
out := make(map[string]pb.KeyType, len(encMap))
for keyType, header := range encMap {
out[header] = keyType
out[base64.RawStdEncoding.EncodeToString(header)] = keyType
}
return out
}
func header(vals ...multicodec.Code) string {
func header(vals ...multicodec.Code) []byte {
var buf []byte
for _, val := range vals {
buf = binary.AppendUvarint(buf, uint64(val))
}
return string(buf)
return buf
}

View File

@@ -1,120 +0,0 @@
package meta
import (
"errors"
"github.com/ipld/go-ipld-prime"
"github.com/ipld/go-ipld-prime/datamodel"
"github.com/ipld/go-ipld-prime/node/basicnode"
)
var ErrNotFound = errors.New("key-value not found in meta")
// Meta is a container for meta key-value pairs in a UCAN token.
// This also serves as a way to construct the underlying IPLD data with minimum allocations and transformations,
// while hiding the IPLD complexity from the caller.
type Meta struct {
Keys []string
Values map[string]ipld.Node
}
// NewMeta constructs a new Meta.
func NewMeta() *Meta {
return &Meta{Values: map[string]ipld.Node{}}
}
// GetBool retrieves a value as a bool.
// Returns ErrNotFound if the given key is missing.
// Returns datamodel.ErrWrongKind if the value has the wrong type.
func (m *Meta) GetBool(key string) (bool, error) {
v, ok := m.Values[key]
if !ok {
return false, ErrNotFound
}
return v.AsBool()
}
// GetString retrieves a value as a string.
// Returns ErrNotFound if the given key is missing.
// Returns datamodel.ErrWrongKind if the value has the wrong type.
func (m *Meta) GetString(key string) (string, error) {
v, ok := m.Values[key]
if !ok {
return "", ErrNotFound
}
return v.AsString()
}
// GetInt64 retrieves a value as an int64.
// Returns ErrNotFound if the given key is missing.
// Returns datamodel.ErrWrongKind if the value has the wrong type.
func (m *Meta) GetInt64(key string) (int64, error) {
v, ok := m.Values[key]
if !ok {
return 0, ErrNotFound
}
return v.AsInt()
}
// GetFloat64 retrieves a value as a float64.
// Returns ErrNotFound if the given key is missing.
// Returns datamodel.ErrWrongKind if the value has the wrong type.
func (m *Meta) GetFloat64(key string) (float64, error) {
v, ok := m.Values[key]
if !ok {
return 0, ErrNotFound
}
return v.AsFloat()
}
// GetBytes retrieves a value as a []byte.
// Returns ErrNotFound if the given key is missing.
// Returns datamodel.ErrWrongKind if the value has the wrong type.
func (m *Meta) GetBytes(key string) ([]byte, error) {
v, ok := m.Values[key]
if !ok {
return nil, ErrNotFound
}
return v.AsBytes()
}
// GetNode retrieves a value as a raw IPLD node.
// Returns ErrNotFound if the given key is missing.
// Returns datamodel.ErrWrongKind if the value has the wrong type.
func (m *Meta) GetNode(key string) (ipld.Node, error) {
v, ok := m.Values[key]
if !ok {
return nil, ErrNotFound
}
return v, nil
}
// Add adds a key/value pair in the meta set.
// Accepted types for the value are: bool, string, int, int32, int64, []byte,
// and ipld.Node.
func (m *Meta) Add(key string, val any) error {
switch val := val.(type) {
case bool:
m.Values[key] = basicnode.NewBool(val)
case string:
m.Values[key] = basicnode.NewString(val)
case int:
m.Values[key] = basicnode.NewInt(int64(val))
case int32:
m.Values[key] = basicnode.NewInt(int64(val))
case int64:
m.Values[key] = basicnode.NewInt(val)
case float32:
m.Values[key] = basicnode.NewFloat(float64(val))
case float64:
m.Values[key] = basicnode.NewFloat(val)
case []byte:
m.Values[key] = basicnode.NewBytes(val)
case datamodel.Node:
m.Values[key] = val
default:
panic("invalid value type")
}
m.Keys = append(m.Keys, key)
return nil
}