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).
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 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.
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.
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.
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.
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.
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:
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"`
}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"`
}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
}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 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.
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"),
)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.
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.
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/ucanHere'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
}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)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, ¬ification)
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)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)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 managementAlways 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)
}