From dc2705778b1c904a86f2533bae31f48f2ebd3858 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Tue, 11 Aug 2026 19:48:11 +0200 Subject: [PATCH 1/5] refactor: parse container codecs in ucanfmt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The delegate and container pack commands each hand-rolled the mapping from a codec name to a container codec byte, and each declared the "base64+gzip" default separately. Add ParseCodec next to the FormatCodec call it inverts, and express the binary-versus-text distinction as IsTextualCodec so every caller shares one rule for deciding whether output ends with a newline. Convert the pack command; the delegate command follows once its delegation logic moves out of the cobra layer. Assisted-by: Claude:claude-opus-5 Signed-off-by: Miroslav Bajtoš --- cmd/container/pack.go | 28 +++--------------- pkg/ucanfmt/codec.go | 43 ++++++++++++++++++++++++++++ pkg/ucanfmt/codec_test.go | 60 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 107 insertions(+), 24 deletions(-) create mode 100644 pkg/ucanfmt/codec.go create mode 100644 pkg/ucanfmt/codec_test.go 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/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)) + }) + } +} From 9c547285e52ac5ee934bd25e602a74d4ef820322 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Tue, 11 Aug 2026 19:48:28 +0200 Subject: [PATCH 2/5] fix: don't format private keys into PEM errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EncodeSignerToPEM interpolated the signer itself with %s. ed25519.Signer is a []byte with no String method, so the raw private key bytes rendered into the error text and from there into anything that logged it. Format the key DID instead. Add LoadSignerFromPEMFile alongside DecodeSignerFromPEM. The delegate command carries its own copy today and switches to this one once its delegation logic moves out of the cobra layer; callers holding a key in memory keep using DecodeSignerFromPEM and never touch the filesystem. Assisted-by: Claude:claude-opus-5 Signed-off-by: Miroslav Bajtoš --- pkg/identity/pem.go | 15 +++++++++++++-- pkg/identity/pem_test.go | 22 ++++++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) 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) From e4a9e18a1654d7a90b63c51cc0316c161cc41457 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Tue, 11 Aug 2026 19:48:28 +0200 Subject: [PATCH 3/5] feat: add pkg/ucandelegate for issuing delegations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issuing a delegation required the ucantool binary, and the CLI takes the issuer key as a file path, so an importing module had to write private keys to disk purely to satisfy an API shape. Lift the orchestration out of the delegate command's RunE into a package that takes a signer or PEM bytes in memory. Request replaces the cobra flag globals. Two behaviours are worth naming: a nil Expiration passes WithNoExpiration explicitly, because omitting the option entirely makes ucantone expire the delegation 30 seconds from now; and Result.WriteTo carries the rule that textual container codecs end with a newline while binary output is written bare, so consumers match the CLI byte for byte without reimplementing it. Assisted-by: Claude:claude-opus-5 Signed-off-by: Miroslav Bajtoš --- pkg/ucandelegate/delegate.go | 239 ++++++++++++++++++++ pkg/ucandelegate/delegate_test.go | 350 ++++++++++++++++++++++++++++++ 2 files changed, 589 insertions(+) create mode 100644 pkg/ucandelegate/delegate.go create mode 100644 pkg/ucandelegate/delegate_test.go diff --git a/pkg/ucandelegate/delegate.go b/pkg/ucandelegate/delegate.go new file mode 100644 index 0000000..215f511 --- /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) { + if r.IsText() { + n, err := fmt.Fprintln(w, string(r.Bytes)) + return int64(n), err + } + n, err := w.Write(r.Bytes) + return int64(n), 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") +} From 324a869cd06fefee68ee3b6142481476643b76fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Tue, 11 Aug 2026 19:48:39 +0200 Subject: [PATCH 4/5] refactor: bind delegate flags to ucandelegate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The delegate command is now a flag binder over ucandelegate.Issue, which is the check that the extraction is faithful: nothing but reading flags and writing bytes is left behind. Add a test for the trailing-newline rule, since the delegation payload itself cannot be compared against a golden file. ucantone generates a random nonce for every delegation, so the encoded bytes differ on every run. Assisted-by: Claude:claude-opus-5 Signed-off-by: Miroslav Bajtoš --- README.md | 28 +++++++++ cmd/delegate.go | 135 +++++-------------------------------------- cmd/delegate_test.go | 106 +++++++++++++++++++++++++++++++++ 3 files changed, 149 insertions(+), 120 deletions(-) create mode 100644 cmd/delegate_test.go 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/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") + }) +} From 99d22954041f1084691ee1a825718cba5a6f7f60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Tue, 11 Aug 2026 21:06:56 +0200 Subject: [PATCH 5/5] perf: write delegation bytes without fmt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Result.WriteTo copied the whole output into a string and ran it through fmt just to append a newline. Write the bytes directly and follow with a single '\n' for textual codecs. A failed or short first write now reports its partial count instead of fmt's. Signed-off-by: Miroslav Bajtoš Assisted-by: Claude:claude-opus-5[1m] --- pkg/ucandelegate/delegate.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pkg/ucandelegate/delegate.go b/pkg/ucandelegate/delegate.go index 215f511..aa9e52b 100644 --- a/pkg/ucandelegate/delegate.go +++ b/pkg/ucandelegate/delegate.go @@ -84,12 +84,12 @@ func (r Result) IsText() bool { // 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) { - if r.IsText() { - n, err := fmt.Fprintln(w, string(r.Bytes)) + n, err := w.Write(r.Bytes) + if err != nil || !r.IsText() { return int64(n), err } - n, err := w.Write(r.Bytes) - 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