Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,34 @@ ucantool view container.bin --json
{"ctn-v1":[{"/":{"bytes":"glhAR66mRiQ8FKsCM4aoM9sdLs+HYkG6GTTyqGl0XAE9nr9PGgFtg2gLimfiYFjoD90bBEeqG6P6AMWnUwvolA0MD6JhaEg0Ae0B7QETcXN1Y2FuL2RsZ0AxLjAuMC1yYy4xp2NhdWR4OGRpZDprZXk6ejZNa3M3UHhxVGVCNmhWQWllYWZoRGtlYVVKYWpEQTVyQ01qWHYxUVEyc1NxbWo1Y2NtZHAvZnJ1aXRzL3B1cmNoYXNlY2V4cBppHF6WY2lzc3RkaWQ6d2ViOmZydWl0Lm1hcmtldGNwb2yBg2NhbGxnLmZydWl0c4Jib3KDg2I9PWEuZWFwcGxlg2I9PWEuZm9yYW5nZYNiPT1hLmZiYW5hbmFjc3VidGRpZDp3ZWI6ZnJ1aXQubWFya2V0ZW5vbmNlUKn5t5tUI9ePips/9FYLOww"}},{"/":{"bytes":"glhAckRmUKVOqWffQV+++DJMLSqHTk/wCDqWsMXZpajZ67hX1HMsmNz8OEqaALpzvnaQWqbtoM3JjQ7zTlO8gKLED6JhaEg0Ae0B7QETcXN1Y2FuL2ludkAxLjAuMC1yYy4xqWNhdWR0ZGlkOndlYjpmcnVpdC5tYXJrZXRjY21kdC91Y2FuL2Fzc2VydC9yZWNlaXB0Y2V4cBppHF6WY2lhdBppHF54Y2lzc3RkaWQ6d2ViOmZydWl0Lm1hcmtldGNwcmaAY3N1YnRkaWQ6d2ViOmZydWl0Lm1hcmtldGRhcmdzomNvdXShYm9rGCpjcmFu2CpYJQABcRIgewTVERdle8QnvMiXLq+K8NY5RZEBnvxy8WNXv23scT9lbm9uY2VQjaUQqg4PnK2wOT4VxFw03w"}},{"/":{"bytes":"glhA2uUTIRx6xLliKr+3EUhFgBFpnBP0Zeew9yZ6ma733xiF7vLS1krqa6yZimBxun8DjMlsYHeu18b+NuBvkMlwCaJhaEg0Ae0B7QETcXN1Y2FuL2ludkAxLjAuMC1yYy4xqWNjbWRwL2ZydWl0cy9wdXJjaGFzZWNleHAaaRxelmNpYXQaaRxeeGNpc3N4OGRpZDprZXk6ejZNa3M3UHhxVGVCNmhWQWllYWZoRGtlYVVKYWpEQTVyQ01qWHYxUVEyc1NxbWo1Y3ByZoHYKlglAAFxEiBBbvyIkSr+mDAubWKbg5WKadYbY+ZoN0lRhyyxHf18hWNzdWJ0ZGlkOndlYjpmcnVpdC5tYXJrZXRkYXJnc6FmZnJ1aXRzgmVhcHBsZWZiYW5hbmFkbWV0YaViaWR4OGRpZDprZXk6ejZNa2d5NWUyTHRwcUFTcWZ6MUtUNkc1ZHFiaTV4WVE0V1A0a2kxaXY0WHRuaFlHZGJsb2KhZmRpZ2VzdEMBAgNkbmFtZWR0ZXN0ZHJvb3TYKlglAAFVEiDH0BSJCAhYxQAGWDbGWPhHpspnxIZGGSEr5PggDku6zmRzaXplGQPoZW5vbmNlUC/rE9w/ky0qf8Ha+FwAQPs"}}]}
```

## Use as a library

Generating delegations does not require the CLI. `pkg/ucandelegate` issues them
from a key held in memory, so a caller never has to write a private key to disk.

```go
import "github.com/fil-forge/ucantool/pkg/ucandelegate"

res, err := ucandelegate.IssueFromPEM(pemData, ucandelegate.Request{
Audience: "did:key:aud",
Commands: []string{"/msg/send"},
Expiration: ucandelegate.ExpiresIn(time.Hour),
ContainerCodec: "base64+gzip",
})
if err != nil {
return err
}

// res.Bytes holds the encoded delegation. WriteTo terminates printable
// output with a newline and writes binary output bare, the way the CLI does.
_, err = res.WriteTo(os.Stdout)
```

Pass a `Signer` instead of PEM bytes to use `Issue`, and leave `ContainerCodec`
empty to encode a single delegation as a bare DAG-CBOR block. A nil `Expiration`
issues a delegation that never expires; `ExpiresAt` takes an absolute
`time.Time`. `res.IsText()` reports whether the bytes are printable.

## Screenshots

### Delegation
Expand Down
28 changes: 4 additions & 24 deletions cmd/container/pack.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,10 @@ import (
"github.com/fil-forge/ucantone/ucan/delegation"
"github.com/fil-forge/ucantone/ucan/invocation"
"github.com/fil-forge/ucantone/ucan/receipt"
"github.com/fil-forge/ucantool/pkg/ucanfmt"
"github.com/spf13/cobra"
)

const defaultContainerCodec = "base64+gzip"

var packCmd = &cobra.Command{
Use: "pack <path|container> [path|container...]",
Short: "Combine UCANs into a single UCAN container",
Expand All @@ -31,11 +30,11 @@ var (
)

func init() {
packCmd.Flags().StringVarP(&packCodecStr, "codec", "o", defaultContainerCodec, "UCAN container codec (e.g. 'raw', 'base64', 'base64url', 'raw+gzip', 'base64+gzip' or 'base64url+gzip')")
packCmd.Flags().StringVarP(&packCodecStr, "codec", "o", ucanfmt.DefaultContainerCodec, "UCAN container codec (e.g. 'raw', 'base64', 'base64url', 'raw+gzip', 'base64+gzip' or 'base64url+gzip')")
}

func pack(cmd *cobra.Command, args []string) error {
codec, err := parseCodec(packCodecStr)
codec, err := ucanfmt.ParseCodec(packCodecStr)
if err != nil {
return err
}
Expand Down Expand Up @@ -98,30 +97,11 @@ func pack(cmd *cobra.Command, args []string) error {
return fmt.Errorf("encoding container: %w", err)
}

if codec == container.Raw || codec == container.RawGzip {
if !ucanfmt.IsTextualCodec(codec) {
// binary output, no trailing newline
_, err = cmd.OutOrStdout().Write(out)
return err
}
_, err = fmt.Fprintln(cmd.OutOrStdout(), string(out))
return err
}

func parseCodec(s string) (byte, error) {
switch s {
case "raw":
return container.Raw, nil
case "base64":
return container.Base64, nil
case "base64url":
return container.Base64url, nil
case "raw+gzip":
return container.RawGzip, nil
case "base64+gzip":
return container.Base64Gzip, nil
case "base64url+gzip":
return container.Base64urlGzip, nil
default:
return 0, fmt.Errorf("invalid container codec: %q", s)
}
}
135 changes: 15 additions & 120 deletions cmd/delegate.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,13 @@ package cmd

import (
"fmt"
"os"
"time"

"github.com/fil-forge/ucantone/did"
"github.com/fil-forge/ucantone/multikey"
"github.com/fil-forge/ucantone/ucan"
"github.com/fil-forge/ucantone/ucan/command"
"github.com/fil-forge/ucantone/ucan/container"
"github.com/fil-forge/ucantone/ucan/delegation"
"github.com/fil-forge/ucantone/ucan/delegation/policy"
"github.com/fil-forge/ucantool/pkg/identity"
"github.com/fil-forge/ucantool/pkg/ucandelegate"
"github.com/spf13/cobra"
)

const defaultContainerCodec = "base64+gzip"

var delegateCmd = &cobra.Command{
Use: "delegate",
Aliases: []string{"d"},
Expand Down Expand Up @@ -61,127 +52,31 @@ func init() {
}

func mkDelegation(cmd *cobra.Command, _ []string) error {
signer, err := readAndDecodeIssuerKey(issuerPrivateKeyFile)
signer, err := identity.LoadSignerFromPEMFile(issuerPrivateKeyFile)
if err != nil {
return fmt.Errorf("parsing issuer private key from file %s: %w", issuerPrivateKeyFile, err)
}

issuer := multikey.KeyIssuer(signer)
if issuerDidWeb != "" {
issuerDidWeb, err := did.Parse(issuerDidWeb)
if err != nil {
return fmt.Errorf("parsing issuer DID: %w", err)
}
if issuerDidWeb.Method() != "web" {
return fmt.Errorf("issuer DID must start with 'did:web:'")
}
issuer = multikey.NewIssuer(issuerDidWeb, signer)
}

audience, err := did.Parse(audienceStr)
if err != nil {
return fmt.Errorf("parsing audience DID: %w", err)
req := ucandelegate.Request{
Signer: signer,
IssuerDIDWeb: issuerDidWeb,
Audience: audienceStr,
Subject: subjectStr,
Commands: commandsStr,
Policy: policyStr,
ContainerCodec: containerCodecStr,
}

var opts []delegation.Option
if expiration > 0 {
if time.Now().Unix() > expiration {
return fmt.Errorf("provided expiration time %d is in the past", expiration)
}
opts = append(opts, delegation.WithExpiration(ucan.UnixTimestamp(expiration)))
} else {
opts = append(opts, delegation.WithNoExpiration())
}

var subject did.DID
if subjectStr == "" {
subject = issuer.DID()
} else {
subject, err = did.Parse(subjectStr)
if err != nil {
return fmt.Errorf("parsing subject DID: %w", err)
}
}

var commands []ucan.Command
for _, commandStr := range commandsStr {
command, err := command.Parse(commandStr)
if err != nil {
return fmt.Errorf("parsing command: %w", err)
}
commands = append(commands, command)
}

if policyStr != "" {
pol, err := policy.Parse(policyStr)
if err != nil {
return fmt.Errorf("parsing policy: %w", err)
}
opts = append(opts, delegation.WithPolicy(pol))
}

var delegations []ucan.Delegation
for _, cmd := range commands {
d, err := delegation.Delegate(issuer, audience, subject, cmd, opts...)
if err != nil {
return fmt.Errorf("creating delegation: %w", err)
}
delegations = append(delegations, d)
req.Expiration = ucandelegate.ExpiresAt(time.Unix(expiration, 0))
}

if len(delegations) == 1 && containerCodecStr == "" {
out, err := delegation.Encode(delegations[0])
if err != nil {
return fmt.Errorf("formatting delegation: %w", err)
}
_, err = cmd.OutOrStdout().Write(out)
return err
}

if containerCodecStr == "" {
containerCodecStr = defaultContainerCodec
}

var codec byte
switch containerCodecStr {
case "raw":
codec = container.Raw
case "base64":
codec = container.Base64
case "base64url":
codec = container.Base64url
case "raw+gzip":
codec = container.RawGzip
case "base64+gzip":
codec = container.Base64Gzip
case "base64url+gzip":
codec = container.Base64urlGzip
default:
return fmt.Errorf("invalid container codec: %s", containerCodecStr)
}

out, err := container.Encode(codec, container.New(container.WithDelegations(delegations...)))
res, err := ucandelegate.Issue(req)
if err != nil {
return fmt.Errorf("encoding container: %w", err)
}
if codec == container.Raw || codec == container.RawGzip {
// binary output, no trailing newline
_, err = cmd.OutOrStdout().Write(out)
return err
}

// Write to stdout (cmd.Println goes to stderr) so redirected/pipelined
// callers capture the encoded container, matching the raw and
// single-delegation branches above.
_, err = fmt.Fprintln(cmd.OutOrStdout(), string(out))
// callers capture the delegation.
_, err = res.WriteTo(cmd.OutOrStdout())
return err
}

// readAndDecodeIssuerKey attempts to read and decode the private key from the
// provided path.
func readAndDecodeIssuerKey(path string) (multikey.Signer, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("reading file: %w", err)
}
return identity.DecodeSignerFromPEM(data)
}
106 changes: 106 additions & 0 deletions cmd/delegate_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
package cmd

import (
"bytes"
"os"
"path/filepath"
"testing"

"github.com/fil-forge/ucantone/multikey/ed25519"
"github.com/fil-forge/ucantone/testutil"
"github.com/fil-forge/ucantone/ucan/container"
"github.com/fil-forge/ucantone/ucan/delegation"
"github.com/fil-forge/ucantool/pkg/identity"
"github.com/stretchr/testify/require"
)

// execDelegate runs the delegate command and returns what it wrote to stdout.
// Cobra keeps flag values in package globals that outlive a single Execute, so
// they are reset before every run.
func execDelegate(t *testing.T, args ...string) ([]byte, error) {
t.Helper()

issuerPrivateKeyFile = ""
issuerDidWeb = ""
audienceStr = ""
subjectStr = ""
commandsStr = nil
policyStr = ""
containerCodecStr = ""
expiration = 0

var stdout, stderr bytes.Buffer
rootCmd.SetOut(&stdout)
rootCmd.SetErr(&stderr)
rootCmd.SetArgs(append([]string{"delegate"}, args...))
err := rootCmd.Execute()
return stdout.Bytes(), err
}

// writeIssuerKey writes a throwaway Ed25519 key to a temporary PEM file.
func writeIssuerKey(t *testing.T) string {
t.Helper()

signer, err := ed25519.Generate()
require.NoError(t, err)
pemData, err := identity.EncodeSignerToPEM(signer)
require.NoError(t, err)

path := filepath.Join(t.TempDir(), "id.pem")
require.NoError(t, os.WriteFile(path, pemData, 0600))
return path
}

func TestDelegateCmd(t *testing.T) {
keyPath := writeIssuerKey(t)
audience := testutil.RandomDID(t).String()

t.Run("writes a bare delegation without a trailing newline", func(t *testing.T) {
stdout, err := execDelegate(t, "-f", keyPath, "-a", audience, "-c", "/msg/send")
require.NoError(t, err)

// Decoding fails if anything, a newline included, trails the delegation.
_, err = delegation.Decode(stdout)
require.NoError(t, err)
})

t.Run("terminates a textual container with a newline", func(t *testing.T) {
stdout, err := execDelegate(t, "-f", keyPath, "-a", audience, "-c", "/msg/send", "-o", "base64+gzip")
require.NoError(t, err)
require.Equal(t, byte('\n'), stdout[len(stdout)-1])

decoded, err := container.Decode(bytes.TrimRight(stdout, "\n"))
require.NoError(t, err)
require.Len(t, decoded.Delegations(), 1)
})

t.Run("writes a raw container without a trailing newline", func(t *testing.T) {
stdout, err := execDelegate(t, "-f", keyPath, "-a", audience, "-c", "/msg/send", "-o", "raw")
require.NoError(t, err)

decoded, err := container.Decode(stdout)
require.NoError(t, err)
require.Len(t, decoded.Delegations(), 1)
})

t.Run("multiple commands force a textual container", func(t *testing.T) {
stdout, err := execDelegate(t, "-f", keyPath, "-a", audience, "-c", "/msg/send", "-c", "/msg/recv")
require.NoError(t, err)
require.Equal(t, byte('\n'), stdout[len(stdout)-1])

decoded, err := container.Decode(bytes.TrimRight(stdout, "\n"))
require.NoError(t, err)
require.Len(t, decoded.Delegations(), 2)
})

t.Run("errors on an invalid codec", func(t *testing.T) {
_, err := execDelegate(t, "-f", keyPath, "-a", audience, "-c", "/msg/send", "-o", "bogus")
require.ErrorContains(t, err, "invalid container codec")
})

t.Run("errors on a missing key file", func(t *testing.T) {
missing := filepath.Join(t.TempDir(), "missing.pem")
_, err := execDelegate(t, "-f", missing, "-a", audience, "-c", "/msg/send")
require.ErrorContains(t, err, "parsing issuer private key from file")
})
}
Loading