diff --git a/README.md b/README.md index 407330d..8c116d7 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/cmd/container/pack.go b/cmd/container/pack.go index 51efa8e..d2d9008 100644 --- a/cmd/container/pack.go +++ b/cmd/container/pack.go @@ -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...]", Short: "Combine UCANs into a single UCAN container", @@ -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 } @@ -98,7 +97,7 @@ 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 @@ -106,22 +105,3 @@ func pack(cmd *cobra.Command, args []string) error { _, 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) - } -} diff --git a/cmd/delegate.go b/cmd/delegate.go index 351150c..a1eed6e 100644 --- a/cmd/delegate.go +++ b/cmd/delegate.go @@ -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"}, @@ -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) -} diff --git a/cmd/delegate_test.go b/cmd/delegate_test.go new file mode 100644 index 0000000..18d4fb1 --- /dev/null +++ b/cmd/delegate_test.go @@ -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") + }) +} diff --git a/pkg/identity/pem.go b/pkg/identity/pem.go index bee6bee..6d60873 100644 --- a/pkg/identity/pem.go +++ b/pkg/identity/pem.go @@ -6,6 +6,7 @@ import ( "crypto/x509" "encoding/pem" "fmt" + "os" "github.com/fil-forge/ucantone/multikey" "github.com/fil-forge/ucantone/multikey/ed25519" @@ -16,7 +17,7 @@ import ( func EncodeSignerToPEM(signer multikey.Signer) ([]byte, error) { privateKeyBytes, err := x509.MarshalPKCS8PrivateKey(signer.PrivateKey()) if err != nil { - return nil, fmt.Errorf("marshaling private key of signer %s: %w", signer, err) + return nil, fmt.Errorf("marshaling private key of signer %s: %w", signer.KeyDID(), err) } privateKeyBlock := &pem.Block{ @@ -26,12 +27,22 @@ func EncodeSignerToPEM(signer multikey.Signer) ([]byte, error) { buffer := new(bytes.Buffer) if err := pem.Encode(buffer, privateKeyBlock); err != nil { - return nil, fmt.Errorf("encoding private key of signer %s: %w", signer, err) + return nil, fmt.Errorf("encoding private key of signer %s: %w", signer.KeyDID(), err) } return buffer.Bytes(), nil } +// LoadSignerFromPEMFile reads a PKCS#8 PEM file and decodes the private key it +// holds as a signer. +func LoadSignerFromPEMFile(path string) (multikey.Signer, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("reading file: %w", err) + } + return DecodeSignerFromPEM(data) +} + // DecodeSignerFromPEM loads a private key from a PKCS#8 PEM as a signer. // Currently, only Ed25519 keys are supported. func DecodeSignerFromPEM(pemData []byte) (multikey.Signer, error) { diff --git a/pkg/identity/pem_test.go b/pkg/identity/pem_test.go index a53f4d7..3fcdd48 100644 --- a/pkg/identity/pem_test.go +++ b/pkg/identity/pem_test.go @@ -1,6 +1,8 @@ package identity_test import ( + "os" + "path/filepath" "testing" "github.com/fil-forge/ucantone/multikey/ed25519" @@ -24,6 +26,26 @@ func TestEd25519SignerPEMRoundTrip(t *testing.T) { require.Equal(t, original.KeyDID(), decoded.KeyDID()) } +func TestLoadSignerFromPEMFile(t *testing.T) { + original, err := ed25519.Generate() + require.NoError(t, err) + + pemBytes, err := identity.EncodeSignerToPEM(original) + require.NoError(t, err) + + path := filepath.Join(t.TempDir(), "id.pem") + require.NoError(t, os.WriteFile(path, pemBytes, 0600)) + + loaded, err := identity.LoadSignerFromPEMFile(path) + require.NoError(t, err) + require.Equal(t, original.KeyDID(), loaded.KeyDID()) +} + +func TestLoadSignerFromPEMFile_Missing(t *testing.T) { + _, err := identity.LoadSignerFromPEMFile(filepath.Join(t.TempDir(), "missing.pem")) + require.ErrorContains(t, err, "reading file") +} + func TestDecodeEd25519SignerFromPEM_NoPrivateKeyBlock(t *testing.T) { pemData := []byte("-----BEGIN CERTIFICATE-----\nMIIB\n-----END CERTIFICATE-----\n") _, err := identity.DecodeSignerFromPEM(pemData) diff --git a/pkg/ucandelegate/delegate.go b/pkg/ucandelegate/delegate.go new file mode 100644 index 0000000..aa9e52b --- /dev/null +++ b/pkg/ucandelegate/delegate.go @@ -0,0 +1,239 @@ +// Package ucandelegate issues UCAN delegations signed by an in-memory key. +package ucandelegate + +import ( + "fmt" + "io" + "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/ucanfmt" +) + +// Request describes the delegations to issue. +type Request struct { + // Signer signs the delegations. Required. + Signer multikey.Signer + + // IssuerDIDWeb wraps the signer's did:key in a did:web issuer DID. When + // set, it must be a did:web DID. Optional. + IssuerDIDWeb string + + // Audience is the DID the delegations are issued to. Required. + Audience string + + // Subject is the DID the delegations are about. Defaults to the issuer DID. + Subject string + + // Commands are the commands the issuer authorizes the audience to invoke, + // one delegation per command. At least one is required. + Commands []string + + // Policy is a policy encoded as a DAG-JSON string. Optional. + Policy string + + // Expiration is the time the delegations expire, in seconds since the Unix + // epoch. + // + // WARNING: a nil Expiration issues delegations that are valid FOREVER, + // unless revoked. + Expiration *ucan.UnixTimestamp + + // ContainerCodec names the UCAN container codec to encode the delegations + // with. An empty ContainerCodec encodes a single delegation as a bare + // DAG-CBOR block, and multiple delegations with + // [ucanfmt.DefaultContainerCodec]. + ContainerCodec string +} + +// ExpiresAt returns t as a delegation expiration time, truncated to a whole +// second. +func ExpiresAt(t time.Time) *ucan.UnixTimestamp { + exp := ucan.UnixTimestamp(t.Unix()) + return &exp +} + +// ExpiresIn returns a delegation expiration time d from now, truncated to a +// whole second. +func ExpiresIn(d time.Duration) *ucan.UnixTimestamp { + return ExpiresAt(time.Now().Add(d)) +} + +// Result holds encoded delegations. +type Result struct { + // Bytes are the encoded delegations. + Bytes []byte + + // Codec is the UCAN container codec Bytes are encoded with, or 0 when Bytes + // are a bare DAG-CBOR delegation. + Codec byte +} + +// IsText reports whether Bytes are printable text rather than binary. +func (r Result) IsText() bool { + return ucanfmt.IsTextualCodec(r.Codec) +} + +// WriteTo writes the encoded delegations to w, terminating printable text with +// a newline and writing binary output bare. +func (r Result) WriteTo(w io.Writer) (int64, error) { + n, err := w.Write(r.Bytes) + if err != nil || !r.IsText() { + return int64(n), err + } + nl, err := w.Write([]byte{'\n'}) + return int64(n + nl), err +} + +// IssueFromPEM issues the delegations described by req, signed with the private +// key held in a PKCS#8 PEM. req.Signer must not be set. +func IssueFromPEM(pemData []byte, req Request) (Result, error) { + if req.Signer != nil { + return Result{}, fmt.Errorf("signer must not be set when issuing from a PEM") + } + + signer, err := identity.DecodeSignerFromPEM(pemData) + if err != nil { + return Result{}, fmt.Errorf("decoding issuer private key: %w", err) + } + + req.Signer = signer + return Issue(req) +} + +// Issue creates the delegations described by req and encodes them. +func Issue(req Request) (Result, error) { + dlgs, err := Delegate(req) + if err != nil { + return Result{}, err + } + return Encode(dlgs, req.ContainerCodec) +} + +// Delegate creates one delegation per command in req. +func Delegate(req Request) ([]ucan.Delegation, error) { + if req.Signer == nil { + return nil, fmt.Errorf("signer is required") + } + if req.Audience == "" { + return nil, fmt.Errorf("audience is required") + } + if len(req.Commands) == 0 { + return nil, fmt.Errorf("at least one command is required") + } + + issuer, err := newIssuer(req) + if err != nil { + return nil, err + } + + audience, err := did.Parse(req.Audience) + if err != nil { + return nil, fmt.Errorf("parsing audience DID: %w", err) + } + + var opts []delegation.Option + if req.Expiration != nil { + if ucan.Now() > *req.Expiration { + return nil, fmt.Errorf("provided expiration time %d is in the past", *req.Expiration) + } + opts = append(opts, delegation.WithExpiration(*req.Expiration)) + } else { + // Passing no expiration option at all would make the delegation expire + // 30 seconds from now. + opts = append(opts, delegation.WithNoExpiration()) + } + + subject := issuer.DID() + if req.Subject != "" { + subject, err = did.Parse(req.Subject) + if err != nil { + return nil, fmt.Errorf("parsing subject DID: %w", err) + } + } + + var commands []ucan.Command + for _, commandStr := range req.Commands { + cmd, err := command.Parse(commandStr) + if err != nil { + return nil, fmt.Errorf("parsing command: %w", err) + } + commands = append(commands, cmd) + } + + if req.Policy != "" { + pol, err := policy.Parse(req.Policy) + if err != nil { + return nil, fmt.Errorf("parsing policy: %w", err) + } + opts = append(opts, delegation.WithPolicy(pol)) + } + + var delegations []ucan.Delegation + for _, cmd := range commands { + dlg, err := delegation.Delegate(issuer, audience, subject, cmd, opts...) + if err != nil { + return nil, fmt.Errorf("creating delegation: %w", err) + } + delegations = append(delegations, dlg) + } + + return delegations, nil +} + +// Encode encodes delegations in a UCAN container with the named codec. An empty +// codec encodes a single delegation as a bare DAG-CBOR block and multiple +// delegations with [ucanfmt.DefaultContainerCodec]. +func Encode(dlgs []ucan.Delegation, codec string) (Result, error) { + if len(dlgs) == 0 { + return Result{}, fmt.Errorf("no delegations to encode") + } + + if len(dlgs) == 1 && codec == "" { + out, err := delegation.Encode(dlgs[0]) + if err != nil { + return Result{}, fmt.Errorf("formatting delegation: %w", err) + } + return Result{Bytes: out}, nil + } + + if codec == "" { + codec = ucanfmt.DefaultContainerCodec + } + containerCodec, err := ucanfmt.ParseCodec(codec) + if err != nil { + return Result{}, err + } + + out, err := container.Encode(containerCodec, container.New(container.WithDelegations(dlgs...))) + if err != nil { + return Result{}, fmt.Errorf("encoding container: %w", err) + } + + return Result{Bytes: out, Codec: containerCodec}, nil +} + +// newIssuer builds the delegation issuer, wrapping the signer's did:key in a +// did:web DID when req asks for one. +func newIssuer(req Request) (multikey.Issuer, error) { + if req.IssuerDIDWeb == "" { + return multikey.KeyIssuer(req.Signer), nil + } + + issuerDID, err := did.Parse(req.IssuerDIDWeb) + if err != nil { + return nil, fmt.Errorf("parsing issuer DID: %w", err) + } + if issuerDID.Method() != "web" { + return nil, fmt.Errorf("issuer DID must start with 'did:web:'") + } + + return multikey.NewIssuer(issuerDID, req.Signer), nil +} diff --git a/pkg/ucandelegate/delegate_test.go b/pkg/ucandelegate/delegate_test.go new file mode 100644 index 0000000..825f002 --- /dev/null +++ b/pkg/ucandelegate/delegate_test.go @@ -0,0 +1,350 @@ +package ucandelegate_test + +import ( + "bytes" + "testing" + "time" + + "github.com/fil-forge/ucantone/multikey/ed25519" + "github.com/fil-forge/ucantone/testutil" + "github.com/fil-forge/ucantone/ucan" + "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/stretchr/testify/require" +) + +// newRequest builds a minimal valid request with a freshly generated signer. +func newRequest(t *testing.T) ucandelegate.Request { + t.Helper() + signer, err := ed25519.Generate() + require.NoError(t, err) + return ucandelegate.Request{ + Signer: signer, + Audience: testutil.RandomDID(t).String(), + Commands: []string{"/msg/send"}, + } +} + +func TestDelegateSingleCommand(t *testing.T) { + dlgs, err := ucandelegate.Delegate(newRequest(t)) + require.NoError(t, err) + require.Len(t, dlgs, 1) +} + +func TestDelegateOneDelegationPerCommand(t *testing.T) { + req := newRequest(t) + req.Commands = []string{"/msg/send", "/msg/recv"} + + dlgs, err := ucandelegate.Delegate(req) + require.NoError(t, err) + + commands := make([]string, 0, len(dlgs)) + for _, dlg := range dlgs { + commands = append(commands, dlg.Command().String()) + } + require.Equal(t, []string{"/msg/send", "/msg/recv"}, commands) +} + +func TestDelegateAudience(t *testing.T) { + req := newRequest(t) + + dlgs, err := ucandelegate.Delegate(req) + require.NoError(t, err) + require.Equal(t, req.Audience, dlgs[0].Audience().String()) +} + +func TestDelegateSubjectDefaultsToIssuer(t *testing.T) { + req := newRequest(t) + + dlgs, err := ucandelegate.Delegate(req) + require.NoError(t, err) + require.Equal(t, req.Signer.KeyDID(), dlgs[0].Subject()) +} + +func TestDelegateExplicitSubject(t *testing.T) { + req := newRequest(t) + req.Subject = testutil.RandomDID(t).String() + + dlgs, err := ucandelegate.Delegate(req) + require.NoError(t, err) + require.Equal(t, req.Subject, dlgs[0].Subject().String()) +} + +func TestDelegateIssuerDIDWeb(t *testing.T) { + req := newRequest(t) + req.IssuerDIDWeb = "did:web:example.com" + + dlgs, err := ucandelegate.Delegate(req) + require.NoError(t, err) + require.Equal(t, req.IssuerDIDWeb, dlgs[0].Issuer().String()) +} + +func TestDelegateIssuerDefaultsToSignerKeyDID(t *testing.T) { + req := newRequest(t) + + dlgs, err := ucandelegate.Delegate(req) + require.NoError(t, err) + require.Equal(t, req.Signer.KeyDID(), dlgs[0].Issuer()) +} + +// A nil Expiration must produce a delegation that never expires. Passing no +// expiration option to ucantone would silently expire it 30 seconds from now. +func TestDelegateNilExpirationNeverExpires(t *testing.T) { + dlgs, err := ucandelegate.Delegate(newRequest(t)) + require.NoError(t, err) + require.Nil(t, dlgs[0].Expiration()) +} + +func TestDelegateExpiration(t *testing.T) { + req := newRequest(t) + exp := ucan.Now() + 3600 + req.Expiration = &exp + + dlgs, err := ucandelegate.Delegate(req) + require.NoError(t, err) + require.Equal(t, &exp, dlgs[0].Expiration()) +} + +func TestDelegateExpiresIn(t *testing.T) { + req := newRequest(t) + req.Expiration = ucandelegate.ExpiresIn(time.Hour) + + dlgs, err := ucandelegate.Delegate(req) + require.NoError(t, err) + require.Equal(t, req.Expiration, dlgs[0].Expiration()) +} + +func TestExpiresAtTruncatesToWholeSecond(t *testing.T) { + at := time.Unix(1770000000, 999999999) + require.Equal(t, ucan.UnixTimestamp(1770000000), *ucandelegate.ExpiresAt(at)) +} + +func TestExpiresInIsRelativeToNow(t *testing.T) { + exp := *ucandelegate.ExpiresIn(time.Hour) + require.InDelta(t, int64(ucan.Now()+3600), int64(exp), 2) +} + +func TestDelegatePolicy(t *testing.T) { + req := newRequest(t) + req.Policy = `[["==", ".foo", "bar"]]` + + dlgs, err := ucandelegate.Delegate(req) + require.NoError(t, err) + + statements := dlgs[0].Policy().Statements() + require.Len(t, statements, 1) + require.Equal(t, policy.OpEqual, statements[0].Operator()) +} + +func TestDelegateInvalidRequests(t *testing.T) { + invalidRequests := map[string]struct { + mutate func(req *ucandelegate.Request) + expectedErrMsg string + }{ + "signer is missing": { + mutate: func(req *ucandelegate.Request) { req.Signer = nil }, + expectedErrMsg: "signer is required", + }, + "audience is empty": { + mutate: func(req *ucandelegate.Request) { req.Audience = "" }, + expectedErrMsg: "audience is required", + }, + "commands are empty": { + mutate: func(req *ucandelegate.Request) { req.Commands = nil }, + expectedErrMsg: "at least one command is required", + }, + "audience is not a DID": { + mutate: func(req *ucandelegate.Request) { req.Audience = "not-a-did" }, + expectedErrMsg: "parsing audience DID", + }, + "subject is not a DID": { + mutate: func(req *ucandelegate.Request) { req.Subject = "not-a-did" }, + expectedErrMsg: "parsing subject DID", + }, + "issuer DID is not a DID": { + mutate: func(req *ucandelegate.Request) { req.IssuerDIDWeb = "not-a-did" }, + expectedErrMsg: "parsing issuer DID", + }, + "issuer DID is not did:web": { + mutate: func(req *ucandelegate.Request) { req.IssuerDIDWeb = "did:key:z6Mk" }, + expectedErrMsg: "issuer DID must start with 'did:web:'", + }, + "command has no leading slash": { + mutate: func(req *ucandelegate.Request) { req.Commands = []string{"msg/send"} }, + expectedErrMsg: "parsing command", + }, + "policy is not valid DAG-JSON": { + mutate: func(req *ucandelegate.Request) { req.Policy = "{not json" }, + expectedErrMsg: "parsing policy", + }, + "expiration is in the past": { + mutate: func(req *ucandelegate.Request) { + exp := ucan.Now() - 1 + req.Expiration = &exp + }, + expectedErrMsg: "is in the past", + }, + } + + for desc, tc := range invalidRequests { + t.Run(desc, func(t *testing.T) { + req := newRequest(t) + tc.mutate(&req) + _, err := ucandelegate.Delegate(req) + require.ErrorContains(t, err, tc.expectedErrMsg) + }) + } +} + +func TestEncodeSingleDelegationAsBareCBOR(t *testing.T) { + dlgs, err := ucandelegate.Delegate(newRequest(t)) + require.NoError(t, err) + + res, err := ucandelegate.Encode(dlgs, "") + require.NoError(t, err) + require.Equal(t, byte(0), res.Codec) + + decoded, err := delegation.Decode(res.Bytes) + require.NoError(t, err) + require.Equal(t, dlgs[0].Link(), decoded.Link()) +} + +func TestEncodeMultipleDelegationsAsDefaultContainer(t *testing.T) { + req := newRequest(t) + req.Commands = []string{"/msg/send", "/msg/recv"} + dlgs, err := ucandelegate.Delegate(req) + require.NoError(t, err) + + res, err := ucandelegate.Encode(dlgs, "") + require.NoError(t, err) + require.Equal(t, container.Base64Gzip, res.Codec) + + decoded, err := container.Decode(res.Bytes) + require.NoError(t, err) + require.Len(t, decoded.Delegations(), 2) +} + +func TestEncodeSingleDelegationWithExplicitCodec(t *testing.T) { + dlgs, err := ucandelegate.Delegate(newRequest(t)) + require.NoError(t, err) + + res, err := ucandelegate.Encode(dlgs, "raw") + require.NoError(t, err) + require.Equal(t, container.Raw, res.Codec) + + decoded, err := container.Decode(res.Bytes) + require.NoError(t, err) + require.Len(t, decoded.Delegations(), 1) +} + +func TestEncodeUnknownCodec(t *testing.T) { + dlgs, err := ucandelegate.Delegate(newRequest(t)) + require.NoError(t, err) + + _, err = ucandelegate.Encode(dlgs, "bogus") + require.ErrorContains(t, err, "invalid container codec") +} + +func TestEncodeNoDelegations(t *testing.T) { + _, err := ucandelegate.Encode(nil, "") + require.ErrorContains(t, err, "no delegations to encode") +} + +func TestResultIsText(t *testing.T) { + textual := map[byte]bool{ + 0: false, + container.Raw: false, + container.RawGzip: false, + container.Base64: true, + container.Base64Gzip: true, + container.Base64urlGzip: true, + } + for codec, expected := range textual { + t.Run(container.FormatCodec(codec), func(t *testing.T) { + require.Equal(t, expected, ucandelegate.Result{Codec: codec}.IsText()) + }) + } +} + +func TestResultWriteToTerminatesTextWithNewline(t *testing.T) { + res := ucandelegate.Result{Bytes: []byte("Fabc"), Codec: container.Base64Gzip} + + var out bytes.Buffer + written, err := res.WriteTo(&out) + require.NoError(t, err) + require.Equal(t, "Fabc\n", out.String()) + require.Equal(t, int64(out.Len()), written) +} + +func TestResultWriteToWritesBinaryBare(t *testing.T) { + res := ucandelegate.Result{Bytes: []byte{0x40, 0x01}, Codec: container.Raw} + + var out bytes.Buffer + written, err := res.WriteTo(&out) + require.NoError(t, err) + require.Equal(t, []byte{0x40, 0x01}, out.Bytes()) + require.Equal(t, int64(2), written) +} + +func TestResultWriteToWritesBareDelegationBare(t *testing.T) { + dlgs, err := ucandelegate.Delegate(newRequest(t)) + require.NoError(t, err) + res, err := ucandelegate.Encode(dlgs, "") + require.NoError(t, err) + + var out bytes.Buffer + _, err = res.WriteTo(&out) + require.NoError(t, err) + + // Decoding fails if a newline trails the delegation. + _, err = delegation.Decode(out.Bytes()) + require.NoError(t, err) +} + +func TestIssueUsesRequestCodec(t *testing.T) { + req := newRequest(t) + req.ContainerCodec = "base64url+gzip" + + res, err := ucandelegate.Issue(req) + require.NoError(t, err) + require.Equal(t, container.Base64urlGzip, res.Codec) + + decoded, err := container.Decode(res.Bytes) + require.NoError(t, err) + require.Len(t, decoded.Delegations(), 1) +} + +func TestIssueFromPEM(t *testing.T) { + signer, err := ed25519.Generate() + require.NoError(t, err) + pemData, err := identity.EncodeSignerToPEM(signer) + require.NoError(t, err) + + res, err := ucandelegate.IssueFromPEM(pemData, ucandelegate.Request{ + Audience: testutil.RandomDID(t).String(), + Commands: []string{"/msg/send"}, + }) + require.NoError(t, err) + require.Equal(t, byte(0), res.Codec) + + decoded, err := delegation.Decode(res.Bytes) + require.NoError(t, err) + require.Equal(t, signer.KeyDID(), decoded.Issuer()) +} + +func TestIssueFromPEMRejectsSigner(t *testing.T) { + req := newRequest(t) + _, err := ucandelegate.IssueFromPEM(nil, req) + require.ErrorContains(t, err, "signer must not be set") +} + +func TestIssueFromPEMInvalidPEM(t *testing.T) { + _, err := ucandelegate.IssueFromPEM([]byte("not a pem"), ucandelegate.Request{ + Audience: testutil.RandomDID(t).String(), + Commands: []string{"/msg/send"}, + }) + require.ErrorContains(t, err, "decoding issuer private key") +} diff --git a/pkg/ucanfmt/codec.go b/pkg/ucanfmt/codec.go new file mode 100644 index 0000000..d4f9f69 --- /dev/null +++ b/pkg/ucanfmt/codec.go @@ -0,0 +1,43 @@ +package ucanfmt + +import ( + "fmt" + + "github.com/fil-forge/ucantone/ucan/container" +) + +// DefaultContainerCodec is the container codec used when none is requested. +const DefaultContainerCodec = "base64+gzip" + +// ParseCodec converts a human readable container codec name into a container +// codec code. It is the inverse of [container.FormatCodec]. +func ParseCodec(name string) (byte, error) { + switch name { + 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", name) + } +} + +// IsTextualCodec reports whether bytes encoded with the given container codec +// are printable text. Callers writing such bytes to a terminal or a text file +// should terminate them with a newline; binary output must be written bare. +func IsTextualCodec(codec byte) bool { + switch codec { + case container.Base64, container.Base64url, container.Base64Gzip, container.Base64urlGzip: + return true + default: + return false + } +} diff --git a/pkg/ucanfmt/codec_test.go b/pkg/ucanfmt/codec_test.go new file mode 100644 index 0000000..5db6115 --- /dev/null +++ b/pkg/ucanfmt/codec_test.go @@ -0,0 +1,60 @@ +package ucanfmt_test + +import ( + "testing" + + "github.com/fil-forge/ucantone/ucan/container" + "github.com/fil-forge/ucantool/pkg/ucanfmt" + "github.com/stretchr/testify/require" +) + +var codecNames = map[string]byte{ + "raw": container.Raw, + "base64": container.Base64, + "base64url": container.Base64url, + "raw+gzip": container.RawGzip, + "base64+gzip": container.Base64Gzip, + "base64url+gzip": container.Base64urlGzip, +} + +func TestParseCodec(t *testing.T) { + for name, codec := range codecNames { + t.Run(name, func(t *testing.T) { + parsed, err := ucanfmt.ParseCodec(name) + require.NoError(t, err) + require.Equal(t, codec, parsed) + }) + } +} + +func TestParseCodecRoundTripsFormatCodec(t *testing.T) { + for name := range codecNames { + t.Run(name, func(t *testing.T) { + parsed, err := ucanfmt.ParseCodec(name) + require.NoError(t, err) + require.Equal(t, name, container.FormatCodec(parsed)) + }) + } +} + +func TestParseCodecUnknownName(t *testing.T) { + _, err := ucanfmt.ParseCodec("bogus") + require.ErrorContains(t, err, `invalid container codec: "bogus"`) +} + +func TestIsTextualCodec(t *testing.T) { + textual := map[byte]bool{ + container.Raw: false, + container.RawGzip: false, + container.Base64: true, + container.Base64url: true, + container.Base64Gzip: true, + container.Base64urlGzip: true, + 0: false, + } + for codec, expected := range textual { + t.Run(container.FormatCodec(codec), func(t *testing.T) { + require.Equal(t, expected, ucanfmt.IsTextualCodec(codec)) + }) + } +}