diff --git a/delegate.go b/delegate.go new file mode 100644 index 0000000..8f67007 --- /dev/null +++ b/delegate.go @@ -0,0 +1,124 @@ +package ucan + +import ( + "crypto/rand" + "time" + + "github.com/libp2p/go-libp2p/core/crypto" + "github.com/ucan-wg/go-ucan/v1/capability/command" + "github.com/ucan-wg/go-ucan/v1/capability/policy" + "github.com/ucan-wg/go-ucan/v1/delegation" + "github.com/ucan-wg/go-ucan/v1/did" + "github.com/ucan-wg/go-ucan/v1/internal/envelope" + "github.com/ucan-wg/go-ucan/v1/issue" +) + +const ( + DefaultExpiration = 30 * 24 * time.Hour + DefaultNonceLength = 32 +) + +//go:generate -command options go run github.com/launchdarkly/go-options +//go:generate options -type=authorityConfig -option=AuthorityOption -prefix=With -output=authority_options.go -cmp=false -new=false -imports=time + +type authorityConfig struct { + expiration time.Duration + nonceLength int +} + +type Authority struct { + *authorityConfig + privKey crypto.PrivKey + did did.DID // TODO +} + +func NewAuthority(privKey crypto.PrivKey, opts ...AuthorityOption) (*Authority, error) { + cfg := &authorityConfig{ + expiration: DefaultExpiration, + nonceLength: DefaultNonceLength, + } + + if err := applyAuthorityConfigOptions(cfg, opts...); err != nil { + return nil, err + } + + id, err := did.FromPubKey(privKey.GetPublic()) + if err != nil { + return nil, err + } + + return &Authority{ + authorityConfig: cfg, + privKey: privKey, + did: id, + }, nil +} + +func (a *Authority) DID() did.DID { + return a.did +} + +func (a *Authority) Expiration() time.Duration { + return a.expiration +} + +func (a *Authority) NonceLength() int { + return a.nonceLength +} + +func (a *Authority) Delegate(aud did.DID, prf []delegation.Token, cmd *command.Command, pol *policy.Policy, exp *time.Time, opts ...delegation.Option) (*envelope.Envelope[*delegation.Token], error) { + nonce, err := a.Nonce() + if err != nil { + return nil, err + } + + tkn, err := delegation.New(a.DID(), aud, prf, cmd, pol, nil, nonce, opts...) + if err != nil { + return nil, err + } + + return envelope.New(a.privKey, tkn) +} + +func (a *Authority) Nonce() ([]byte, error) { + nonce := make([]byte, a.nonceLength) + + if _, err := rand.Read(nonce); err != nil { + return nil, err + } + + return nonce, nil +} + +// Issue creates a root UCAN token that can later be delegated. +// +// A subject is required when creating a root UCAN delegation as root +// UCAN delegation tokens should never be "Powerlined" per the +// specification. Therefore, the inclusion of the WithSubject or WithPowerline +// options will result in an error. +// +// Issuing a root UCAN delegation token +// should be a relatively rare occurrence, so this method is not +// available via an Authority. +func Issue(privKey crypto.PrivKey, sub did.DID, cmd *command.Command, pol *policy.Policy, exp *time.Time, opts ...issue.Option) (*envelope.Envelope[*delegation.Token], error) { // TODO: cmd as pointer? + delOpts, err := issue.ToDelegateOptions(sub, opts...) + if err != nil { + return nil, err + } + + authority, err := NewAuthority(privKey) + if err != nil { + return nil, err + } + + return authority.Delegate(authority.DID(), nil, cmd, pol, nil, delOpts...) +} + +func Delegate(privKey crypto.PrivKey, aud did.DID, prf []delegation.Token, cmd *command.Command, pol *policy.Policy, exp *time.Time, opts ...delegation.Option) (*envelope.Envelope[*delegation.Token], error) { + authority, err := NewAuthority(privKey) + if err != nil { + return nil, err + } + + return authority.Delegate(aud, prf, cmd, pol, exp, opts...) +} diff --git a/delegate_test.go b/delegate_test.go new file mode 100644 index 0000000..1a6305f --- /dev/null +++ b/delegate_test.go @@ -0,0 +1,159 @@ +package ucan_test + +import ( + "crypto/rand" + "fmt" + "testing" + "time" + + "github.com/ipld/go-ipld-prime" + "github.com/ipld/go-ipld-prime/codec/dagjson" + "github.com/ipld/go-ipld-prime/schema" + "github.com/libp2p/go-libp2p/core/crypto" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/ucan-wg/go-ucan/v1" + "github.com/ucan-wg/go-ucan/v1/capability/command" + "github.com/ucan-wg/go-ucan/v1/capability/policy" + "github.com/ucan-wg/go-ucan/v1/did" +) + +const ( + ed25519PrivKeyCfg = "CAESQL1hvbXpiuk2pWr/XFbfHJcZNpJ7S90iTA3wSCTc/BPRneCwPnCZb6c0vlD6ytDWqaOt0HEOPYnqEpnzoBDprSM=" + ed25519DID = "did:key:z6Mkq5YmbJcTrPExNDi26imrTCpKhepjBFBSHqrBDN2ArPkv" + + issuerPrivKeyCfg = "CAESQLSql38oDmQXIihFFaYIjb73mwbPsc7MIqn4o8PN4kRNnKfHkw5gRP1IV9b6d0estqkZayGZ2vqMAbhRixjgkDU=" + issuerDID = "did:key:z6Mkpzn2n3ZGT2VaqMGSQC3tzmzV4TS9S71iFsDXE1WnoNH2" + + subjectPrivKeyCfg = "CAESQL9RtjZ4dQBeXtvDe53UyvslSd64kSGevjdNiA1IP+hey5i/3PfRXSuDr71UeJUo1fLzZ7mGldZCOZL3gsIQz5c=" + subjectDID = "did:key:z6MktA1uBdCpq4uJBqE9jjMiLyxZBg9a6xgPPKJjMqss6Zc2" + subJectCmd = "/foo/bar" + subjectPol = ` +[ + ["==", ".status", "draft"], + ["all", ".reviewer", [ + ["like", ".email", "*@example.com"]] + ], + ["any", ".tags", [ + ["or", [ + ["==", ".", "news"], + ["==", ".", "press"]] + ]] + ] +] +` +) + +func TestNewAuthority(t *testing.T) { + t.Parallel() + + t.Run("with default configuration", func(t *testing.T) { + t.Parallel() + + authority := authority(t, ed25519PrivKeyCfg) + assert.Equal(t, ed25519DID, authority.DID().String()) + assert.Equal(t, ucan.DefaultNonceLength, authority.NonceLength()) + assert.Equal(t, ucan.DefaultExpiration, authority.Expiration()) + }) +} + +func TestAuthority_Nonce(t *testing.T) { + t.Parallel() + + fixture := func(t *testing.T, exp int, opts ...ucan.AuthorityOption) { + authority := authority(t, ed25519PrivKeyCfg, opts...) + + nonce, err := authority.Nonce() + require.NoError(t, err) + assert.Len(t, nonce, exp) + } + + t.Run("with default nonce length", func(t *testing.T) { + t.Parallel() + fixture(t, ucan.DefaultNonceLength) + }) + + t.Run("with custom nonce length", func(t *testing.T) { + t.Parallel() + fixture(t, 64, ucan.WithNonceLength(64)) + }) +} + +func TestIssue(t *testing.T) { + t.Parallel() + + privKey := privKey(t, issuerPrivKeyCfg) + + id, err := did.Parse(subjectDID) + require.NoError(t, err) + + cmd, err := command.Parse(subJectCmd) + require.NoError(t, err) + + pol, err := policy.FromDagJson(subjectPol) + require.NoError(t, err) + + now := time.Now().Add(ucan.DefaultExpiration) + + // meta := map[string]any{ + // "foo": "fooo", + // "bar": "barr", + // } + + env, err := ucan.Issue(privKey, id, cmd, &pol, &now) + require.NoError(t, err) + + node, err := env.Wrap() + + typed, ok := node.(schema.TypedNode) + require.True(t, ok) + + json, err := ipld.Encode(typed.Representation(), dagjson.Encode) + require.NoError(t, err) + + fmt.Println(string(json)) + + t.Fail() +} + +func authority(t *testing.T, privKeyCfg string, opts ...ucan.AuthorityOption) *ucan.Authority { + t.Helper() + + privKey := privKey(t, ed25519PrivKeyCfg) + + authority, err := ucan.NewAuthority(privKey, opts...) + require.NoError(t, err) + + return authority +} + +func privKey(t *testing.T, privKeyCfg string) crypto.PrivKey { + t.Helper() + + privKeyMar, err := crypto.ConfigDecodeKey(privKeyCfg) + require.NoError(t, err) + + privKey, err := crypto.UnmarshalPrivateKey(privKeyMar) + require.NoError(t, err) + + 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() +} diff --git a/doc.go b/doc.go new file mode 100644 index 0000000..1a95113 --- /dev/null +++ b/doc.go @@ -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 diff --git a/issue/issue.go b/issue/issue.go new file mode 100644 index 0000000..bca4c37 --- /dev/null +++ b/issue/issue.go @@ -0,0 +1,31 @@ +package issue + +import ( + "time" + + "github.com/ucan-wg/go-ucan/v1/delegation" + "github.com/ucan-wg/go-ucan/v1/did" +) + +//go:generate -command options go run github.com/launchdarkly/go-options +//go:generate options -type=config -prefix=With -output=issue_options.go -cmp=false -imports=time + +type config struct { + Meta map[string]any + NoExpiration bool + NotBefore time.Time +} + +func ToDelegateOptions(sub did.DID, opts ...Option) ([]delegation.Option, error) { + cfg, err := newConfig(opts...) + if err != nil { + return nil, err + } + + return []delegation.Option{ + delegation.WithSubject(&sub), + delegation.WithMeta(cfg.Meta), + delegation.WithNoExpiration(cfg.NoExpiration), + delegation.WithNotBefore(&cfg.NotBefore), + }, nil +} diff --git a/issue/issue_options.go b/issue/issue_options.go new file mode 100644 index 0000000..c1f5693 --- /dev/null +++ b/issue/issue_options.go @@ -0,0 +1,103 @@ +package issue + +// Code generated by github.com/launchdarkly/go-options. DO NOT EDIT. + +import "fmt" + +import ( + "time" +) + +type ApplyOptionFunc func(c *config) error + +func (f ApplyOptionFunc) apply(c *config) error { + return f(c) +} + +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.apply(c); err != nil { + return err + } + } + return nil +} + +type Option interface { + apply(*config) error +} + +type withMetaImpl struct { + o map[string]any +} + +func (o withMetaImpl) apply(c *config) error { + c.Meta = o.o + return nil +} + +func (o withMetaImpl) String() string { + name := "WithMeta" + + // hack to avoid go vet error about passing a function to Sprintf + var value interface{} = o.o + return fmt.Sprintf("%s: %+v", name, value) +} + +func WithMeta(o map[string]any) Option { + return withMetaImpl{ + o: o, + } +} + +type withNoExpirationImpl struct { + o bool +} + +func (o withNoExpirationImpl) apply(c *config) error { + c.NoExpiration = o.o + return nil +} + +func (o withNoExpirationImpl) String() string { + name := "WithNoExpiration" + + // hack to avoid go vet error about passing a function to Sprintf + var value interface{} = o.o + return fmt.Sprintf("%s: %+v", name, value) +} + +func WithNoExpiration(o bool) Option { + return withNoExpirationImpl{ + o: o, + } +} + +type withNotBeforeImpl struct { + o time.Time +} + +func (o withNotBeforeImpl) apply(c *config) error { + c.NotBefore = o.o + return nil +} + +func (o withNotBeforeImpl) String() string { + name := "WithNotBefore" + + // hack to avoid go vet error about passing a function to Sprintf + var value interface{} = o.o + return fmt.Sprintf("%s: %+v", name, value) +} + +func WithNotBefore(o time.Time) Option { + return withNotBeforeImpl{ + o: o, + } +}