Skip to content
/ actor Public

A high-performance, capability-based actor system for Go with UCAN authorization and libp2p networking support

Notifications You must be signed in to change notification settings

depinkit/actor

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

41 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

NuActor: Secure Actor Programming in Decentralized Systems

NuActor is a framework designed for secure actor oriented programming in decentralized systems. The framework utilizes zero trust interactions, whereby every message is authenticated individually at the point of interaction. The system supports fine-grained capabilities, anchored in decentralized identifiers (see DID) and effected with user controlled authorization networks (see UCAN).

Secure Interactions in Decentralized Systems

Decentralized systems are distributed systems where there are different stakeholders and controlling entities who are mutually distrustful. Actors are ideally suited for modeling and programming such systems, as they are able to express concurrency, distribution, and agency on behalf of their controllers.

However, given the open ended computing nature of decentralized systems, there is a fundamental problem in securing interactions. Because the system is open, there is effectively no perimeter; the messages are coming from the Internet, and can potentially originate in malicious or hostile actors.

NuActor takes the following approach:

  • The only entity an actor can fully trust is itself and its controller.
  • All messages invoking a behavior carry with them capability tokens that authorize them to perform the invocation.
  • Invocations are checked at dispatch so that it is always verified whether an invocation is allowed, anchored on the entities the actor trusts for the required capabilities.
  • There is no central authority; every entity (identified by a DID) can issue their own capability tokens and anchor trust wherever they want.
  • There are certain entities in the open public networks that may be marginally trusted to vet users (KYC) for invoking public behaviors. The set of such entities is open, and everyone is free to trust whoever they want. The creators of the network at bootstrap are good candidates for such entities.
  • Trust is ephemeral and can be revoked at all times.
  • In effect, users are in control of authorization in the network (UCAN!)

Capabilities

Behaviors and the Capability Namespace

Capabilities are defined in a hierarchical namespace, akin to the UNIX file system structure. The root capability, which implicitly has all other capabilities, is /. Every other capability extends this path, separating the namespace with additional /s. A capability is narrower than another if it is a subpath in the UNIX sense. So /A implies /A/B and so on, but /A does not imply /B.

Behaviors have names that directly map to capabilities. So the behavior namespace is also hierarchical, allowing for easy automated matching of behaviors to capabilities.

Capability Tokens

Capabilities are expressed with a token, which is a structured object signed by the private key of the issuer. The issuer is in the token as a DID, which allows any entity inspecting the token to verify by retrieving the public key associated with the DID. Typically these are key DIDs, which embed the public key directly.

The structure of the token is as following:

type Token struct {
	// DMS tokens
	DMS *DMSToken `json:"dms,omitempty"`
}

type DMSToken struct {
	Action     Action       `json:"act"`
	Issuer     did.DID      `json:"iss"`
	Subject    did.DID      `json:"sub"`
	Audience   did.DID      `json:"aud"`
	Topic      []Capability `json:"topic,omitempty"`
	Capability []Capability `json:"cap"`
	Nonce      []byte       `json:"nonce"`
	Expire     uint64       `json:"exp"`
	Depth      uint64       `json:"depth,omitempty"`
	Chain      *Token       `json:"chain,omitempty"`
	Signature  []byte       `json:"sig,omitempty"`
}

The Subject is the DID of the entity to which the Issuer grants (if the chain is empty) or delegates the capabilities listed in the Capability field and the broadcast topics listed in the Topic field. The audience may be empty, but when present it restricts the receiver of invocations to a specified entity.

The Action can be any of Delegate, Invoke or Broadcast, with revocations to be added in the very near future.

If the Action is Delegate then the Issuer confers to the Subject the ability to further create new tokens, chained on this one.

If the Action is Invoke or Broadcast, then the token confers to the Subject the capability to make an invocation or broadcast to a behavior. Such tokens are terminal and cannot be chained further.

The Chain field of the token inlines the chain of tokens (could be a single one) on which the capability transfer is anchored on.

Note that the delegation spread can be restricted by the issuer of a token using the Depth field. If set, it is the maximum chain depth at which a token can appear. If it appears deeper in the chain, the token chain fails verification.

Finally, all capabilities have an expiration time (in UNIX nanoseconds). An expired token cannot be used any more and fails verification.

Anchors of Trust

In order to sign and verify token chains, the receiver needs to install some trust anchors. Specifically, we distinguish 3 types of anchors:

  • root anchors which are DIDs that are fully trusted for input with implicit root capability. Any valid chain anchored on one of our roots will be admissible.
  • require anchors which are tokens that act as side chains for marginal input trust. These tokens admit a chain anchored in their subject, as long as the capability and depth constraints are satisfied.
  • provide anchors which are tokens that anchor the actor's output invocation and broadcast tokens. These are delegations which the actor can use to prove that it has the required capabilities, beside self-signing.

Token Chain Verification

The token chain is verified with strict rules:

  • The entire chain must not have expired.
  • Each token in the chain cannot expire before its chain.
  • Each token must match the Issuer with the Subject of its chain.
  • Each token in the chain can only narrow (attenuate) the capabilities of its chain.
  • Each token in the chain can only narrow the audience; an empty audience ("to whom it may concern") can only be narrowed once to an audience DID and all chains build on top must concern the same audience.
  • The chain of a token can only delegate.
  • The signature must verify.
  • The whole chain must recursively verify.

Programming NuActors

The actor package

The Go implementation of NuActor lives in the actor package of DMS.

To use it:

import "github.com/depinkit/actor"

The network substrate for NuActor is currently implemented with libp2p, with broadcast using gossipsub.

The Security Context

Each actor has a key pair for signing its messages; the actor's id is the public key itself and is embedded in every message it sends. The private key for the actor lives inside the actor's SecurityContext.

In general:

  • each actor has its own SecurityContext; however, if the actor wants to create multiple subactors and act as an ensemble, it can share it.
  • the key pair is ephemeral; however, the root actor in the process has a persistent key pair, which matches the libp2p key and Peer ID. This makes the actor reachable by default given its Peer ID or DID.
  • every actor in the process shares a DID, which is the ID of the root actor.

Each Security Context is anchored in a process wide CapabilityContext, which stores anchors of trust and ephemeral tokes consumed during actor interactions.

The CapabilityContext itself is anchored on a TrustContext, which contains the private key for the root actor and the process itself.

The following diagram depicts this relathionship: Actor Security Context

Actor Handles

type Handle struct {
	ID      ID      `json:"id"`
	DID     DID     `json:"did"`
	Address Address `json:"addr"`
}

type Address struct {
	HostID       string `json:"host,omitempty"`
	InboxAddress string `json:"inbox,omitempty"`
}

Messages

type Envelope struct {
	To         Handle          `json:"to"`
	Behavior   string          `json:"be"`
	From       Handle          `json:"from"`
	Nonce      uint64          `json:"nonce"`
	Options    EnvelopeOptions `json:"opt"`
	Message    []byte          `json:"msg"`
	Capability []byte          `json:"cap,omitempty"`
	Signature  []byte          `json:"sig,omitempty"`

	Discard func() `json:"-"`
}

type EnvelopeOptions struct {
	Expire  uint64 `json:"exp"`
	ReplyTo string `json:"cont,omitempty"`
	Topic   string `json:"topic,omitempty"`
}

The Actor Interfaces

type Actor interface {
	Context() context.Context
	Handle() Handle
	Security() SecurityContext

	AddBehavior(behavior string, continuation Behavior, opt ...BehaviorOption) error
	RemoveBehavior(behavior string)

	Receive(msg Envelope) error
	Send(msg Envelope) error
	Invoke(msg Envelope) (<-chan Envelope, error)

	Publish(msg Envelope) error
	Subscribe(topic string, setup ...BroadcastSetup) error

	Start() error
	Stop() error

	Limiter() RateLimiter
}

Sending Messages

The following code shows how to send a message at the call site:

	msg, _ := actor.Message(
		myActor.Handle(),
		destinationHandle,
		"/some/behavior",
		MyMessage{ /*...*/ },
	)
	_ = myActor.Send(msg)

At the receiver this is how we can react to the message:

	_ = myActor.AddBehavior("/some/behavior", func(msg Envelope) {
		defer msg.Discard()
		// process message
	})

Notice the _ for errors, please don't do this in production.

Interactive Invocations

Interactive invocations are a combinations of a synchronous send and wait for a reply.

At the call site:

	msg, _ := actor.Message(
		myActor.Handle(),
		destinationHandle,
		"/some/behavior",
		MyMessage{ /*...*/ },
	)
	replyChan, _ := myActorInvoke(msg)
	reply := <-replyChan
	defer reply.Discard()
	// process reply ...

At the receiver this is how we can create an interactive behavior:

	_ = myActor.AddBehavior("/some/behavior", func(msg Envelope) {
		defer msg.Discard()
		reply, _ := actor.ReplyTo(
			msg
			MyReply{ /*...*/ },
		)
		_ = mayActor.Send(MyReply)
	})

Again, notice the _ for errors, please don't do this in production.

Broadcast

We can easily broadcast messages to all interested parties in a topic.

At the broadcast site:

	msg, _ := actor.Message(
		myActor.Handle(),
		destinationHandle,
		"/some/broadcast/behavior",
		MyMessage{ /*...*/ },
		actor.WithMessageTopic("/some/topic"),
	)
	_ = actor.Publish(msg)

At the receiver:

	_ = myActor.Subscribe("/some/topic")
	_ = myActor.AddBehavior("/some/broadcast/behavior", func(msg Envelope) {
		defer msg.Discard()
		// process the message
	}, actor.WithBehaviorTopic("/some/topic"),
	)

Discarding Capability Tokens

Notice all these defer msg.Discard() in the examples above; this is necessary to ensure deterministic cleanup of tokens exchanged during the interaction. Please do not forget that.

Getting Started: Using NuActor with Network

The NuActor framework uses the github.com/depinkit/network package for peer-to-peer communication. The network package provides libp2p-based networking, DHT peer discovery, and gossipsub pub/sub messaging.

Note: This actor package was extracted from and is actively used by NuNet's Device Management Service (DMS), where it coordinates secure interactions between compute nodes in a decentralized network.

Installation

go get github.com/depinkit/actor
go get github.com/depinkit/network
go get github.com/depinkit/crypto
go get github.com/depinkit/did
go get github.com/depinkit/ucan

Complete Example: Setting Up Actors

Here's a complete example showing how to set up two actors communicating over the network:

package main

import (
	"context"
	"fmt"
	"time"

	"github.com/depinkit/actor"
	"github.com/depinkit/crypto"
	"github.com/depinkit/did"
	"github.com/depinkit/network"
	"github.com/depinkit/network/config"
	"github.com/depinkit/network/libp2p"
	"github.com/depinkit/ucan"
	"github.com/multiformats/go-multiaddr"
	"github.com/spf13/afero"
	"gitlab.com/nunet/device-management-service/types"
)

func main() {
	// Create two actors on different network nodes
	actor1, net1 := createActorWithNetwork(9000, 9001, nil)
	defer actor1.Stop()
	defer net1.Stop()

	actor2, net2 := createActorWithNetwork(9010, 9011, []string{
		fmt.Sprintf("/ip4/127.0.0.1/tcp/9000/p2p/%s", net1.Host.ID()),
	})
	defer actor2.Stop()
	defer net2.Stop()

	// Wait for network to connect
	time.Sleep(2 * time.Second)

	// Register a behavior on actor2
	actor2.AddBehavior("/greet", func(msg actor.Envelope) {
		defer msg.Discard()

		var request string
		json.Unmarshal(msg.Message, &request)
		fmt.Printf("Actor2 received: %s from %s\n", request, msg.From.DID)

		// Send reply
		reply, _ := actor.ReplyTo(msg, "Hello back!")
		actor2.Send(reply)
	})

	// Actor1 sends a message to Actor2
	message, _ := actor.Message(
		actor1.Handle(),
		actor2.Handle(),
		"/greet",
		"Hello Actor2!",
	)

	// Send and wait for reply
	replyChan, _ := actor1.Invoke(message)
	reply := <-replyChan
	defer reply.Discard()

	var response string
	json.Unmarshal(reply.Message, &response)
	fmt.Printf("Actor1 received reply: %s\n", response)
}

func createActorWithNetwork(tcpPort, quicPort int, bootstrap []string) (actor.Actor, *libp2p.Libp2p) {
	// Generate identity
	priv, _ := crypto.GeneratePrivateKey(crypto.KEY_ED25519)
	rootDID := did.FromPublicKey(priv.GetPublic())
	
	// Create trust context
	trustCtx := did.NewTrustContext(priv)
	
	// Create capability context
	capCtx := ucan.NewCapabilityContext(trustCtx)
	capCtx.AddRoot(rootDID) // Trust self
	
	// Create network configuration
	cfg := &config.Config{
		P2P: config.P2P{
			ListenAddress: []string{
				fmt.Sprintf("/ip4/0.0.0.0/tcp/%d", tcpPort),
				fmt.Sprintf("/ip4/0.0.0.0/udp/%d/quic-v1", quicPort),
			},
			BootstrapPeers: bootstrap,
		},
	}
	
	// Parse bootstrap peers
	var bootstrapPeers []multiaddr.Multiaddr
	for _, addr := range bootstrap {
		ma, _ := multiaddr.NewMultiaddr(addr)
		bootstrapPeers = append(bootstrapPeers, ma)
	}
	
	// Create libp2p configuration
	libp2pCfg := &types.Libp2pConfig{
		PrivateKey:     priv,
		ListenAddress:  cfg.P2P.ListenAddress,
		BootstrapPeers: bootstrapPeers,
		Rendezvous:     "nuactor-example",
		Server:         false,
	}
	
	// Create and initialize network
	fs := afero.NewOsFs()
	net, _ := libp2p.NewLibp2p(libp2pCfg, fs)
	net.Init(cfg)
	net.Start()
	
	// Create actor security context
	actorSecurity := actor.NewBasicSecurityContext(priv, rootDID, capCtx)
	
	// Create supervisor handle
	supervisor := actor.Handle{
		ID:  actorSecurity.ID(),
		DID: rootDID,
		Address: actor.Address{
			HostID: net.Host.ID().String(),
		},
	}
	
	// Create actor
	limiter := actor.NewRateLimiter(actor.RateLimiterConfig{
		PublicLimitAllow:      100,
		PublicLimitAcquire:    10,
		BroadcastLimitAllow:   100,
		BroadcastLimitAcquire: 10,
	})
	
	actorInstance, _ := actor.New(supervisor, net, actorSecurity, limiter)
	actorInstance.Start()
	
	return actorInstance, net
}

Sending Messages Between Actors

Once you have actors connected via the network, you can send messages:

// Define your message type
type GreetingRequest struct {
	Name    string `json:"name"`
	Message string `json:"message"`
}

type GreetingResponse struct {
	Reply string `json:"reply"`
}

// On the receiving actor
receiverActor.AddBehavior("/api/greet", func(msg actor.Envelope) {
	defer msg.Discard()
	
	var req GreetingRequest
	if err := json.Unmarshal(msg.Message, &req); err != nil {
		log.Error("Invalid message format")
		return
	}
	
	log.Infof("Received greeting from %s: %s", req.Name, req.Message)
	
	// Send response
	response := GreetingResponse{
		Reply: fmt.Sprintf("Hello %s, nice to meet you!", req.Name),
	}
	
	replyMsg, _ := actor.ReplyTo(msg, response)
	receiverActor.Send(replyMsg)
}, actor.WithBehaviorCapability("/api/greet"))

// On the sending actor
request := GreetingRequest{
	Name:    "Alice",
	Message: "Hello from Alice!",
}

msg, _ := actor.Message(
	senderActor.Handle(),
	receiverActor.Handle(),
	"/api/greet",
	request,
)

// For one-way send
_ = senderActor.Send(msg)

// For request-response (invoke)
replyChan, _ := senderActor.Invoke(msg)
reply := <-replyChan
defer reply.Discard()

var response GreetingResponse
json.Unmarshal(reply.Message, &response)
fmt.Printf("Got reply: %s\n", response.Reply)

Broadcasting to Multiple Actors

Use pub/sub for one-to-many communication:

// All actors subscribe to a topic
topic := "/notifications/system"

// On each actor that wants to receive broadcasts
actor.Subscribe(topic)
actor.AddBehavior("/notify", func(msg actor.Envelope) {
	defer msg.Discard()
	
	var notification string
	json.Unmarshal(msg.Message, &notification)
	fmt.Printf("Received notification: %s\n", notification)
}, actor.WithBehaviorTopic(topic))

// Broadcasting actor sends to all subscribers
notification := "System update available!"
msg, _ := actor.Message(
	broadcasterActor.Handle(),
	actor.Handle{}, // Empty handle for broadcast
	"/notify",
	notification,
	actor.WithMessageTopic(topic),
)

_ = broadcasterActor.Publish(msg)

Working with Capabilities

Actors can grant capabilities to each other for authorized operations:

// Actor A grants capability to Actor B
capability := "/api/write"
expiry := 24 * time.Hour

actorA.Security().Grant(
	actorB.Handle().DID,  // to whom
	actorA.Handle().DID,  // audience (optional)
	[]ucan.Capability{{Path: capability}},
	expiry,
)

// Actor B can now invoke behaviors requiring that capability
msg, _ := actor.Message(
	actorB.Handle(),
	actorA.Handle(),
	"/api/write",
	dataToWrite,
)

// The capability token is automatically included and verified
_ = actorB.Send(msg)

Network Discovery and Connection

Actors automatically discover each other via the network's DHT:

// Configure network with bootstrap peers
cfg := &config.Config{
	P2P: config.P2P{
		ListenAddress: []string{
			"/ip4/0.0.0.0/tcp/9000",
			"/ip4/0.0.0.0/udp/9001/quic-v1",
		},
		BootstrapPeers: []string{
			// Well-known bootstrap nodes
			"/dnsaddr/bootstrap.libp2p.io/p2p/QmNnooDu7bfjPFoTZYxMNLWUQJyrVwtbZg5gBMjTezGAJN",
		},
	},
}

// The network will automatically:
// 1. Connect to bootstrap peers
// 2. Discover other peers via DHT
// 3. Maintain connections
// 4. Enable NAT traversal via relay/hole-punching

// Actors can now communicate without manual peer management

Error Handling Best Practices

Always handle errors in production code:

// Creating an actor
actorInstance, err := actor.New(supervisor, network, security, limiter)
if err != nil {
	return fmt.Errorf("failed to create actor: %w", err)
}

// Starting an actor
if err := actorInstance.Start(); err != nil {
	return fmt.Errorf("failed to start actor: %w", err)
}
defer func() {
	if err := actorInstance.Stop(); err != nil {
		log.Errorf("Failed to stop actor: %v", err)
	}
}()

// Sending messages
msg, err := actor.Message(from, to, "/behavior", payload)
if err != nil {
	return fmt.Errorf("failed to create message: %w", err)
}

if err := actorInstance.Send(msg); err != nil {
	return fmt.Errorf("failed to send message: %w", err)
}

// Adding behaviors
err = actorInstance.AddBehavior("/my/behavior", handler,
	actor.WithBehaviorCapability("/my/behavior"),
)
if err != nil {
	return fmt.Errorf("failed to add behavior: %w", err)
}

Behind the Scenes

Sending a Message

Send a Message

Invoking a Behavior

Behavior Invocation

Verifying Capabilities

Invocation Token Chain

About

A high-performance, capability-based actor system for Go with UCAN authorization and libp2p networking support

Topics

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Languages