diff --git a/CHANGELOG.md b/CHANGELOG.md index 5329c317b..11264dfb4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,11 @@ The following emojis are used to highlight certain changes: ### Changed +* 🛠 The `ipns` package has been refactored. You should no longer use the direct Protobuf + version of the IPNS Record. Instead, we have a shiny new `ipns.Record` type that wraps + all the required functionality to work the best as possible with IPNS v2 Records. Please + check the [documentation](https://pkg.go.dev/github.com/ipfs/boxo/ipns) for more information. + ### Removed ### Fixed diff --git a/coreiface/options/name.go b/coreiface/options/name.go index ae8be9ae9..8e9b5183d 100644 --- a/coreiface/options/name.go +++ b/coreiface/options/name.go @@ -11,12 +11,11 @@ const ( ) type NamePublishSettings struct { - ValidTime time.Duration - Key string - - TTL *time.Duration - - AllowOffline bool + ValidTime time.Duration + Key string + TTL *time.Duration + CompatibleWithV1 bool + AllowOffline bool } type NameResolveSettings struct { @@ -104,6 +103,15 @@ func (nameOpts) TTL(ttl time.Duration) NamePublishOption { } } +// CompatibleWithV1 is an option for [Name.Publish] which specifies if the +// created record should be backwards compatible with V1 IPNS Records. +func (nameOpts) CompatibleWithV1(compatible bool) NamePublishOption { + return func(settings *NamePublishSettings) error { + settings.CompatibleWithV1 = compatible + return nil + } +} + // Cache is an option for Name.Resolve which specifies if cache should be used. // Default value is true func (nameOpts) Cache(cache bool) NameResolveOption { diff --git a/coreiface/options/namesys/opts.go b/coreiface/options/namesys/opts.go index 0cd1ba778..ed568200b 100644 --- a/coreiface/options/namesys/opts.go +++ b/coreiface/options/namesys/opts.go @@ -84,8 +84,9 @@ func ProcessOpts(opts []ResolveOpt) ResolveOpts { // PublishOptions specifies options for publishing an IPNS record. type PublishOptions struct { - EOL time.Time - TTL time.Duration + EOL time.Time + TTL time.Duration + CompatibleWithV1 bool } // DefaultPublishOptions returns the default options for publishing an IPNS record. @@ -113,6 +114,13 @@ func PublishWithTTL(ttl time.Duration) PublishOption { } } +// PublishCompatibleWithV1 sets compatibility with IPNS Records V1. +func PublishCompatibleWithV1(compatible bool) PublishOption { + return func(o *PublishOptions) { + o.CompatibleWithV1 = compatible + } +} + // ProcessPublishOptions converts an array of PublishOpt into a PublishOpts object. func ProcessPublishOptions(opts []PublishOption) PublishOptions { rsopts := DefaultPublishOptions() diff --git a/examples/gateway/proxy/routing.go b/examples/gateway/proxy/routing.go index 29d112894..bbb66046c 100644 --- a/examples/gateway/proxy/routing.go +++ b/examples/gateway/proxy/routing.go @@ -7,9 +7,7 @@ import ( "net/http" "strings" - "github.com/gogo/protobuf/proto" "github.com/ipfs/boxo/ipns" - ipns_pb "github.com/ipfs/boxo/ipns/pb" "github.com/libp2p/go-libp2p/core/peer" "github.com/libp2p/go-libp2p/core/routing" "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" @@ -104,13 +102,12 @@ func (ps *proxyRouting) fetch(ctx context.Context, id peer.ID) ([]byte, error) { return nil, err } - var entry ipns_pb.IpnsEntry - err = proto.Unmarshal(rb, &entry) + rec, err := ipns.UnmarshalRecord(rb) if err != nil { return nil, err } - err = ipns.ValidateWithPeerID(id, &entry) + err = ipns.ValidateWithPeerID(rec, id) if err != nil { return nil, err } diff --git a/examples/go.mod b/examples/go.mod index a7aa94964..0e3b04d51 100644 --- a/examples/go.mod +++ b/examples/go.mod @@ -3,8 +3,7 @@ module github.com/ipfs/boxo/examples go 1.19 require ( - github.com/gogo/protobuf v1.3.2 - github.com/ipfs/boxo v0.7.1-0.20230323075409-f4a8dd6614df + github.com/ipfs/boxo v0.8.0 github.com/ipfs/go-block-format v0.1.2 github.com/ipfs/go-cid v0.4.0 github.com/ipfs/go-datastore v0.6.0 @@ -45,6 +44,7 @@ require ( github.com/go-logr/stdr v1.2.2 // indirect github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0 // indirect github.com/godbus/dbus/v5 v5.1.0 // indirect + github.com/gogo/protobuf v1.3.2 // indirect github.com/golang/mock v1.6.0 // indirect github.com/golang/protobuf v1.5.2 // indirect github.com/google/gopacket v1.1.19 // indirect @@ -64,7 +64,6 @@ require ( github.com/ipfs/go-ipld-cbor v0.0.6 // indirect github.com/ipfs/go-ipld-format v0.5.0 // indirect github.com/ipfs/go-ipld-legacy v0.2.1 // indirect - github.com/ipfs/go-ipns v0.3.0 // indirect github.com/ipfs/go-log v1.0.5 // indirect github.com/ipfs/go-log/v2 v2.5.1 // indirect github.com/ipfs/go-metrics-interface v0.0.1 // indirect @@ -82,7 +81,7 @@ require ( github.com/libp2p/go-doh-resolver v0.4.0 // indirect github.com/libp2p/go-flow-metrics v0.1.0 // indirect github.com/libp2p/go-libp2p-asn-util v0.2.0 // indirect - github.com/libp2p/go-libp2p-kad-dht v0.21.1 // indirect + github.com/libp2p/go-libp2p-kad-dht v0.23.0 // indirect github.com/libp2p/go-libp2p-kbucket v0.5.0 // indirect github.com/libp2p/go-libp2p-record v0.2.0 // indirect github.com/libp2p/go-msgio v0.3.0 // indirect @@ -160,6 +159,7 @@ require ( golang.org/x/text v0.7.0 // indirect golang.org/x/tools v0.3.0 // indirect golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect + gonum.org/v1/gonum v0.11.0 // indirect google.golang.org/genproto v0.0.0-20230110181048-76db0878b65f // indirect google.golang.org/grpc v1.53.0 // indirect google.golang.org/protobuf v1.28.1 // indirect diff --git a/examples/go.sum b/examples/go.sum index aa888c694..0788f632d 100644 --- a/examples/go.sum +++ b/examples/go.sum @@ -312,8 +312,6 @@ github.com/ipfs/go-ipld-format v0.5.0 h1:WyEle9K96MSrvr47zZHKKcDxJ/vlpET6PSiQsAF github.com/ipfs/go-ipld-format v0.5.0/go.mod h1:ImdZqJQaEouMjCvqCe0ORUS+uoBmf7Hf+EO/jh+nk3M= github.com/ipfs/go-ipld-legacy v0.2.1 h1:mDFtrBpmU7b//LzLSypVrXsD8QxkEWxu5qVxN99/+tk= github.com/ipfs/go-ipld-legacy v0.2.1/go.mod h1:782MOUghNzMO2DER0FlBR94mllfdCJCkTtDtPM51otM= -github.com/ipfs/go-ipns v0.3.0 h1:ai791nTgVo+zTuq2bLvEGmWP1M0A6kGTXUsgv/Yq67A= -github.com/ipfs/go-ipns v0.3.0/go.mod h1:3cLT2rbvgPZGkHJoPO1YMJeh6LtkxopCkKFcio/wE24= github.com/ipfs/go-log v1.0.5 h1:2dOuUCB1Z7uoczMWgAyDck5JLb72zHzrMnGnCNNbvY8= github.com/ipfs/go-log v1.0.5/go.mod h1:j0b8ZoR+7+R99LD9jZ6+AJsrzkPbSXbZfGakb5JPtIo= github.com/ipfs/go-log/v2 v2.1.3/go.mod h1:/8d0SH3Su5Ooc31QlL1WysJhvyOTDCjcCZ9Axpmri6g= @@ -393,8 +391,8 @@ github.com/libp2p/go-libp2p v0.26.3 h1:6g/psubqwdaBqNNoidbRKSTBEYgaOuKBhHl8Q5tO+ github.com/libp2p/go-libp2p v0.26.3/go.mod h1:x75BN32YbwuY0Awm2Uix4d4KOz+/4piInkp4Wr3yOo8= github.com/libp2p/go-libp2p-asn-util v0.2.0 h1:rg3+Os8jbnO5DxkC7K/Utdi+DkY3q/d1/1q+8WeNAsw= github.com/libp2p/go-libp2p-asn-util v0.2.0/go.mod h1:WoaWxbHKBymSN41hWSq/lGKJEca7TNm58+gGJi2WsLI= -github.com/libp2p/go-libp2p-kad-dht v0.21.1 h1:xpfp8/t9+X2ip1l8Umap1/UGNnJ3RHJgKGAEsnRAlTo= -github.com/libp2p/go-libp2p-kad-dht v0.21.1/go.mod h1:Oy8wvbdjpB70eS5AaFaI68tOtrdo3KylTvXDjikxqFo= +github.com/libp2p/go-libp2p-kad-dht v0.23.0 h1:sxE6LxLopp79eLeV695n7+c77V/Vn4AMF28AdM/XFqM= +github.com/libp2p/go-libp2p-kad-dht v0.23.0/go.mod h1:oO5N308VT2msnQI6qi5M61wzPmJYg7Tr9e16m5n7uDU= github.com/libp2p/go-libp2p-kbucket v0.5.0 h1:g/7tVm8ACHDxH29BGrpsQlnNeu+6OF1A9bno/4/U1oA= github.com/libp2p/go-libp2p-kbucket v0.5.0/go.mod h1:zGzGCpQd78b5BNTDGHNDLaTt9aDK/A02xeZp9QeFC4U= github.com/libp2p/go-libp2p-record v0.2.0 h1:oiNUOCWno2BFuxt3my4i1frNrt7PerzB3queqa1NkQ0= @@ -963,6 +961,8 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 h1:H2TDz8ibqkAF6YGhCdN3jS9O0/s90v0rJh3X/OLHEUk= golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8= +gonum.org/v1/gonum v0.11.0 h1:f1IJhK4Km5tBJmaiJXtk/PkL4cdVX6J+tGiM187uT5E= +gonum.org/v1/gonum v0.11.0/go.mod h1:fSG4YDCxxUZQJ7rKsQrj0gMOg00Il0Z96/qMA4bVQhA= google.golang.org/api v0.0.0-20180910000450-7ca32eb868bf/go.mod h1:4mhQ8q/RsB7i+udVvVy5NUi08OU8ZlA0gRVgrF7VFY0= google.golang.org/api v0.0.0-20181030000543-1d582fd0359e/go.mod h1:4mhQ8q/RsB7i+udVvVy5NUi08OU8ZlA0gRVgrF7VFY0= google.golang.org/api v0.1.0/go.mod h1:UGEZY7KEX120AnNLIHFMKIo4obdJhkp2tPbaPlQx13Y= diff --git a/gateway/handler_ipns_record.go b/gateway/handler_ipns_record.go index 66023f0d3..40804e005 100644 --- a/gateway/handler_ipns_record.go +++ b/gateway/handler_ipns_record.go @@ -49,7 +49,7 @@ func (i *handler) serveIpnsRecord(ctx context.Context, w http.ResponseWriter, r return false } - record, err := ipns.UnmarshalIpnsEntry(rawRecord) + record, err := ipns.UnmarshalRecord(rawRecord) if err != nil { i.webError(w, r, err, http.StatusInternalServerError) return false @@ -69,9 +69,8 @@ func (i *handler) serveIpnsRecord(ctx context.Context, w http.ResponseWriter, r return false } - if record.Ttl != nil { - seconds := int(time.Duration(*record.Ttl).Seconds()) - w.Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%d", seconds)) + if ttl, err := record.TTL(); err == nil { + w.Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%d", int(ttl.Seconds()))) } else { w.Header().Set("Last-Modified", time.Now().UTC().Format(http.TimeFormat)) } diff --git a/go.mod b/go.mod index 49fce9b75..942c00dd3 100644 --- a/go.mod +++ b/go.mod @@ -38,7 +38,7 @@ require ( github.com/libp2p/go-buffer-pool v0.1.0 github.com/libp2p/go-doh-resolver v0.4.0 github.com/libp2p/go-libp2p v0.26.3 - github.com/libp2p/go-libp2p-kad-dht v0.21.1 + github.com/libp2p/go-libp2p-kad-dht v0.23.0 github.com/libp2p/go-libp2p-record v0.2.0 github.com/libp2p/go-libp2p-routing-helpers v0.7.0 github.com/libp2p/go-libp2p-testing v0.12.0 @@ -112,7 +112,6 @@ require ( github.com/ipfs/go-ipfs-ds-help v1.1.0 // indirect github.com/ipfs/go-ipfs-pq v0.0.3 // indirect github.com/ipfs/go-ipfs-util v0.0.2 // indirect - github.com/ipfs/go-ipns v0.3.0 // indirect github.com/ipfs/go-log v1.0.5 // indirect github.com/ipfs/go-unixfs v0.4.5 // indirect github.com/ipld/go-car/v2 v2.9.1-0.20230325062757-fff0e4397a3d // indirect @@ -167,6 +166,7 @@ require ( golang.org/x/text v0.7.0 // indirect golang.org/x/tools v0.3.0 // indirect golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect + gonum.org/v1/gonum v0.11.0 // indirect google.golang.org/appengine v1.6.7 // indirect google.golang.org/genproto v0.0.0-20230110181048-76db0878b65f // indirect google.golang.org/grpc v1.53.0 // indirect diff --git a/go.sum b/go.sum index c05d4d76f..10e5df123 100644 --- a/go.sum +++ b/go.sum @@ -325,8 +325,6 @@ github.com/ipfs/go-ipld-format v0.5.0 h1:WyEle9K96MSrvr47zZHKKcDxJ/vlpET6PSiQsAF github.com/ipfs/go-ipld-format v0.5.0/go.mod h1:ImdZqJQaEouMjCvqCe0ORUS+uoBmf7Hf+EO/jh+nk3M= github.com/ipfs/go-ipld-legacy v0.2.1 h1:mDFtrBpmU7b//LzLSypVrXsD8QxkEWxu5qVxN99/+tk= github.com/ipfs/go-ipld-legacy v0.2.1/go.mod h1:782MOUghNzMO2DER0FlBR94mllfdCJCkTtDtPM51otM= -github.com/ipfs/go-ipns v0.3.0 h1:ai791nTgVo+zTuq2bLvEGmWP1M0A6kGTXUsgv/Yq67A= -github.com/ipfs/go-ipns v0.3.0/go.mod h1:3cLT2rbvgPZGkHJoPO1YMJeh6LtkxopCkKFcio/wE24= github.com/ipfs/go-log v0.0.1/go.mod h1:kL1d2/hzSpI0thNYjiKfjanbVNU+IIGA/WnNESY9leM= github.com/ipfs/go-log v1.0.5 h1:2dOuUCB1Z7uoczMWgAyDck5JLb72zHzrMnGnCNNbvY8= github.com/ipfs/go-log v1.0.5/go.mod h1:j0b8ZoR+7+R99LD9jZ6+AJsrzkPbSXbZfGakb5JPtIo= @@ -413,8 +411,8 @@ github.com/libp2p/go-libp2p v0.26.3 h1:6g/psubqwdaBqNNoidbRKSTBEYgaOuKBhHl8Q5tO+ github.com/libp2p/go-libp2p v0.26.3/go.mod h1:x75BN32YbwuY0Awm2Uix4d4KOz+/4piInkp4Wr3yOo8= github.com/libp2p/go-libp2p-asn-util v0.2.0 h1:rg3+Os8jbnO5DxkC7K/Utdi+DkY3q/d1/1q+8WeNAsw= github.com/libp2p/go-libp2p-asn-util v0.2.0/go.mod h1:WoaWxbHKBymSN41hWSq/lGKJEca7TNm58+gGJi2WsLI= -github.com/libp2p/go-libp2p-kad-dht v0.21.1 h1:xpfp8/t9+X2ip1l8Umap1/UGNnJ3RHJgKGAEsnRAlTo= -github.com/libp2p/go-libp2p-kad-dht v0.21.1/go.mod h1:Oy8wvbdjpB70eS5AaFaI68tOtrdo3KylTvXDjikxqFo= +github.com/libp2p/go-libp2p-kad-dht v0.23.0 h1:sxE6LxLopp79eLeV695n7+c77V/Vn4AMF28AdM/XFqM= +github.com/libp2p/go-libp2p-kad-dht v0.23.0/go.mod h1:oO5N308VT2msnQI6qi5M61wzPmJYg7Tr9e16m5n7uDU= github.com/libp2p/go-libp2p-kbucket v0.5.0 h1:g/7tVm8ACHDxH29BGrpsQlnNeu+6OF1A9bno/4/U1oA= github.com/libp2p/go-libp2p-kbucket v0.5.0/go.mod h1:zGzGCpQd78b5BNTDGHNDLaTt9aDK/A02xeZp9QeFC4U= github.com/libp2p/go-libp2p-record v0.2.0 h1:oiNUOCWno2BFuxt3my4i1frNrt7PerzB3queqa1NkQ0= @@ -982,6 +980,8 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 h1:H2TDz8ibqkAF6YGhCdN3jS9O0/s90v0rJh3X/OLHEUk= golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8= +gonum.org/v1/gonum v0.11.0 h1:f1IJhK4Km5tBJmaiJXtk/PkL4cdVX6J+tGiM187uT5E= +gonum.org/v1/gonum v0.11.0/go.mod h1:fSG4YDCxxUZQJ7rKsQrj0gMOg00Il0Z96/qMA4bVQhA= google.golang.org/api v0.0.0-20180910000450-7ca32eb868bf/go.mod h1:4mhQ8q/RsB7i+udVvVy5NUi08OU8ZlA0gRVgrF7VFY0= google.golang.org/api v0.0.0-20181030000543-1d582fd0359e/go.mod h1:4mhQ8q/RsB7i+udVvVy5NUi08OU8ZlA0gRVgrF7VFY0= google.golang.org/api v0.1.0/go.mod h1:UGEZY7KEX120AnNLIHFMKIo4obdJhkp2tPbaPlQx13Y= diff --git a/ipns/README.md b/ipns/README.md index 0afa0be00..502f6dded 100644 --- a/ipns/README.md +++ b/ipns/README.md @@ -1,31 +1,48 @@ -## Usage +# IPNS -To create a new IPNS record: +> A reference implementation of the IPNS Record and Verification specification. + +## Documentation + +- Go Documentation: https://pkg.go.dev/github.com/ipfs/boxo/ipns +- IPNS Record Specification: https://specs.ipfs.tech/ipns/ipns-record/ + +## Example + +Here's an example on how to create an IPNS Record: ```go import ( + "crypto/rand" "time" - ipns "github.com/ipfs/boxo/ipns" - crypto "github.com/libp2p/go-libp2p/core/crypto" + "github.com/ipfs/boxo/ipns" + "github.com/ipfs/boxo/path" + ic "github.com/libp2p/go-libp2p/core/crypto" ) -// Generate a private key to sign the IPNS record with. Most of the time, -// however, you'll want to retrieve an already-existing key from IPFS using the -// go-ipfs/core/coreapi CoreAPI.KeyAPI() interface. -privateKey, publicKey, err := crypto.GenerateKeyPair(crypto.RSA, 2048) -if err != nil { - panic(err) -} +func main() { + // Create a private key to sign your IPNS record. Most of the time, you + // will want to retrieve an already-existing key from Kubo, for example. + sk, _, err := ic.GenerateEd25519Key(rand.Reader) + if err != nil { + panic(err) + } -// Create an IPNS record that expires in one hour and points to the IPFS address -// /ipfs/Qme1knMqwt1hKZbc1BmQFmnm9f36nyQGwXxPGVpVJ9rMK5 -ipnsRecord, err := ipns.Create(privateKey, []byte("/ipfs/Qme1knMqwt1hKZbc1BmQFmnm9f36nyQGwXxPGVpVJ9rMK5"), 0, time.Now().Add(1*time.Hour)) -if err != nil { - panic(err) -} -``` + // Define the path this record will point to. + path := path.FromString("/ipfs/bafkqac3jobxhgidsn5rww4yk") + + // Until when the record is valid. + eol := time.Now().Add(time.Hour) + + // For how long should caches cache the record. + ttl := time.Second * 20 -Once you have the record, you’ll need to use IPFS to *publish* it. + record, err := ipns.NewRecord(sk, path, 1, eol, ttl) + if err != nil { + panic(err) + } -There are several other major operations you can do with `go-ipns`. Check out the [API docs](https://pkg.go.dev/github.com/ipfs/boxo/ipns) or look at the tests in this repo for examples. + // Now you have an IPNS Record. +} +``` diff --git a/ipns/errors.go b/ipns/errors.go index d78aafffa..84cc45803 100644 --- a/ipns/errors.go +++ b/ipns/errors.go @@ -4,41 +4,45 @@ import ( "errors" ) -// ErrExpiredRecord should be returned when an ipns record is -// invalid due to being too old +// ErrExpiredRecord is returned when an IPNS Record is invalid due to being expired. var ErrExpiredRecord = errors.New("expired record") -// ErrUnrecognizedValidity is returned when an IpnsRecord has an -// unknown validity type. +// ErrUnrecognizedValidity is returned when an IPNS Record has an unknown validity type. var ErrUnrecognizedValidity = errors.New("unrecognized validity type") -// ErrInvalidPath should be returned when an ipns record path -// is not in a valid format +// ErrInvalidValidity is returned when an IPNS Record has a known validity type, +// but the validity value is invalid. +var ErrInvalidValidity = errors.New("invalid validity") + +// ErrInvalidPath is returned when an IPNS Record has an invalid path. var ErrInvalidPath = errors.New("record path invalid") -// ErrSignature should be returned when an ipns record fails -// signature verification +// ErrInvalidPublicKey is returned when an IPNS Record has an invalid public key, +var ErrInvalidPublicKey = errors.New("record public key invalid") + +// ErrSignature is returned when an IPNS Record fails signature verification. var ErrSignature = errors.New("record signature verification failed") -// ErrKeyFormat should be returned when an ipns record key is -// incorrectly formatted (not a peer ID) +// ErrKeyFormat is returned when an IPNS Record key is incorrectly formatted. var ErrKeyFormat = errors.New("record key could not be parsed into peer ID") -// ErrPublicKeyNotFound should be returned when the public key -// corresponding to the ipns record path cannot be retrieved -// from the peer store -var ErrPublicKeyNotFound = errors.New("public key not found in peer store") +// ErrPublicKeyNotFound is returned when the public key is not found. +var ErrPublicKeyNotFound = errors.New("public key not found") -// ErrPublicKeyMismatch should be returned when the public key embedded in the -// record doesn't match the expected public key. -var ErrPublicKeyMismatch = errors.New("public key in record did not match expected pubkey") +// ErrPublicKeyMismatch is returned when the public key embedded in the IPNS +// Record doesn't match the expected public key. +var ErrPublicKeyMismatch = errors.New("public key in record did not match expected public key") -// ErrBadRecord should be returned when an ipns record cannot be unmarshalled +// ErrBadRecord is returned when an IPNS Record cannot be unmarshalled. var ErrBadRecord = errors.New("record could not be unmarshalled") -// 10 KiB limit defined in https://github.com/ipfs/specs/pull/319 -const MaxRecordSize int = 10 << (10 * 1) +// ErrDataMissing is returned when the record is missing the data field. +var ErrDataMissing = errors.New("data field is missing from record") -// ErrRecordSize should be returned when an ipns record is -// invalid due to being too big +// ErrRecordSize is returned when an IPNS Record is too long. var ErrRecordSize = errors.New("record exceeds allowed size limit") + +// MaxRecordSize is the IPNS Record [size limit]. +// +// [size limit]: https://specs.ipfs.tech/ipns/ipns-record/#record-size-limit +const MaxRecordSize int = 10 << (10 * 1) diff --git a/ipns/examples/embed.go b/ipns/examples/embed.go deleted file mode 100644 index 1f33d514d..000000000 --- a/ipns/examples/embed.go +++ /dev/null @@ -1,27 +0,0 @@ -package examples - -import ( - "time" - - pb "github.com/ipfs/boxo/ipns/pb" - - "github.com/ipfs/boxo/ipns" - "github.com/libp2p/go-libp2p/core/crypto" -) - -// CreateEntryWithEmbed shows how you can create an IPNS entry -// and embed it with a public key. For ed25519 keys this is not needed -// so attempting to embed with an ed25519 key, will not actually embed the key -func CreateEntryWithEmbed(ipfsPath string, publicKey crypto.PubKey, privateKey crypto.PrivKey) (*pb.IpnsEntry, error) { - ipfsPathByte := []byte(ipfsPath) - eol := time.Now().Add(time.Hour * 48) - entry, err := ipns.Create(privateKey, ipfsPathByte, 1, eol, 0) - if err != nil { - return nil, err - } - err = ipns.EmbedPublicKey(publicKey, entry) - if err != nil { - return nil, err - } - return entry, nil -} diff --git a/ipns/examples/examples_test.go b/ipns/examples/examples_test.go deleted file mode 100644 index 0fe182d9f..000000000 --- a/ipns/examples/examples_test.go +++ /dev/null @@ -1,60 +0,0 @@ -package examples_test - -import ( - "testing" - - "github.com/ipfs/boxo/ipns/examples" - "github.com/libp2p/go-libp2p/core/crypto" -) - -var testPath = "/ipfs/Qme1knMqwt1hKZbc1BmQFmnm9f36nyQGwXxPGVpVJ9rMK5" - -func TestKeyGeneration(t *testing.T) { - _, err := generateRSAKey() - if err != nil { - t.Error(err) - } - - _, err = generateEDKey() - if err != nil { - t.Error(err) - } -} - -func TestEmbeddedEntryCreation(t *testing.T) { - rk, err := generateRSAKey() - if err != nil { - t.Fatal(err) - } - - ek, err := generateEDKey() - if err != nil { - t.Fatal(err) - } - _, err = examples.CreateEntryWithEmbed(testPath, rk.GetPublic(), rk) - if err != nil { - t.Error(err) - } - - _, err = examples.CreateEntryWithEmbed(testPath, ek.GetPublic(), ek) - if err != nil { - t.Error(err) - } - -} -func generateRSAKey() (crypto.PrivKey, error) { - k, err := examples.GenerateRSAKeyPair(2048) - if err != nil { - return nil, err - } - return k, nil -} - -func generateEDKey() (crypto.PrivKey, error) { - // ED25519 uses 256bit keys, and ignore the bit param - k, err := examples.GenerateEDKeyPair() - if err != nil { - return nil, err - } - return k, nil -} diff --git a/ipns/examples/key.go b/ipns/examples/key.go deleted file mode 100644 index 94f219b8d..000000000 --- a/ipns/examples/key.go +++ /dev/null @@ -1,24 +0,0 @@ -package examples - -import ( - "github.com/libp2p/go-libp2p/core/crypto" -) - -// GenerateRSAKeyPair is used to generate an RSA key pair -func GenerateRSAKeyPair(bits int) (crypto.PrivKey, error) { - priv, _, err := crypto.GenerateKeyPair(crypto.RSA, bits) - if err != nil { - return nil, err - } - return priv, nil -} - -// GenerateEDKeyPair is used to generate an ED25519 keypair -func GenerateEDKeyPair() (crypto.PrivKey, error) { - // ED25519 ignores the bit param and uses 256bit keys - priv, _, err := crypto.GenerateKeyPair(crypto.Ed25519, 256) - if err != nil { - return nil, err - } - return priv, nil -} diff --git a/ipns/ipns.go b/ipns/ipns.go deleted file mode 100644 index db92b39ff..000000000 --- a/ipns/ipns.go +++ /dev/null @@ -1,419 +0,0 @@ -package ipns - -import ( - "bytes" - "fmt" - "sort" - "time" - - "github.com/multiformats/go-multicodec" - "github.com/pkg/errors" - - "github.com/ipld/go-ipld-prime" - _ "github.com/ipld/go-ipld-prime/codec/dagcbor" // used to import the DagCbor encoder/decoder - ipldcodec "github.com/ipld/go-ipld-prime/multicodec" - basicnode "github.com/ipld/go-ipld-prime/node/basic" - - "github.com/gogo/protobuf/proto" - - pb "github.com/ipfs/boxo/ipns/pb" - - u "github.com/ipfs/boxo/util" - ic "github.com/libp2p/go-libp2p/core/crypto" - "github.com/libp2p/go-libp2p/core/peer" -) - -const ( - validity = "Validity" - validityType = "ValidityType" - value = "Value" - sequence = "Sequence" - ttl = "TTL" -) - -// Create creates a new IPNS entry and signs it with the given private key. -// -// This function does not embed the public key. If you want to do that, use -// `EmbedPublicKey`. -func Create(sk ic.PrivKey, val []byte, seq uint64, eol time.Time, ttl time.Duration) (*pb.IpnsEntry, error) { - entry := new(pb.IpnsEntry) - - entry.Value = val - typ := pb.IpnsEntry_EOL - entry.ValidityType = &typ - entry.Sequence = &seq - entry.Validity = []byte(u.FormatRFC3339(eol)) - - ttlNs := uint64(ttl.Nanoseconds()) - entry.Ttl = proto.Uint64(ttlNs) - - cborData, err := createCborDataForIpnsEntry(entry) - if err != nil { - return nil, err - } - entry.Data = cborData - - // For now we still create V1 signatures. These are deprecated, and not - // used during verification anymore (Validate func requires SignatureV2), - // but setting it here allows legacy nodes (e.g., go-ipfs < v0.9.0) to - // still resolve IPNS published by modern nodes. - sig1, err := sk.Sign(ipnsEntryDataForSigV1(entry)) - if err != nil { - return nil, errors.Wrap(err, "could not compute signature data") - } - entry.SignatureV1 = sig1 - - sig2Data, err := ipnsEntryDataForSigV2(entry) - if err != nil { - return nil, err - } - sig2, err := sk.Sign(sig2Data) - if err != nil { - return nil, err - } - entry.SignatureV2 = sig2 - - return entry, nil -} - -func createCborDataForIpnsEntry(e *pb.IpnsEntry) ([]byte, error) { - m := make(map[string]ipld.Node) - var keys []string - m[value] = basicnode.NewBytes(e.GetValue()) - keys = append(keys, value) - - m[validity] = basicnode.NewBytes(e.GetValidity()) - keys = append(keys, validity) - - m[validityType] = basicnode.NewInt(int64(e.GetValidityType())) - keys = append(keys, validityType) - - m[sequence] = basicnode.NewInt(int64(e.GetSequence())) - keys = append(keys, sequence) - - m[ttl] = basicnode.NewInt(int64(e.GetTtl())) - keys = append(keys, ttl) - - sort.Sort(cborMapKeyString_RFC7049(keys)) - - newNd := basicnode.Prototype__Map{}.NewBuilder() - ma, err := newNd.BeginMap(int64(len(keys))) - if err != nil { - return nil, err - } - - for _, k := range keys { - if err := ma.AssembleKey().AssignString(k); err != nil { - return nil, err - } - if err := ma.AssembleValue().AssignNode(m[k]); err != nil { - return nil, err - } - } - - if err := ma.Finish(); err != nil { - return nil, err - } - - nd := newNd.Build() - - enc, err := ipldcodec.LookupEncoder(uint64(multicodec.DagCbor)) - if err != nil { - return nil, err - } - - buf := new(bytes.Buffer) - if err := enc(nd, buf); err != nil { - return nil, err - } - - return buf.Bytes(), nil -} - -// ValidateWithPeerID validates the given IPNS entry against the given peer ID. -func ValidateWithPeerID(pid peer.ID, entry *pb.IpnsEntry) error { - pk, err := ExtractPublicKey(pid, entry) - if err != nil { - return err - } - - return Validate(pk, entry) -} - -// Validates validates the given IPNS entry against the given public key. -func Validate(pk ic.PubKey, entry *pb.IpnsEntry) error { - // Make sure max size is respected - if entry.Size() > MaxRecordSize { - return ErrRecordSize - } - - // Check the ipns record signature with the public key - if entry.GetSignatureV2() == nil { - // always error if no valid signature could be found - return ErrSignature - } - - sig2Data, err := ipnsEntryDataForSigV2(entry) - if err != nil { - return fmt.Errorf("could not compute signature data: %w", err) - } - if ok, err := pk.Verify(sig2Data, entry.GetSignatureV2()); err != nil || !ok { - return ErrSignature - } - - // TODO: If we switch from pb.IpnsEntry to a more generic IpnsRecord type then perhaps we should only check - // this if there is no v1 signature. In the meanwhile this helps avoid some potential rough edges around people - // checking the entry fields instead of doing CBOR decoding everywhere. - // See https://github.com/ipfs/boxo/ipns/pull/42 for next steps here - if err := validateCborDataMatchesPbData(entry); err != nil { - return err - } - - eol, err := GetEOL(entry) - if err != nil { - return err - } - if time.Now().After(eol) { - return ErrExpiredRecord - } - return nil -} - -// TODO: Most of this function could probably be replaced with codegen -func validateCborDataMatchesPbData(entry *pb.IpnsEntry) error { - if len(entry.GetData()) == 0 { - return fmt.Errorf("record data is missing") - } - - dec, err := ipldcodec.LookupDecoder(uint64(multicodec.DagCbor)) - if err != nil { - return err - } - - ndbuilder := basicnode.Prototype__Map{}.NewBuilder() - if err := dec(ndbuilder, bytes.NewReader(entry.GetData())); err != nil { - return err - } - - fullNd := ndbuilder.Build() - nd, err := fullNd.LookupByString(value) - if err != nil { - return err - } - ndBytes, err := nd.AsBytes() - if err != nil { - return err - } - if !bytes.Equal(entry.GetValue(), ndBytes) { - return fmt.Errorf("field \"%v\" did not match between protobuf and CBOR", value) - } - - nd, err = fullNd.LookupByString(validity) - if err != nil { - return err - } - ndBytes, err = nd.AsBytes() - if err != nil { - return err - } - if !bytes.Equal(entry.GetValidity(), ndBytes) { - return fmt.Errorf("field \"%v\" did not match between protobuf and CBOR", validity) - } - - nd, err = fullNd.LookupByString(validityType) - if err != nil { - return err - } - ndInt, err := nd.AsInt() - if err != nil { - return err - } - if int64(entry.GetValidityType()) != ndInt { - return fmt.Errorf("field \"%v\" did not match between protobuf and CBOR", validityType) - } - - nd, err = fullNd.LookupByString(sequence) - if err != nil { - return err - } - ndInt, err = nd.AsInt() - if err != nil { - return err - } - - if entry.GetSequence() != uint64(ndInt) { - return fmt.Errorf("field \"%v\" did not match between protobuf and CBOR", sequence) - } - - nd, err = fullNd.LookupByString("TTL") - if err != nil { - return err - } - ndInt, err = nd.AsInt() - if err != nil { - return err - } - if entry.GetTtl() != uint64(ndInt) { - return fmt.Errorf("field \"%v\" did not match between protobuf and CBOR", ttl) - } - - return nil -} - -// GetEOL returns the EOL of this IPNS entry -// -// This function returns ErrUnrecognizedValidity if the validity type of the -// record isn't EOL. Otherwise, it returns an error if it can't parse the EOL. -func GetEOL(entry *pb.IpnsEntry) (time.Time, error) { - if entry.GetValidityType() != pb.IpnsEntry_EOL { - return time.Time{}, ErrUnrecognizedValidity - } - return u.ParseRFC3339(string(entry.GetValidity())) -} - -// EmbedPublicKey embeds the given public key in the given ipns entry. While not -// strictly required, some nodes (e.g., DHT servers) may reject IPNS entries -// that don't embed their public keys as they may not be able to validate them -// efficiently. -func EmbedPublicKey(pk ic.PubKey, entry *pb.IpnsEntry) error { - // Try extracting the public key from the ID. If we can, *don't* embed - // it. - id, err := peer.IDFromPublicKey(pk) - if err != nil { - return err - } - if _, err := id.ExtractPublicKey(); err != peer.ErrNoPublicKey { - // Either a *real* error or nil. - return err - } - - // We failed to extract the public key from the peer ID, embed it in the - // record. - pkBytes, err := ic.MarshalPublicKey(pk) - if err != nil { - return err - } - entry.PubKey = pkBytes - return nil -} - -// UnmarshalIpnsEntry unmarshalls an IPNS entry from a slice of bytes. -func UnmarshalIpnsEntry(data []byte) (*pb.IpnsEntry, error) { - var entry pb.IpnsEntry - err := proto.Unmarshal(data, &entry) - if err != nil { - return nil, err - } - - return &entry, nil -} - -// ExtractPublicKey extracts a public key matching `pid` from the IPNS record, -// if possible. -// -// This function returns (nil, nil) when no public key can be extracted and -// nothing is malformed. -func ExtractPublicKey(pid peer.ID, entry *pb.IpnsEntry) (ic.PubKey, error) { - if entry.PubKey != nil { - pk, err := ic.UnmarshalPublicKey(entry.PubKey) - if err != nil { - return nil, fmt.Errorf("unmarshaling pubkey in record: %s", err) - } - - expPid, err := peer.IDFromPublicKey(pk) - if err != nil { - return nil, fmt.Errorf("could not regenerate peerID from pubkey: %s", err) - } - - if pid != expPid { - return nil, ErrPublicKeyMismatch - } - return pk, nil - } - - return pid.ExtractPublicKey() -} - -// Compare compares two IPNS entries. It returns: -// -// * -1 if a is older than b -// * 0 if a and b cannot be ordered (this doesn't mean that they are equal) -// * +1 if a is newer than b -// -// It returns an error when either a or b are malformed. -// -// NOTE: It *does not* validate the records, the caller is responsible for calling -// `Validate` first. -// -// NOTE: If a and b cannot be ordered by this function, you can determine their -// order by comparing their serialized byte representations (using -// `bytes.Compare`). You must do this if you are implementing a libp2p record -// validator (or you can just use the one provided for you by this package). -func Compare(a, b *pb.IpnsEntry) (int, error) { - aHasV2Sig := a.GetSignatureV2() != nil - bHasV2Sig := b.GetSignatureV2() != nil - - // Having a newer signature version is better than an older signature version - if aHasV2Sig && !bHasV2Sig { - return 1, nil - } else if !aHasV2Sig && bHasV2Sig { - return -1, nil - } - - as := a.GetSequence() - bs := b.GetSequence() - - if as > bs { - return 1, nil - } else if as < bs { - return -1, nil - } - - at, err := u.ParseRFC3339(string(a.GetValidity())) - if err != nil { - return 0, err - } - - bt, err := u.ParseRFC3339(string(b.GetValidity())) - if err != nil { - return 0, err - } - - if at.After(bt) { - return 1, nil - } else if bt.After(at) { - return -1, nil - } - - return 0, nil -} - -func ipnsEntryDataForSigV1(e *pb.IpnsEntry) []byte { - return bytes.Join([][]byte{ - e.Value, - e.Validity, - []byte(fmt.Sprint(e.GetValidityType())), - }, - []byte{}) -} - -func ipnsEntryDataForSigV2(e *pb.IpnsEntry) ([]byte, error) { - dataForSig := []byte("ipns-signature:") - dataForSig = append(dataForSig, e.Data...) - - return dataForSig, nil -} - -type cborMapKeyString_RFC7049 []string - -func (x cborMapKeyString_RFC7049) Len() int { return len(x) } -func (x cborMapKeyString_RFC7049) Swap(i, j int) { x[i], x[j] = x[j], x[i] } -func (x cborMapKeyString_RFC7049) Less(i, j int) bool { - li, lj := len(x[i]), len(x[j]) - if li == lj { - return x[i] < x[j] - } - return li < lj -} - -var _ sort.Interface = (cborMapKeyString_RFC7049)(nil) diff --git a/ipns/ipns_test.go b/ipns/ipns_test.go deleted file mode 100644 index e6d521ce4..000000000 --- a/ipns/ipns_test.go +++ /dev/null @@ -1,63 +0,0 @@ -package ipns - -import ( - "fmt" - "testing" - "time" - - u "github.com/ipfs/boxo/util" - ci "github.com/libp2p/go-libp2p/core/crypto" - "github.com/libp2p/go-libp2p/core/peer" -) - -func TestEmbedPublicKey(t *testing.T) { - - sr := u.NewTimeSeededRand() - priv, pub, err := ci.GenerateKeyPairWithReader(ci.RSA, 2048, sr) - if err != nil { - t.Fatal(err) - } - - pid, err := peer.IDFromPublicKey(pub) - if err != nil { - t.Fatal(err) - } - - e, err := Create(priv, []byte("/a/b"), 0, time.Now().Add(1*time.Hour), 0) - if err != nil { - t.Fatal(err) - } - if err := EmbedPublicKey(pub, e); err != nil { - t.Fatal(err) - } - embeddedPk, err := ci.UnmarshalPublicKey(e.PubKey) - if err != nil { - t.Fatal(err) - } - embeddedPid, err := peer.IDFromPublicKey(embeddedPk) - if err != nil { - t.Fatal(err) - } - if embeddedPid != pid { - t.Fatalf("pid mismatch: %s != %s", pid, embeddedPid) - } -} - -func ExampleCreate() { - // Generate a private key to sign the IPNS record with. Most of the time, - // however, you'll want to retrieve an already-existing key from IPFS using - // go-ipfs/core/coreapi CoreAPI.KeyAPI() interface. - privateKey, _, err := ci.GenerateKeyPair(ci.RSA, 2048) - if err != nil { - panic(err) - } - - // Create an IPNS record that expires in one hour and points to the IPFS address - // /ipfs/Qme1knMqwt1hKZbc1BmQFmnm9f36nyQGwXxPGVpVJ9rMK5 - ipnsRecord, err := Create(privateKey, []byte("/ipfs/Qme1knMqwt1hKZbc1BmQFmnm9f36nyQGwXxPGVpVJ9rMK5"), 0, time.Now().Add(1*time.Hour), 0) - if err != nil { - panic(err) - } - - fmt.Println(ipnsRecord) -} diff --git a/ipns/record.go b/ipns/record.go index b479dab90..e9595bd02 100644 --- a/ipns/record.go +++ b/ipns/record.go @@ -3,124 +3,466 @@ package ipns import ( "bytes" "errors" - - pb "github.com/ipfs/boxo/ipns/pb" + "fmt" + "sort" + "time" "github.com/gogo/protobuf/proto" + ipns_pb "github.com/ipfs/boxo/ipns/pb" + "github.com/ipfs/boxo/path" + "github.com/ipfs/boxo/util" + "github.com/ipfs/go-cid" logging "github.com/ipfs/go-log/v2" - record "github.com/libp2p/go-libp2p-record" + "github.com/ipld/go-ipld-prime" + "github.com/ipld/go-ipld-prime/codec/dagcbor" + "github.com/ipld/go-ipld-prime/datamodel" + basicnode "github.com/ipld/go-ipld-prime/node/basic" ic "github.com/libp2p/go-libp2p/core/crypto" "github.com/libp2p/go-libp2p/core/peer" - pstore "github.com/libp2p/go-libp2p/core/peerstore" + "github.com/multiformats/go-multibase" + "github.com/multiformats/go-multihash" + "go.uber.org/multierr" ) var log = logging.Logger("ipns") -var _ record.Validator = Validator{} +type ValidityType int64 -// RecordKey returns the libp2p record key for a given peer ID. -func RecordKey(pid peer.ID) string { - return "/ipns/" + string(pid) -} +// ValidityEOL means "this record is valid until {Validity}". This is currently +// the only supported Validity type. +const ValidityEOL ValidityType = 0 -// Validator is an IPNS record validator that satisfies the libp2p record -// validator interface. -type Validator struct { - // KeyBook, if non-nil, will be used to lookup keys for validating IPNS - // records. - KeyBook pstore.KeyBook +// Record represents an [IPNS Record]. +// +// [IPNS Record]: https://specs.ipfs.tech/ipns/ipns-record/ +type Record struct { + pb ipns_pb.IpnsEntry + node datamodel.Node } -// Validate validates an IPNS record. -func (v Validator) Validate(key string, value []byte) error { - ns, pidString, err := record.SplitKey(key) - if err != nil || ns != "ipns" { - return ErrInvalidPath +// UnmarshalRecord parses the [Protobuf-serialized] IPNS Record into a usable +// [Record] struct. Please note that this function does not perform a full +// validation of the record. For that use [Validate]. +// +// [Protobuf-serialized]: https://specs.ipfs.tech/ipns/ipns-record/#record-serialization-format +func UnmarshalRecord(data []byte) (*Record, error) { + if len(data) > MaxRecordSize { + return nil, ErrRecordSize } - // Parse the value into an IpnsEntry - entry := new(pb.IpnsEntry) - err = proto.Unmarshal(value, entry) + var pb ipns_pb.IpnsEntry + err := proto.Unmarshal(data, &pb) if err != nil { - return ErrBadRecord + return nil, multierr.Combine(ErrBadRecord, err) + } + + record := &Record{ + pb: pb, + } + + // Ensure the record has DAG-CBOR data because we need it. + if len(pb.GetData()) == 0 { + return nil, multierr.Combine(ErrBadRecord, ErrDataMissing) + } + + // Decode CBOR data. + builder := basicnode.Prototype__Map{}.NewBuilder() + if err := dagcbor.Decode(builder, bytes.NewReader(pb.GetData())); err != nil { + return nil, multierr.Combine(ErrBadRecord, err) } + record.node = builder.Build() - // Get the public key defined by the ipns path - pid, err := peer.IDFromBytes([]byte(pidString)) + return record, nil +} + +// MarshalRecord encodes the given IPNS Record into its [Protobuf serialization format]. +// +// [Protobuf serialization format]: https://specs.ipfs.tech/ipns/ipns-record/#record-serialization-format +func MarshalRecord(r *Record) ([]byte, error) { + return proto.Marshal(&r.pb) +} + +// Value returns the [path.Path] that is embedded in this IPNS Record. If the +// path is invalid, an [ErrInvalidPath] is returned. +func (r *Record) Value() (path.Path, error) { + value, err := r.getBytesValue(cborValueKey) if err != nil { - log.Debugf("failed to parse ipns record key %s into peer ID", pidString) - return ErrKeyFormat + return "", err } - pubk, err := v.getPublicKey(pid, entry) + // Maybe it's an old style record. + if mh, err := multihash.Cast(value); err == nil { + return path.FromCid(cid.NewCidV0(mh)), nil + } + + // Otherwise, parse as path. + p := path.FromString(string(value)) + if err := p.IsValid(); err != nil { + return "", multierr.Combine(ErrInvalidPath, err) + } + return p, nil +} + +func (r *Record) ValidityType() (ValidityType, error) { + value, err := r.getIntValue(cborValidityTypeKey) if err != nil { - return err + return -1, err } - return Validate(pubk, entry) + return ValidityType(value), nil } -func (v Validator) getPublicKey(pid peer.ID, entry *pb.IpnsEntry) (ic.PubKey, error) { - switch pk, err := ExtractPublicKey(pid, entry); err { - case peer.ErrNoPublicKey: - case nil: - return pk, nil +// Validity returns the validity of the IPNS Record. This function returns +// [ErrUnrecognizedValidity] if the validity type of the record isn't EOL. +// Otherwise, it returns an error if it can't parse the EOL. +func (r *Record) Validity() (time.Time, error) { + validityType, err := r.ValidityType() + if err != nil { + return time.Time{}, err + } + + switch validityType { + case ValidityEOL: + value, err := r.getBytesValue(cborValidityKey) + if err != nil { + return time.Time{}, err + } + + v, err := util.ParseRFC3339(string(value)) + if err != nil { + return time.Time{}, multierr.Combine(ErrInvalidValidity, err) + } + return v, nil default: + return time.Time{}, ErrUnrecognizedValidity + } +} + +func (r *Record) Sequence() (uint64, error) { + value, err := r.getIntValue(cborSequenceKey) + if err != nil { + return 0, err + } + + return uint64(value), nil +} + +func (r *Record) TTL() (time.Duration, error) { + value, err := r.getIntValue(cborTTLKey) + if err != nil { + return 0, err + } + + return time.Duration(value), nil +} + +func (r *Record) PubKey() (ic.PubKey, error) { + if pk := r.pb.GetPubKey(); len(pk) != 0 { + return ic.UnmarshalPublicKey(pk) + } + + return nil, ErrPublicKeyNotFound +} + +func (r *Record) getBytesValue(key string) ([]byte, error) { + node, err := r.node.LookupByString(key) + if err != nil { + return nil, multierr.Combine(ErrBadRecord, err) + } + + value, err := node.AsBytes() + if err != nil { + return nil, multierr.Combine(ErrBadRecord, err) + } + + return value, nil +} + +func (r *Record) getIntValue(key string) (int64, error) { + node, err := r.node.LookupByString(key) + if err != nil { + return -1, multierr.Combine(ErrBadRecord, err) + } + + value, err := node.AsInt() + if err != nil { + return -1, multierr.Combine(ErrBadRecord, err) + } + + return value, nil +} + +const ( + cborValidityKey = "Validity" + cborValidityTypeKey = "ValidityType" + cborValueKey = "Value" + cborSequenceKey = "Sequence" + cborTTLKey = "TTL" +) + +type options struct { + compatibleWithV1 bool +} + +type Option func(*options) + +func CompatibleWithV1(compatible bool) Option { + return func(opts *options) { + opts.compatibleWithV1 = compatible + } +} + +func processOptions(opts ...Option) *options { + options := &options{} + for _, opt := range opts { + opt(options) + } + return options +} + +// NewRecord creates a new IPNS [Record] and signs it with the given private key. +// This function does not embed the public key. To do so, call [EmbedPublicKey]. +func NewRecord(sk ic.PrivKey, value path.Path, seq uint64, eol time.Time, ttl time.Duration, opts ...Option) (*Record, error) { + options := processOptions(opts...) + + node, err := createNode(value, seq, eol, ttl) + if err != nil { return nil, err } - if v.KeyBook == nil { - log.Debugf("public key with hash %s not found in IPNS record and no peer store provided", pid) - return nil, ErrPublicKeyNotFound + cborData, err := nodeToCBOR(node) + if err != nil { + return nil, err } - pubk := v.KeyBook.PubKey(pid) - if pubk == nil { - log.Debugf("public key with hash %s not found in peer store", pid) - return nil, ErrPublicKeyNotFound + sig2Data, err := recordDataForSignatureV2(cborData) + if err != nil { + return nil, err } - return pubk, nil + + sig2, err := sk.Sign(sig2Data) + if err != nil { + return nil, err + } + + pb := ipns_pb.IpnsEntry{ + Data: cborData, + SignatureV2: sig2, + } + + if options.compatibleWithV1 { + pb.Value = []byte(value) + typ := ipns_pb.IpnsEntry_EOL + pb.ValidityType = &typ + pb.Sequence = &seq + pb.Validity = []byte(util.FormatRFC3339(eol)) + ttlNs := uint64(ttl.Nanoseconds()) + pb.Ttl = proto.Uint64(ttlNs) + + // For now we still create V1 signatures. These are deprecated, and not + // used during verification anymore (Validate func requires SignatureV2), + // but setting it here allows legacy nodes (e.g., go-ipfs < v0.9.0) to + // still resolve IPNS published by modern nodes. + sig1, err := sk.Sign(recordDataForSignatureV1(&pb)) + if err != nil { + return nil, fmt.Errorf("%w: could not compute signature data", err) + } + pb.SignatureV1 = sig1 + } + + return &Record{ + pb: pb, + node: node, + }, nil } -// Select selects the best record by checking which has the highest sequence -// number and latest EOL. -// -// This function returns an error if any of the records fail to parse. Validate -// your records first! -func (v Validator) Select(k string, vals [][]byte) (int, error) { - var recs []*pb.IpnsEntry - for _, v := range vals { - e := new(pb.IpnsEntry) - if err := proto.Unmarshal(v, e); err != nil { - return -1, err +func createNode(value path.Path, seq uint64, eol time.Time, ttl time.Duration) (datamodel.Node, error) { + m := make(map[string]ipld.Node) + var keys []string + + m[cborValueKey] = basicnode.NewBytes([]byte(value)) + keys = append(keys, cborValueKey) + + m[cborValidityKey] = basicnode.NewBytes([]byte(util.FormatRFC3339(eol))) + keys = append(keys, cborValidityKey) + + m[cborValidityTypeKey] = basicnode.NewInt(int64(ValidityEOL)) + keys = append(keys, cborValidityTypeKey) + + m[cborSequenceKey] = basicnode.NewInt(int64(seq)) + keys = append(keys, cborSequenceKey) + + m[cborTTLKey] = basicnode.NewInt(int64(ttl)) + keys = append(keys, cborTTLKey) + + sort.Slice(keys, func(i, j int) bool { + li, lj := len(keys[i]), len(keys[j]) + if li == lj { + return keys[i] < keys[j] } - recs = append(recs, e) + return li < lj + }) + + newNd := basicnode.Prototype__Map{}.NewBuilder() + ma, err := newNd.BeginMap(int64(len(keys))) + if err != nil { + return nil, err } - return selectRecord(recs, vals) + for _, k := range keys { + if err := ma.AssembleKey().AssignString(k); err != nil { + return nil, err + } + if err := ma.AssembleValue().AssignNode(m[k]); err != nil { + return nil, err + } + } + + if err := ma.Finish(); err != nil { + return nil, err + } + + return newNd.Build(), nil +} + +func nodeToCBOR(node datamodel.Node) ([]byte, error) { + buf := new(bytes.Buffer) + if err := dagcbor.Encode(node, buf); err != nil { + return nil, err + } + + return buf.Bytes(), nil +} + +func recordDataForSignatureV1(e *ipns_pb.IpnsEntry) []byte { + return bytes.Join([][]byte{ + e.Value, + e.Validity, + []byte(fmt.Sprint(e.GetValidityType())), + }, + []byte{}) +} + +func recordDataForSignatureV2(data []byte) ([]byte, error) { + dataForSig := []byte("ipns-signature:") + dataForSig = append(dataForSig, data...) + return dataForSig, nil } -func selectRecord(recs []*pb.IpnsEntry, vals [][]byte) (int, error) { - switch len(recs) { - case 0: - return -1, errors.New("no usable records in given set") - case 1: - return 0, nil +// compare compares two IPNS Records. It returns: +// +// - -1 if a is older than b +// - 0 if a and b cannot be ordered (this doesn't mean that they are equal) +// - +1 if a is newer than b +// +// This function does not validate the records. The caller is responsible for +// ensuring that the Records are valid by using [Validate]. +func compare(a, b *Record) (int, error) { + aHasV2Sig := a.pb.GetSignatureV2() != nil + bHasV2Sig := b.pb.GetSignatureV2() != nil + + // Having a newer signature version is better than an older signature version + if aHasV2Sig && !bHasV2Sig { + return 1, nil + } else if !aHasV2Sig && bHasV2Sig { + return -1, nil } - var i int - for j := 1; j < len(recs); j++ { - cmp, err := Compare(recs[i], recs[j]) + as, err := a.Sequence() + if err != nil { + return 0, err + } + + bs, err := b.Sequence() + if err != nil { + return 0, err + } + + if as > bs { + return 1, nil + } else if as < bs { + return -1, nil + } + + at, err := a.Validity() + if err != nil { + return 0, err + } + + bt, err := b.Validity() + if err != nil { + return 0, err + } + + if at.After(bt) { + return 1, nil + } else if bt.After(at) { + return -1, nil + } + + return 0, nil +} + +// EmbedPublicKey embeds the given public key in the given [Record]. While not +// strictly required, some notes (e.g., DHT servers), may reject IPNS Records +// that do not embed their public keys as they may not be able to validate them +// efficiently. +func EmbedPublicKey(r *Record, pk ic.PubKey) error { + // Try extracting the public key from the ID. If we can, do not embed it. + pid, err := peer.IDFromPublicKey(pk) + if err != nil { + return err + } + if _, err := pid.ExtractPublicKey(); err != peer.ErrNoPublicKey { + // Either a *real* error or nil. + return err + } + + // We failed to extract the public key from the peer ID, embed it. + pkBytes, err := ic.MarshalPublicKey(pk) + if err != nil { + return err + } + + r.pb.PubKey = pkBytes + return nil +} + +// ExtractPublicKey extracts a [crypto.PubKey] matching the given [peer.ID] from +// the IPNS Record, if possible. +func ExtractPublicKey(r *Record, pid peer.ID) (ic.PubKey, error) { + if pk, err := r.PubKey(); err == nil { + expPid, err := peer.IDFromPublicKey(pk) if err != nil { - return -1, err - } - if cmp == 0 { - cmp = bytes.Compare(vals[i], vals[j]) + return nil, multierr.Combine(ErrInvalidPublicKey, err) } - if cmp < 0 { - i = j + + if pid != expPid { + return nil, ErrPublicKeyMismatch } + + return pk, nil + } else if !errors.Is(err, ErrPublicKeyNotFound) { + return nil, multierr.Combine(ErrInvalidPublicKey, err) + } else { + return pid.ExtractPublicKey() + } +} + +// RoutingKey returns the IPNS routing key for the given [peer.ID]. Note +// that the intended use of this function is for routing only. The output of this +// function is not human readable. For a human readable version, see [Key]. +func RoutingKey(pid peer.ID) string { + return "/ipns/" + string(pid) +} + +// Key returns the human-readable IPNS key for the given [peer.ID]. +func Key(pid peer.ID) string { + cid := peer.ToCid(pid) + encoded, err := cid.StringOfBase(multibase.Base36) + if err != nil { + panic("cid.StringOfBase was called with wrong parameters") } - return i, nil + return "/ipns/" + encoded } diff --git a/ipns/record_test.go b/ipns/record_test.go new file mode 100644 index 000000000..0eced4595 --- /dev/null +++ b/ipns/record_test.go @@ -0,0 +1,250 @@ +package ipns + +import ( + "bytes" + "testing" + "time" + + ipns_pb "github.com/ipfs/boxo/ipns/pb" + "github.com/ipfs/boxo/path" + "github.com/ipfs/boxo/util" + "github.com/ipld/go-ipld-prime/codec/dagcbor" + basicnode "github.com/ipld/go-ipld-prime/node/basic" + ic "github.com/libp2p/go-libp2p/core/crypto" + "github.com/libp2p/go-libp2p/core/peer" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + testPath = path.Path("/ipfs/bafkqac3jobxhgidsn5rww4yk") +) + +func mustKeyPair(t *testing.T, typ int) (ic.PrivKey, ic.PubKey, peer.ID) { + sr := util.NewTimeSeededRand() + sk, pk, err := ic.GenerateKeyPairWithReader(typ, 2048, sr) + require.NoError(t, err) + + pid, err := peer.IDFromPublicKey(pk) + require.NoError(t, err) + + return sk, pk, pid +} + +func mustNewRecord(t *testing.T, sk ic.PrivKey, value path.Path, seq uint64, eol time.Time, ttl time.Duration, opts ...Option) *Record { + rec, err := NewRecord(sk, value, seq, eol, ttl, opts...) + require.NoError(t, err) + require.NoError(t, Validate(rec, sk.GetPublic())) + return rec +} + +func mustMarshal(t *testing.T, entry *Record) []byte { + data, err := MarshalRecord(entry) + require.NoError(t, err) + return data +} + +func fieldsMatch(t *testing.T, rec *Record, value path.Path, seq uint64, eol time.Time, ttl time.Duration) { + recPath, err := rec.Value() + require.NoError(t, err) + require.Equal(t, value.String(), recPath.String()) + + recSeq, err := rec.Sequence() + require.NoError(t, err) + require.Equal(t, seq, recSeq) + + recValidityType, err := rec.ValidityType() + require.NoError(t, err) + require.Equal(t, recValidityType, ValidityEOL) + + recEOL, err := rec.Validity() + require.NoError(t, err) + require.True(t, recEOL.Equal(eol)) + + recTTL, err := rec.TTL() + require.NoError(t, err) + require.Equal(t, ttl, recTTL) +} + +func fieldsMatchV1(t *testing.T, rec *Record, value path.Path, seq uint64, eol time.Time, ttl time.Duration) { + require.Equal(t, value.String(), string(rec.pb.GetValue())) + require.Equal(t, seq, rec.pb.GetSequence()) + require.Equal(t, rec.pb.GetValidityType(), ipns_pb.IpnsEntry_EOL) + require.Equal(t, time.Duration(rec.pb.GetTtl()), ttl) + + recEOL, err := util.ParseRFC3339(string(rec.pb.GetValidity())) + require.NoError(t, err) + require.NoError(t, err) + require.True(t, recEOL.Equal(eol)) +} + +func TestNewRecord(t *testing.T) { + t.Parallel() + + sk, _, _ := mustKeyPair(t, ic.Ed25519) + + seq := uint64(0) + eol := time.Now().Add(time.Hour) + ttl := time.Minute * 10 + + t.Run("V2 only record by default", func(t *testing.T) { + t.Parallel() + + rec := mustNewRecord(t, sk, testPath, seq, eol, ttl) + require.Empty(t, rec.pb.SignatureV1) + + _, err := rec.PubKey() + require.ErrorIs(t, err, ErrPublicKeyNotFound) + + fieldsMatch(t, rec, testPath, seq, eol, ttl) + require.Empty(t, rec.pb.GetValue()) + require.Empty(t, rec.pb.GetSequence()) + require.Empty(t, rec.pb.GetValidity()) + require.Empty(t, rec.pb.GetValidityType()) + require.Empty(t, rec.pb.GetTtl()) + }) + + t.Run("V1+V2 with option", func(t *testing.T) { + t.Parallel() + + rec := mustNewRecord(t, sk, testPath, seq, eol, ttl, CompatibleWithV1(true)) + require.NotEmpty(t, rec.pb.SignatureV1) + + _, err := rec.PubKey() + require.ErrorIs(t, err, ErrPublicKeyNotFound) + + fieldsMatch(t, rec, testPath, seq, eol, ttl) + fieldsMatchV1(t, rec, testPath, seq, eol, ttl) + }) +} + +func TestEmbedPublicKey(t *testing.T) { + t.Parallel() + + sk, pk, pid := mustKeyPair(t, ic.RSA) + + seq := uint64(0) + eol := time.Now().Add(time.Hour) + ttl := time.Minute * 10 + + rec := mustNewRecord(t, sk, testPath, seq, eol, ttl) + + _, err := rec.PubKey() + require.ErrorIs(t, err, ErrPublicKeyNotFound) + + err = EmbedPublicKey(rec, pk) + require.NoError(t, err) + + recPK, err := rec.PubKey() + require.NoError(t, err) + + recPID, err := peer.IDFromPublicKey(recPK) + require.NoError(t, err) + + require.Equal(t, pid, recPID) +} + +func TestExtractPublicKey(t *testing.T) { + t.Parallel() + + t.Run("Returns expected public key when embedded in Peer ID", func(t *testing.T) { + sk, pk, pid := mustKeyPair(t, ic.Ed25519) + rec := mustNewRecord(t, sk, testPath, 0, time.Now().Add(time.Hour), time.Minute*10) + + pk2, err := ExtractPublicKey(rec, pid) + require.Nil(t, err) + require.Equal(t, pk, pk2) + }) + + t.Run("Returns expected public key when embedded in Record", func(t *testing.T) { + sk, pk, pid := mustKeyPair(t, ic.RSA) + rec := mustNewRecord(t, sk, testPath, 0, time.Now().Add(time.Hour), time.Minute*10) + err := EmbedPublicKey(rec, pk) + require.Nil(t, err) + + pk2, err := ExtractPublicKey(rec, pid) + require.Nil(t, err) + require.True(t, pk.Equals(pk2)) + }) + + t.Run("Errors when not embedded in Record or Peer ID", func(t *testing.T) { + sk, _, pid := mustKeyPair(t, ic.RSA) + rec := mustNewRecord(t, sk, testPath, 0, time.Now().Add(time.Hour), time.Minute*10) + + pk, err := ExtractPublicKey(rec, pid) + require.Error(t, err) + require.Nil(t, pk) + }) + + t.Run("Errors on invalid public key bytes", func(t *testing.T) { + sk, _, pid := mustKeyPair(t, ic.Ed25519) + rec := mustNewRecord(t, sk, testPath, 0, time.Now().Add(time.Hour), time.Minute*10) + + // Force bad pub key information. + rec.pb.PubKey = []byte("invalid stuff") + + pk, err := ExtractPublicKey(rec, pid) + require.ErrorIs(t, err, ErrInvalidPublicKey) + require.Nil(t, pk) + }) +} + +func TestCBORDataSerialization(t *testing.T) { + t.Parallel() + + sk, _, _ := mustKeyPair(t, ic.Ed25519) + + eol := time.Now().Add(time.Hour) + path := path.FromString(string(append([]byte("/path/1"), 0x00))) + seq := uint64(1) + ttl := time.Hour + + rec := mustNewRecord(t, sk, path, seq, eol, ttl) + + builder := basicnode.Prototype__Map{}.NewBuilder() + err := dagcbor.Decode(builder, bytes.NewReader(rec.pb.GetData())) + require.NoError(t, err) + + node := builder.Build() + iter := node.MapIterator() + var fields []string + for !iter.Done() { + k, v, err := iter.Next() + require.NoError(t, err) + kStr, err := k.AsString() + require.NoError(t, err) + + switch kStr { + case cborValueKey: + b, err := v.AsBytes() + require.NoError(t, err) + require.Equal(t, b, []byte(path)) + case cborSequenceKey: + s, err := v.AsInt() + require.NoError(t, err) + require.Equal(t, seq, uint64(s)) + case cborValidityKey: + val, err := v.AsBytes() + require.NoError(t, err) + require.Equal(t, []byte(util.FormatRFC3339(eol)), val) + case cborValidityTypeKey: + vt, err := v.AsInt() + require.NoError(t, err) + require.Equal(t, uint64(0), uint64(vt)) + case cborTTLKey: + ttlVal, err := v.AsInt() + require.NoError(t, err) + require.Equal(t, ttl, time.Duration(ttlVal)) + } + + fields = append(fields, kStr) + } + + // Ensure correct key order, i.e., by length then value. + expectedOrder := []string{"TTL", "Value", "Sequence", "Validity", "ValidityType"} + require.Len(t, fields, len(expectedOrder)) + for i, f := range fields { + expected := expectedOrder[i] + assert.Equal(t, expected, f) + } +} diff --git a/ipns/select_test.go b/ipns/select_test.go deleted file mode 100644 index 5b435f62d..000000000 --- a/ipns/select_test.go +++ /dev/null @@ -1,126 +0,0 @@ -package ipns - -import ( - "fmt" - "math/rand" - "testing" - "time" - - pb "github.com/ipfs/boxo/ipns/pb" - - "github.com/gogo/protobuf/proto" - u "github.com/ipfs/boxo/util" - ci "github.com/libp2p/go-libp2p/core/crypto" -) - -func shuffle(a []*pb.IpnsEntry) { - for n := 0; n < 5; n++ { - for i := range a { - j := rand.Intn(len(a)) - a[i], a[j] = a[j], a[i] - } - } -} - -func AssertSelected(r *pb.IpnsEntry, from ...*pb.IpnsEntry) error { - shuffle(from) - var vals [][]byte - for _, r := range from { - data, err := proto.Marshal(r) - if err != nil { - return err - } - vals = append(vals, data) - } - - i, err := selectRecord(from, vals) - if err != nil { - return err - } - - if from[i] != r { - return fmt.Errorf("selected incorrect record %d", i) - } - - return nil -} - -func TestOrdering(t *testing.T) { - // select timestamp so selection is deterministic - ts := time.Unix(1000000, 0) - - // generate a key for signing the records - r := u.NewSeededRand(15) // generate deterministic keypair - priv, _, err := ci.GenerateKeyPairWithReader(ci.RSA, 2048, r) - if err != nil { - t.Fatal(err) - } - - e1, err := Create(priv, []byte("foo"), 1, ts.Add(time.Hour), 0) - if err != nil { - t.Fatal(err) - } - - e2, err := Create(priv, []byte("bar"), 2, ts.Add(time.Hour), 0) - if err != nil { - t.Fatal(err) - } - - e3, err := Create(priv, []byte("baz"), 3, ts.Add(time.Hour), 0) - if err != nil { - t.Fatal(err) - } - - e4, err := Create(priv, []byte("cat"), 3, ts.Add(time.Hour*2), 0) - if err != nil { - t.Fatal(err) - } - - e5, err := Create(priv, []byte("dog"), 4, ts.Add(time.Hour*3), 0) - if err != nil { - t.Fatal(err) - } - - e6, err := Create(priv, []byte("fish"), 4, ts.Add(time.Hour*3), 0) - if err != nil { - t.Fatal(err) - } - - // e1 is the only record, i hope it gets this right - err = AssertSelected(e1, e1) - if err != nil { - t.Fatal(err) - } - - // e2 has the highest sequence number - err = AssertSelected(e2, e1, e2) - if err != nil { - t.Fatal(err) - } - - // e3 has the highest sequence number - err = AssertSelected(e3, e1, e2, e3) - if err != nil { - t.Fatal(err) - } - - // e4 has a higher timeout - err = AssertSelected(e4, e1, e2, e3, e4) - if err != nil { - t.Fatal(err) - } - - // e5 has the highest sequence number - err = AssertSelected(e5, e1, e2, e3, e4, e5) - if err != nil { - t.Fatal(err) - } - - // e6 should be selected as its signauture will win in the comparison - err = AssertSelected(e6, e1, e2, e3, e4, e5, e6) - if err != nil { - t.Fatal(err) - } - - _ = []interface{}{e1, e2, e3, e4, e5, e6} -} diff --git a/ipns/validate_test.go b/ipns/validate_test.go deleted file mode 100644 index 1b24ec492..000000000 --- a/ipns/validate_test.go +++ /dev/null @@ -1,449 +0,0 @@ -package ipns - -import ( - "bytes" - "errors" - "fmt" - "math/rand" - "strings" - "testing" - "time" - - "github.com/gogo/protobuf/proto" - pb "github.com/ipfs/boxo/ipns/pb" - u "github.com/ipfs/boxo/util" - ipldcodec "github.com/ipld/go-ipld-prime/multicodec" - basicnode "github.com/ipld/go-ipld-prime/node/basic" - "github.com/libp2p/go-libp2p/core/crypto" - "github.com/libp2p/go-libp2p/core/peer" - pstore "github.com/libp2p/go-libp2p/core/peerstore" - "github.com/libp2p/go-libp2p/p2p/host/peerstore/pstoremem" - "github.com/multiformats/go-multicodec" - "github.com/stretchr/testify/assert" -) - -func testValidatorCase(t *testing.T, priv crypto.PrivKey, kbook pstore.KeyBook, key string, val []byte, eol time.Time, exp error) { - t.Helper() - - match := func(t *testing.T, err error) { - t.Helper() - if err != exp { - params := fmt.Sprintf("key: %s\neol: %s\n", key, eol) - if exp == nil { - t.Fatalf("Unexpected error %s for params %s", err, params) - } else if err == nil { - t.Fatalf("Expected error %s but there was no error for params %s", exp, params) - } else { - t.Fatalf("Expected error %s but got %s for params %s", exp, err, params) - } - } - } - - testValidatorCaseMatchFunc(t, priv, kbook, key, val, eol, match) -} - -func testValidatorCaseMatchFunc(t *testing.T, priv crypto.PrivKey, kbook pstore.KeyBook, key string, val []byte, eol time.Time, matchf func(*testing.T, error)) { - t.Helper() - validator := Validator{kbook} - - data := val - if data == nil { - p := []byte("/ipfs/QmfM2r8seH2GiRaC4esTjeraXEachRt8ZsSeGaWTPLyMoG") - entry, err := Create(priv, p, 1, eol, 0) - if err != nil { - t.Fatal(err) - } - - data, err = proto.Marshal(entry) - if err != nil { - t.Fatal(err) - } - } - - matchf(t, validator.Validate(key, data)) -} - -func TestValidator(t *testing.T) { - ts := time.Now() - - priv, id, _ := genKeys(t) - priv2, id2, _ := genKeys(t) - kbook, err := pstoremem.NewPeerstore() - if err != nil { - t.Fatal(err) - } - if err := kbook.AddPubKey(id, priv.GetPublic()); err != nil { - t.Fatal(err) - } - emptyKbook, err := pstoremem.NewPeerstore() - if err != nil { - t.Fatal(err) - } - - testValidatorCase(t, priv, kbook, "/ipns/"+string(id), nil, ts.Add(time.Hour), nil) - testValidatorCase(t, priv, kbook, "/ipns/"+string(id), nil, ts.Add(time.Hour*-1), ErrExpiredRecord) - testValidatorCase(t, priv, kbook, "/ipns/"+string(id), []byte("bad data"), ts.Add(time.Hour), ErrBadRecord) - testValidatorCase(t, priv, kbook, "/ipns/"+"bad key", nil, ts.Add(time.Hour), ErrKeyFormat) - testValidatorCase(t, priv, emptyKbook, "/ipns/"+string(id), nil, ts.Add(time.Hour), ErrPublicKeyNotFound) - testValidatorCase(t, priv2, kbook, "/ipns/"+string(id2), nil, ts.Add(time.Hour), ErrPublicKeyNotFound) - testValidatorCase(t, priv2, kbook, "/ipns/"+string(id), nil, ts.Add(time.Hour), ErrSignature) - testValidatorCase(t, priv, kbook, "//"+string(id), nil, ts.Add(time.Hour), ErrInvalidPath) - testValidatorCase(t, priv, kbook, "/wrong/"+string(id), nil, ts.Add(time.Hour), ErrInvalidPath) -} - -func mustMarshal(t *testing.T, entry *pb.IpnsEntry) []byte { - t.Helper() - data, err := proto.Marshal(entry) - if err != nil { - t.Fatal(err) - } - return data -} - -func TestEmbeddedPubKeyValidate(t *testing.T) { - goodeol := time.Now().Add(time.Hour) - kbook, err := pstoremem.NewPeerstore() - if err != nil { - t.Fatal(err) - } - - pth := []byte("/ipfs/QmfM2r8seH2GiRaC4esTjeraXEachRt8ZsSeGaWTPLyMoG") - - priv, _, ipnsk := genKeys(t) - - entry, err := Create(priv, pth, 1, goodeol, 0) - if err != nil { - t.Fatal(err) - } - - testValidatorCase(t, priv, kbook, ipnsk, mustMarshal(t, entry), goodeol, ErrPublicKeyNotFound) - - pubkb, err := crypto.MarshalPublicKey(priv.GetPublic()) - if err != nil { - t.Fatal(err) - } - - entry.PubKey = pubkb - testValidatorCase(t, priv, kbook, ipnsk, mustMarshal(t, entry), goodeol, nil) - - entry.PubKey = []byte("probably not a public key") - testValidatorCaseMatchFunc(t, priv, kbook, ipnsk, mustMarshal(t, entry), goodeol, func(t *testing.T, err error) { - if !strings.Contains(err.Error(), "unmarshaling pubkey in record:") { - t.Fatal("expected pubkey unmarshaling to fail") - } - }) - - opriv, _, _ := genKeys(t) - wrongkeydata, err := crypto.MarshalPublicKey(opriv.GetPublic()) - if err != nil { - t.Fatal(err) - } - - entry.PubKey = wrongkeydata - testValidatorCase(t, priv, kbook, ipnsk, mustMarshal(t, entry), goodeol, ErrPublicKeyMismatch) -} - -func TestPeerIDPubKeyValidate(t *testing.T) { - goodeol := time.Now().Add(time.Hour) - kbook, err := pstoremem.NewPeerstore() - if err != nil { - t.Fatal(err) - } - - pth := []byte("/ipfs/QmfM2r8seH2GiRaC4esTjeraXEachRt8ZsSeGaWTPLyMoG") - - sk, pk, err := crypto.GenerateEd25519Key(rand.New(rand.NewSource(42))) - if err != nil { - t.Fatal(err) - } - - pid, err := peer.IDFromPublicKey(pk) - if err != nil { - t.Fatal(err) - } - - ipnsk := "/ipns/" + string(pid) - - entry, err := Create(sk, pth, 1, goodeol, 0) - if err != nil { - t.Fatal(err) - } - - dataNoKey, err := proto.Marshal(entry) - if err != nil { - t.Fatal(err) - } - - testValidatorCase(t, sk, kbook, ipnsk, dataNoKey, goodeol, nil) -} - -func TestOnlySignatureV2Validate(t *testing.T) { - goodeol := time.Now().Add(time.Hour) - - sk, pk, err := crypto.GenerateEd25519Key(rand.New(rand.NewSource(42))) - if err != nil { - t.Fatal(err) - } - - path1 := []byte("/path/1") - entry, err := Create(sk, path1, 1, goodeol, 0) - if err != nil { - t.Fatal(err) - } - - if err := Validate(pk, entry); err != nil { - t.Fatal(err) - } - - entry.SignatureV2 = nil - if err := Validate(pk, entry); !errors.Is(err, ErrSignature) { - t.Fatal(err) - } -} - -func TestSignatureV1Ignored(t *testing.T) { - goodeol := time.Now().Add(time.Hour) - - sk, pk, err := crypto.GenerateEd25519Key(rand.New(rand.NewSource(42))) - if err != nil { - t.Fatal(err) - } - - pid, err := peer.IDFromPublicKey(pk) - if err != nil { - t.Fatal(err) - } - - ipnsk := "/ipns/" + string(pid) - - path1 := []byte("/path/1") - entry1, err := Create(sk, path1, 1, goodeol, 0) - if err != nil { - t.Fatal(err) - } - - path2 := []byte("/path/2") - entry2, err := Create(sk, path2, 2, goodeol, 0) - if err != nil { - t.Fatal(err) - } - - if err := Validate(pk, entry1); err != nil { - t.Fatal(err) - } - - if err := Validate(pk, entry2); err != nil { - t.Fatal(err) - } - - v := Validator{} - best, err := v.Select(ipnsk, [][]byte{mustMarshal(t, entry1), mustMarshal(t, entry2)}) - if err != nil { - t.Fatal(err) - } - if best != 1 { - t.Fatal("entry2 should be better than entry1") - } - - // Having only the v1 signature should be invalid - entry2.SignatureV2 = nil - if err := Validate(pk, entry2); !errors.Is(err, ErrSignature) { - t.Fatal(err) - } - - // Record with v2 signature should always be preferred - best, err = v.Select(ipnsk, [][]byte{mustMarshal(t, entry1), mustMarshal(t, entry2)}) - if err != nil { - t.Fatal(err) - } - if best != 0 { - t.Fatal("entry1 should be better than entry2") - } - - // Having a missing v1 signature is acceptable as long as there is a valid v2 signature - entry1.SignatureV1 = nil - if err := Validate(pk, entry1); err != nil { - t.Fatal(err) - } - - // Having an invalid v1 signature is acceptable as long as there is a valid v2 signature - entry1.SignatureV1 = []byte("garbage") - if err := Validate(pk, entry1); err != nil { - t.Fatal(err) - } -} - -func TestMaxSizeValidate(t *testing.T) { - goodeol := time.Now().Add(time.Hour) - - sk, pk, err := crypto.GenerateEd25519Key(rand.New(rand.NewSource(42))) - if err != nil { - t.Fatal(err) - } - - // Create record over the max size (value+other fields) - value := make([]byte, MaxRecordSize) - entry, err := Create(sk, value, 1, goodeol, 0) - if err != nil { - t.Fatal(err) - } - // Must fail with ErrRecordSize - if err := Validate(pk, entry); !errors.Is(err, ErrRecordSize) { - t.Fatal(err) - } -} - -func TestCborDataCanonicalization(t *testing.T) { - goodeol := time.Now().Add(time.Hour) - - sk, pk, err := crypto.GenerateEd25519Key(rand.New(rand.NewSource(42))) - if err != nil { - t.Fatal(err) - } - - path := append([]byte("/path/1"), 0x00) - seqnum := uint64(1) - entry, err := Create(sk, path, seqnum, goodeol, time.Hour) - if err != nil { - t.Fatal(err) - } - - if err := Validate(pk, entry); err != nil { - t.Fatal(err) - } - - dec, err := ipldcodec.LookupDecoder(uint64(multicodec.DagCbor)) - if err != nil { - t.Fatal(err) - } - - ndbuilder := basicnode.Prototype__Map{}.NewBuilder() - if err := dec(ndbuilder, bytes.NewReader(entry.GetData())); err != nil { - t.Fatal(err) - } - - nd := ndbuilder.Build() - iter := nd.MapIterator() - var fields []string - for !iter.Done() { - k, v, err := iter.Next() - if err != nil { - t.Fatal(err) - } - kstr, err := k.AsString() - if err != nil { - t.Fatal(err) - } - - switch kstr { - case value: - b, err := v.AsBytes() - if err != nil { - t.Fatal(err) - } - if !bytes.Equal(path, b) { - t.Fatal("value did not match") - } - case sequence: - s, err := v.AsInt() - if err != nil { - t.Fatal(err) - } - if uint64(s) != seqnum { - t.Fatal("sequence numbers did not match") - } - case validity: - val, err := v.AsBytes() - if err != nil { - t.Fatal(err) - } - if !bytes.Equal(val, []byte(u.FormatRFC3339(goodeol))) { - t.Fatal("validity did not match") - } - case validityType: - vt, err := v.AsInt() - if err != nil { - t.Fatal(err) - } - if uint64(vt) != 0 { - t.Fatal("validity types did not match") - } - case ttl: - ttlVal, err := v.AsInt() - if err != nil { - t.Fatal(err) - } - // TODO: test non-zero TTL - if uint64(ttlVal) != uint64(time.Hour.Nanoseconds()) { - t.Fatal("TTLs did not match") - } - } - - fields = append(fields, kstr) - } - - // Check for map sort order (i.e. by length then by value) - expectedOrder := []string{"TTL", "Value", "Sequence", "Validity", "ValidityType"} - if len(fields) != len(expectedOrder) { - t.Fatal("wrong number of fields") - } - - for i, f := range fields { - expected := expectedOrder[i] - if f != expected { - t.Fatalf("expected %s, got %s", expected, f) - } - } -} - -func genKeys(t *testing.T) (crypto.PrivKey, peer.ID, string) { - sr := u.NewTimeSeededRand() - priv, _, err := crypto.GenerateKeyPairWithReader(crypto.RSA, 2048, sr) - if err != nil { - t.Fatal(err) - } - - // Create entry with expiry in one hour - pid, err := peer.IDFromPrivateKey(priv) - if err != nil { - t.Fatal(err) - } - ipnsKey := RecordKey(pid) - - return priv, pid, ipnsKey -} - -func TestValidateWithPeerID(t *testing.T) { - path := []byte("/ipfs/bafkreifjjcie6lypi6ny7amxnfftagclbuxndqonfipmb64f2km2devei4") - eol := time.Now().Add(time.Hour) - - rnd := rand.New(rand.NewSource(42)) - - sk, pk, err := crypto.GenerateEd25519Key(rnd) - assert.NoError(t, err) - - pid, err := peer.IDFromPublicKey(pk) - assert.NoError(t, err) - - entry, err := Create(sk, path, 1, eol, 0) - assert.NoError(t, err) - - t.Run("valid peer ID", func(t *testing.T) { - t.Parallel() - err = ValidateWithPeerID(pid, entry) - assert.NoError(t, err) - }) - - t.Run("invalid peer ID", func(t *testing.T) { - t.Parallel() - - _, pk2, err := crypto.GenerateEd25519Key(rnd) - assert.NoError(t, err) - - pid2, err := peer.IDFromPublicKey(pk2) - assert.NoError(t, err) - - err = ValidateWithPeerID(pid2, entry) - assert.ErrorIs(t, err, ErrSignature) - }) -} diff --git a/ipns/validation.go b/ipns/validation.go new file mode 100644 index 000000000..d12be7903 --- /dev/null +++ b/ipns/validation.go @@ -0,0 +1,256 @@ +package ipns + +import ( + "bytes" + "errors" + "fmt" + "time" + + pb "github.com/ipfs/boxo/ipns/pb" + "github.com/ipld/go-ipld-prime/codec/dagcbor" + basicnode "github.com/ipld/go-ipld-prime/node/basic" + record "github.com/libp2p/go-libp2p-record" + ic "github.com/libp2p/go-libp2p/core/crypto" + "github.com/libp2p/go-libp2p/core/peer" + "github.com/libp2p/go-libp2p/core/peerstore" +) + +// ValidateWithPeerID validates the given IPNS entry against the given peer ID. +func ValidateWithPeerID(r *Record, pid peer.ID) error { + pk, err := ExtractPublicKey(r, pid) + if err != nil { + return err + } + + return Validate(r, pk) +} + +// Validates validates the given IPNS Record against the given Public Key, following +// the [Record Verification] specification. +// +// [Record Verification]: https://specs.ipfs.tech/ipns/ipns-record/#record-serialization-format +func Validate(r *Record, pk ic.PubKey) error { + // (1) Ensure size is not over maximum record size. + if r.pb.Size() > MaxRecordSize { + return ErrRecordSize + } + + // (2) Ensure SignatureV2 and Data are present and not empty. + if len(r.pb.GetSignatureV2()) == 0 { + return ErrSignature + } + if len(r.pb.GetData()) == 0 { + return ErrBadRecord + } + + // (3) Extract Public Key - not necessary. Done via [ValidateWithPeerID]. + + // (4) Get deserialized Data as DAG-CBOR document. + sig2Data, err := recordDataForSignatureV2(r.pb.GetData()) + if err != nil { + return fmt.Errorf("could not compute signature data: %w", err) + } + + // (6) Verify signature against concatenation. + if ok, err := pk.Verify(sig2Data, r.pb.GetSignatureV2()); err != nil || !ok { + return ErrSignature + } + + // (5) Ensure that CBOR data matches Protobuf, only if Signature V1 is present. + if len(r.pb.GetSignatureV1()) != 0 { + if err := validateCborDataMatchesPbData(&r.pb); err != nil { + return err + } + } + + // Check EOL. + eol, err := r.Validity() + if err != nil { + return err + } + + if time.Now().After(eol) { + return ErrExpiredRecord + } + + return nil +} + +// TODO: Most of this function could probably be replaced with codegen +func validateCborDataMatchesPbData(entry *pb.IpnsEntry) error { + if len(entry.GetData()) == 0 { + return fmt.Errorf("record data is missing") + } + + ndbuilder := basicnode.Prototype__Map{}.NewBuilder() + if err := dagcbor.Decode(ndbuilder, bytes.NewReader(entry.GetData())); err != nil { + return err + } + + fullNd := ndbuilder.Build() + nd, err := fullNd.LookupByString(cborValueKey) + if err != nil { + return err + } + ndBytes, err := nd.AsBytes() + if err != nil { + return err + } + if !bytes.Equal(entry.GetValue(), ndBytes) { + return fmt.Errorf("field \"%v\" did not match between protobuf and CBOR", cborValueKey) + } + + nd, err = fullNd.LookupByString(cborValidityKey) + if err != nil { + return err + } + ndBytes, err = nd.AsBytes() + if err != nil { + return err + } + if !bytes.Equal(entry.GetValidity(), ndBytes) { + return fmt.Errorf("field \"%v\" did not match between protobuf and CBOR", cborValidityKey) + } + + nd, err = fullNd.LookupByString(cborValidityTypeKey) + if err != nil { + return err + } + ndInt, err := nd.AsInt() + if err != nil { + return err + } + if int64(entry.GetValidityType()) != ndInt { + return fmt.Errorf("field \"%v\" did not match between protobuf and CBOR", cborValidityTypeKey) + } + + nd, err = fullNd.LookupByString(cborSequenceKey) + if err != nil { + return err + } + ndInt, err = nd.AsInt() + if err != nil { + return err + } + + if entry.GetSequence() != uint64(ndInt) { + return fmt.Errorf("field \"%v\" did not match between protobuf and CBOR", cborSequenceKey) + } + + nd, err = fullNd.LookupByString("TTL") + if err != nil { + return err + } + ndInt, err = nd.AsInt() + if err != nil { + return err + } + if entry.GetTtl() != uint64(ndInt) { + return fmt.Errorf("field \"%v\" did not match between protobuf and CBOR", cborTTLKey) + } + + return nil +} + +var _ record.Validator = Validator{} + +// Validator is an IPNS Record validator that satisfies the [record.Validator] +// interface from Libp2p. +type Validator struct { + // KeyBook, if non-nil, is used to lookup keys for validating IPNS Records. + KeyBook peerstore.KeyBook +} + +// Validate validates an IPNS record. +func (v Validator) Validate(key string, value []byte) error { + ns, pidString, err := record.SplitKey(key) + if err != nil || ns != "ipns" { + return ErrInvalidPath + } + + r, err := UnmarshalRecord(value) + if err != nil { + return ErrBadRecord + } + + // Get the public key defined by the ipns path + pid, err := peer.IDFromBytes([]byte(pidString)) + if err != nil { + log.Debugf("failed to parse ipns record key %s into peer ID", pidString) + return ErrKeyFormat + } + + pk, err := v.getPublicKey(r, pid) + if err != nil { + return err + } + + return Validate(r, pk) +} + +func (v Validator) getPublicKey(r *Record, pid peer.ID) (ic.PubKey, error) { + switch pk, err := ExtractPublicKey(r, pid); err { + case peer.ErrNoPublicKey: + case nil: + return pk, nil + default: + return nil, err + } + + if v.KeyBook == nil { + log.Debugf("public key with hash %s not found in IPNS record and no peer store provided", pid) + return nil, ErrPublicKeyNotFound + } + + pk := v.KeyBook.PubKey(pid) + if pk == nil { + log.Debugf("public key with hash %s not found in peer store", pid) + return nil, ErrPublicKeyNotFound + } + + return pk, nil +} + +// Select selects the best record by checking which has the highest sequence +// number and latest validity. This function returns an error if any of the +// records fail to parse. +// +// This function does not validate the records. The caller is responsible for +// ensuring that the Records are valid by using [Validate]. +func (v Validator) Select(k string, vals [][]byte) (int, error) { + var recs []*Record + for _, v := range vals { + r, err := UnmarshalRecord(v) + if err != nil { + return -1, err + } + recs = append(recs, r) + } + + return selectRecord(recs, vals) +} + +func selectRecord(recs []*Record, vals [][]byte) (int, error) { + switch len(recs) { + case 0: + return -1, errors.New("no usable records in given set") + case 1: + return 0, nil + } + + var i int + for j := 1; j < len(recs); j++ { + cmp, err := compare(recs[i], recs[j]) + if err != nil { + return -1, err + } + if cmp == 0 { + cmp = bytes.Compare(vals[i], vals[j]) + } + if cmp < 0 { + i = j + } + } + + return i, nil +} diff --git a/ipns/validation_test.go b/ipns/validation_test.go new file mode 100644 index 000000000..3aa83dfcb --- /dev/null +++ b/ipns/validation_test.go @@ -0,0 +1,244 @@ +package ipns + +import ( + "math/rand" + "testing" + "time" + + "github.com/ipfs/boxo/path" + ic "github.com/libp2p/go-libp2p/core/crypto" + "github.com/libp2p/go-libp2p/core/peerstore" + "github.com/libp2p/go-libp2p/p2p/host/peerstore/pstoremem" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func shuffle[T any](a []T) { + for n := 0; n < 5; n++ { + for i := range a { + j := rand.Intn(len(a)) + a[i], a[j] = a[j], a[i] + } + } +} + +func TestOrdering(t *testing.T) { + t.Parallel() + + sk, _, _ := mustKeyPair(t, ic.Ed25519) + ts := time.Unix(1000000, 0) + + assert := func(name string, r *Record, from ...*Record) { + t.Run(name, func(t *testing.T) { + t.Parallel() + shuffle(from) + var vals [][]byte + for _, r := range from { + data, err := MarshalRecord(r) + require.NoError(t, err) + vals = append(vals, data) + } + + i, err := selectRecord(from, vals) + require.NoError(t, err) + require.Equal(t, r, from[i], "selected incorrect record") + }) + } + + r1, err := NewRecord(sk, testPath, 1, ts.Add(time.Hour), 0) + require.NoError(t, err) + + r2, err := NewRecord(sk, testPath, 2, ts.Add(time.Hour), 0) + require.NoError(t, err) + + r3, err := NewRecord(sk, testPath, 3, ts.Add(time.Hour), 0) + require.NoError(t, err) + + r4, err := NewRecord(sk, testPath, 3, ts.Add(time.Hour*2), 0) + require.NoError(t, err) + + r5, err := NewRecord(sk, testPath, 4, ts.Add(time.Hour*3), 0) + require.NoError(t, err) + + assert("R1 is the only record", r1, r1) + assert("R2 has the highest sequence number", r2, r1, r2) + assert("R3 has the highest sequence number", r3, r1, r2, r3) + assert("R4 has the highest EOL", r4, r1, r2, r3, r4) + assert("R5 has the highest sequence number", r5, r1, r2, r3, r4, r5) +} + +func TestValidator(t *testing.T) { + t.Parallel() + + check := func(t *testing.T, sk ic.PrivKey, keybook peerstore.KeyBook, key string, val []byte, eol time.Time, exp error) { + validator := Validator{keybook} + data := val + if data == nil { + // do not call mustNewRecord because that validates the record! + rec, err := NewRecord(sk, testPath, 1, eol, 0) + require.NoError(t, err) + data = mustMarshal(t, rec) + } + require.ErrorIs(t, validator.Validate(key, data), exp) + } + + t.Run("validator returns correct errors", func(t *testing.T) { + t.Parallel() + + ts := time.Now() + sk, _, pid := mustKeyPair(t, ic.RSA) + sk2, _, pid2 := mustKeyPair(t, ic.RSA) + kb, err := pstoremem.NewPeerstore() + require.NoError(t, err) + err = kb.AddPubKey(pid, sk.GetPublic()) + require.NoError(t, err) + emptyKB, err := pstoremem.NewPeerstore() + require.NoError(t, err) + + check(t, sk, kb, RoutingKey(pid), nil, ts.Add(time.Hour), nil) + check(t, sk, kb, RoutingKey(pid), nil, ts.Add(time.Hour*-1), ErrExpiredRecord) + check(t, sk, kb, RoutingKey(pid), []byte("bad data"), ts.Add(time.Hour), ErrBadRecord) + check(t, sk, kb, "/ipns/"+"bad key", nil, ts.Add(time.Hour), ErrKeyFormat) + check(t, sk, emptyKB, RoutingKey(pid), nil, ts.Add(time.Hour), ErrPublicKeyNotFound) + check(t, sk2, kb, RoutingKey(pid2), nil, ts.Add(time.Hour), ErrPublicKeyNotFound) + check(t, sk2, kb, RoutingKey(pid), nil, ts.Add(time.Hour), ErrSignature) + check(t, sk, kb, "//"+string(pid), nil, ts.Add(time.Hour), ErrInvalidPath) + check(t, sk, kb, "/wrong/"+string(pid), nil, ts.Add(time.Hour), ErrInvalidPath) + }) + + t.Run("validator uses public key", func(t *testing.T) { + t.Parallel() + + eol := time.Now().Add(time.Hour) + kb, err := pstoremem.NewPeerstore() + require.NoError(t, err) + + sk, _, pid := mustKeyPair(t, ic.Ed25519) + rec := mustNewRecord(t, sk, testPath, 1, eol, 0) + require.Empty(t, rec.pb.PubKey) + dataNoKey := mustMarshal(t, rec) + + check(t, sk, kb, RoutingKey(pid), dataNoKey, eol, nil) + }) + + t.Run("TestEmbeddedPubKeyValidate", func(t *testing.T) { + t.Parallel() + + eol := time.Now().Add(time.Hour) + kb, err := pstoremem.NewPeerstore() + require.NoError(t, err) + + sk, pk, pid := mustKeyPair(t, ic.RSA) + rec := mustNewRecord(t, sk, testPath, 1, eol, 0) + + // Fails with RSA key without embedded public key. + check(t, sk, kb, RoutingKey(pid), mustMarshal(t, rec), eol, ErrPublicKeyNotFound) + + // Embeds public key, must work now. + require.NoError(t, EmbedPublicKey(rec, pk)) + check(t, sk, kb, RoutingKey(pid), mustMarshal(t, rec), eol, nil) + + // Force bad public key. Validation fails. + rec.pb.PubKey = []byte("probably not a public key") + check(t, sk, kb, RoutingKey(pid), mustMarshal(t, rec), eol, ErrInvalidPublicKey) + + // Does not work with wrong key. + sk2, _, _ := mustKeyPair(t, ic.RSA) + wrongKey, err := ic.MarshalPublicKey(sk2.GetPublic()) + require.NoError(t, err) + rec.pb.PubKey = wrongKey + check(t, sk, kb, RoutingKey(pid), mustMarshal(t, rec), eol, ErrPublicKeyMismatch) + }) +} + +func TestValidate(t *testing.T) { + t.Parallel() + + t.Run("signature v1 is ignored", func(t *testing.T) { + t.Parallel() + + eol := time.Now().Add(time.Hour) + sk, pk, pid := mustKeyPair(t, ic.Ed25519) + ipnsRoutingKey := RoutingKey(pid) + + v := Validator{} + + rec1 := mustNewRecord(t, sk, path.FromString("/path/1"), 1, eol, 0, CompatibleWithV1(true)) + rec2 := mustNewRecord(t, sk, path.FromString("/path/2"), 2, eol, 0, CompatibleWithV1(true)) + + best, err := v.Select(ipnsRoutingKey, [][]byte{mustMarshal(t, rec1), mustMarshal(t, rec2)}) + require.NoError(t, err) + require.Equal(t, 1, best) + + // Having only the v1 signature is invalid. + rec2.pb.SignatureV2 = nil + require.Error(t, Validate(rec2, pk), ErrSignature) + + // Record with v2 signature is always be preferred. + best, err = v.Select(ipnsRoutingKey, [][]byte{mustMarshal(t, rec1), mustMarshal(t, rec2)}) + require.NoError(t, err) + require.Equal(t, 0, best) + + // Missing v1 signature is acceptable as long as there is a valid v2 signature. + rec1.pb.SignatureV1 = nil + require.NoError(t, Validate(rec1, pk)) + + // Invalid v1 signature is acceptable as long as there is a valid v2 signature. + rec1.pb.SignatureV1 = []byte("garbage") + require.NoError(t, Validate(rec1, pk)) + }) + + t.Run("only signature v2 is validated", func(t *testing.T) { + t.Parallel() + + eol := time.Now().Add(time.Hour) + sk, pk, _ := mustKeyPair(t, ic.Ed25519) + + entry, err := NewRecord(sk, testPath, 1, eol, 0) + require.NoError(t, err) + require.NoError(t, Validate(entry, pk)) + + entry.pb.SignatureV2 = nil + require.ErrorIs(t, Validate(entry, pk), ErrSignature) + }) + + t.Run("max size validation", func(t *testing.T) { + t.Parallel() + + eol := time.Now().Add(time.Hour) + sk, pk, _ := mustKeyPair(t, ic.RSA) + + // Create a record that is too large (value + other fields). + value := make([]byte, MaxRecordSize) + rec, err := NewRecord(sk, path.FromString(string(value)), 1, eol, 0) + require.NoError(t, err) + + err = Validate(rec, pk) + require.ErrorIs(t, err, ErrRecordSize) + }) +} + +func TestValidateWithPeerID(t *testing.T) { + t.Parallel() + + sk, _, pid := mustKeyPair(t, ic.Ed25519) + eol := time.Now().Add(time.Hour) + + r, err := NewRecord(sk, testPath, 1, eol, 0) + require.NoError(t, err) + + t.Run("valid peer ID", func(t *testing.T) { + t.Parallel() + + err = ValidateWithPeerID(r, pid) + assert.NoError(t, err) + }) + + t.Run("invalid peer ID", func(t *testing.T) { + t.Parallel() + + _, _, pid2 := mustKeyPair(t, ic.Ed25519) + err = ValidateWithPeerID(r, pid2) + assert.ErrorIs(t, err, ErrSignature) + }) +} diff --git a/namesys/ipns_resolver_validation_test.go b/namesys/ipns_resolver_validation_test.go index 9799e5ba3..ac8143b37 100644 --- a/namesys/ipns_resolver_validation_test.go +++ b/namesys/ipns_resolver_validation_test.go @@ -7,7 +7,6 @@ import ( opts "github.com/ipfs/boxo/coreiface/options/namesys" "github.com/ipfs/boxo/ipns" - ipns_pb "github.com/ipfs/boxo/ipns/pb" "github.com/ipfs/boxo/path" mockrouting "github.com/ipfs/boxo/routing/mock" "github.com/ipfs/boxo/routing/offline" @@ -59,14 +58,14 @@ func testResolverValidation(t *testing.T, keyType int) { // Create entry with expiry in one hour priv, id, _, ipnsDHTPath := genKeys(t, keyType) ts := time.Now() - p := []byte("/ipfs/QmfM2r8seH2GiRaC4esTjeraXEachRt8ZsSeGaWTPLyMoG") - entry, err := createIPNSRecordWithEmbeddedPublicKey(priv, p, 1, ts.Add(time.Hour), 0) + p := path.Path("/ipfs/QmfM2r8seH2GiRaC4esTjeraXEachRt8ZsSeGaWTPLyMoG") + rec, err := createIPNSRecordWithEmbeddedPublicKey(priv, p, 1, ts.Add(time.Hour), 0) if err != nil { t.Fatal(err) } // Publish entry - err = PublishEntry(ctx, vstore, ipnsDHTPath, entry) + err = PublishEntry(ctx, vstore, ipnsDHTPath, rec) if err != nil { t.Fatal(err) } @@ -101,7 +100,7 @@ func testResolverValidation(t *testing.T, keyType int) { priv2, id2, _, ipnsDHTPath2 := genKeys(t, keyType) // Publish entry - err = PublishEntry(ctx, nvVstore, ipnsDHTPath2, entry) + err = PublishEntry(ctx, nvVstore, ipnsDHTPath2, rec) if err != nil { t.Fatal(err) } @@ -114,12 +113,12 @@ func testResolverValidation(t *testing.T, keyType int) { } // Try embedding the incorrect private key inside the entry - if err := ipns.EmbedPublicKey(priv2.GetPublic(), entry); err != nil { + if err := ipns.EmbedPublicKey(rec, priv2.GetPublic()); err != nil { t.Fatal(err) } // Publish entry - err = PublishEntry(ctx, nvVstore, ipnsDHTPath2, entry) + err = PublishEntry(ctx, nvVstore, ipnsDHTPath2, rec) if err != nil { t.Fatal(err) } @@ -146,19 +145,19 @@ func genKeys(t *testing.T, keyType int) (ci.PrivKey, peer.ID, string, string) { if err != nil { t.Fatal(err) } - return sk, id, PkKeyForID(id), ipns.RecordKey(id) + return sk, id, PkKeyForID(id), ipns.RoutingKey(id) } -func createIPNSRecordWithEmbeddedPublicKey(sk ci.PrivKey, val []byte, seq uint64, eol time.Time, ttl time.Duration) (*ipns_pb.IpnsEntry, error) { - entry, err := ipns.Create(sk, val, seq, eol, ttl) +func createIPNSRecordWithEmbeddedPublicKey(sk ci.PrivKey, val path.Path, seq uint64, eol time.Time, ttl time.Duration) (*ipns.Record, error) { + rec, err := ipns.NewRecord(sk, val, seq, eol, ttl) if err != nil { return nil, err } - if err := ipns.EmbedPublicKey(sk.GetPublic(), entry); err != nil { + if err := ipns.EmbedPublicKey(rec, sk.GetPublic()); err != nil { return nil, err } - return entry, nil + return rec, nil } type mockValueStore struct { diff --git a/namesys/publisher.go b/namesys/publisher.go index 24a0b8e4d..fef38390e 100644 --- a/namesys/publisher.go +++ b/namesys/publisher.go @@ -6,10 +6,8 @@ import ( "sync" "time" - "github.com/gogo/protobuf/proto" opts "github.com/ipfs/boxo/coreiface/options/namesys" "github.com/ipfs/boxo/ipns" - pb "github.com/ipfs/boxo/ipns/pb" "github.com/ipfs/boxo/path" ds "github.com/ipfs/go-datastore" dsquery "github.com/ipfs/go-datastore/query" @@ -68,7 +66,7 @@ func IpnsDsKey(id peer.ID) ds.Key { // // This method will not search the routing system for records published by other // nodes. -func (p *IpnsPublisher) ListPublished(ctx context.Context) (map[peer.ID]*pb.IpnsEntry, error) { +func (p *IpnsPublisher) ListPublished(ctx context.Context) (map[peer.ID]*ipns.Record, error) { query, err := p.ds.Query(ctx, dsquery.Query{ Prefix: ipnsPrefix, }) @@ -77,7 +75,7 @@ func (p *IpnsPublisher) ListPublished(ctx context.Context) (map[peer.ID]*pb.Ipns } defer query.Close() - records := make(map[peer.ID]*pb.IpnsEntry) + records := make(map[peer.ID]*ipns.Record) for { select { case result, ok := <-query.Next(): @@ -87,8 +85,8 @@ func (p *IpnsPublisher) ListPublished(ctx context.Context) (map[peer.ID]*pb.Ipns if result.Error != nil { return nil, result.Error } - e := new(pb.IpnsEntry) - if err := proto.Unmarshal(result.Value, e); err != nil { + rec, err := ipns.UnmarshalRecord(result.Value) + if err != nil { // Might as well return what we can. log.Error("found an invalid IPNS entry:", err) continue @@ -103,7 +101,7 @@ func (p *IpnsPublisher) ListPublished(ctx context.Context) (map[peer.ID]*pb.Ipns log.Errorf("ipns ds key invalid: %s", result.Key) continue } - records[peer.ID(pid)] = e + records[peer.ID(pid)] = rec case <-ctx.Done(): return nil, ctx.Err() } @@ -115,7 +113,7 @@ func (p *IpnsPublisher) ListPublished(ctx context.Context) (map[peer.ID]*pb.Ipns // // If `checkRouting` is true and we have no existing record, this method will // check the routing system for any existing records. -func (p *IpnsPublisher) GetPublished(ctx context.Context, id peer.ID, checkRouting bool) (*pb.IpnsEntry, error) { +func (p *IpnsPublisher) GetPublished(ctx context.Context, id peer.ID, checkRouting bool) (*ipns.Record, error) { ctx, cancel := context.WithTimeout(ctx, time.Second*30) defer cancel() @@ -126,7 +124,7 @@ func (p *IpnsPublisher) GetPublished(ctx context.Context, id peer.ID, checkRouti if !checkRouting { return nil, nil } - ipnskey := ipns.RecordKey(id) + ipnskey := ipns.RoutingKey(id) value, err = p.routing.GetValue(ctx, ipnskey) if err != nil { // Not found or other network issue. Can't really do @@ -140,14 +138,11 @@ func (p *IpnsPublisher) GetPublished(ctx context.Context, id peer.ID, checkRouti default: return nil, err } - e := new(pb.IpnsEntry) - if err := proto.Unmarshal(value, e); err != nil { - return nil, err - } - return e, nil + + return ipns.UnmarshalRecord(value) } -func (p *IpnsPublisher) updateRecord(ctx context.Context, k crypto.PrivKey, value path.Path, options ...opts.PublishOption) (*pb.IpnsEntry, error) { +func (p *IpnsPublisher) updateRecord(ctx context.Context, k crypto.PrivKey, value path.Path, options ...opts.PublishOption) (*ipns.Record, error) { id, err := peer.IDFromPrivateKey(k) if err != nil { return nil, err @@ -162,22 +157,33 @@ func (p *IpnsPublisher) updateRecord(ctx context.Context, k crypto.PrivKey, valu return nil, err } - seqno := rec.GetSequence() // returns 0 if rec is nil - if rec != nil && value != path.Path(rec.GetValue()) { - // Don't bother incrementing the sequence number unless the - // value changes. - seqno++ + seqno := uint64(0) + if rec != nil { + seqno, err = rec.Sequence() + if err != nil { + return nil, err + } + + p, err := rec.Value() + if err != nil { + return nil, err + } + if value != path.Path(p.String()) { + // Don't bother incrementing the sequence number unless the + // value changes. + seqno++ + } } opts := opts.ProcessPublishOptions(options) // Create record - entry, err := ipns.Create(k, []byte(value), seqno, opts.EOL, opts.TTL) + r, err := ipns.NewRecord(k, value, seqno, opts.EOL, opts.TTL, ipns.CompatibleWithV1(opts.CompatibleWithV1)) if err != nil { return nil, err } - data, err := proto.Marshal(entry) + data, err := ipns.MarshalRecord(r) if err != nil { return nil, err } @@ -190,13 +196,13 @@ func (p *IpnsPublisher) updateRecord(ctx context.Context, k crypto.PrivKey, valu if err := p.ds.Sync(ctx, key); err != nil { return nil, err } - return entry, nil + return r, nil } // PutRecordToRouting publishes the given entry using the provided ValueStore, // keyed on the ID associated with the provided public key. The public key is // also made available to the routing system so that entries can be verified. -func PutRecordToRouting(ctx context.Context, r routing.ValueStore, k crypto.PubKey, entry *pb.IpnsEntry) error { +func PutRecordToRouting(ctx context.Context, r routing.ValueStore, k crypto.PubKey, rec *ipns.Record) error { ctx, span := StartSpan(ctx, "PutRecordToRouting") defer span.End() @@ -205,7 +211,7 @@ func PutRecordToRouting(ctx context.Context, r routing.ValueStore, k crypto.PubK errs := make(chan error, 2) // At most two errors (IPNS, and public key) - if err := ipns.EmbedPublicKey(k, entry); err != nil { + if err := ipns.EmbedPublicKey(rec, k); err != nil { return err } @@ -215,7 +221,7 @@ func PutRecordToRouting(ctx context.Context, r routing.ValueStore, k crypto.PubK } go func() { - errs <- PublishEntry(ctx, r, ipns.RecordKey(id), entry) + errs <- PublishEntry(ctx, r, ipns.RoutingKey(id), rec) }() // Publish the public key if a public key cannot be extracted from the ID @@ -225,7 +231,7 @@ func PutRecordToRouting(ctx context.Context, r routing.ValueStore, k crypto.PubK // NOTE: This check actually checks if the public key has been embedded // in the IPNS entry. This check is sufficient because we embed the // public key in the IPNS entry if it can't be extracted from the ID. - if entry.PubKey != nil { + if _, err := rec.PubKey(); err == nil { go func() { errs <- PublishPublicKey(ctx, r, PkKeyForID(id), k) }() @@ -265,11 +271,11 @@ func PublishPublicKey(ctx context.Context, r routing.ValueStore, k string, pubk // PublishEntry stores the given IpnsEntry in the ValueStore with the given // ipnskey. -func PublishEntry(ctx context.Context, r routing.ValueStore, ipnskey string, rec *pb.IpnsEntry) error { +func PublishEntry(ctx context.Context, r routing.ValueStore, ipnskey string, rec *ipns.Record) error { ctx, span := StartSpan(ctx, "PublishEntry", trace.WithAttributes(attribute.String("IPNSKey", ipnskey))) defer span.End() - data, err := proto.Marshal(rec) + data, err := ipns.MarshalRecord(rec) if err != nil { return err } diff --git a/namesys/publisher_test.go b/namesys/publisher_test.go index b40593c76..ad975f59a 100644 --- a/namesys/publisher_test.go +++ b/namesys/publisher_test.go @@ -56,7 +56,7 @@ func testNamekeyPublisher(t *testing.T, keyType int, expectedErr error, expected } // Value - value := []byte("ipfs/TESTING") + value := path.Path("ipfs/TESTING") // Seqnum seqnum := uint64(0) @@ -76,12 +76,12 @@ func testNamekeyPublisher(t *testing.T, keyType int, expectedErr error, expected serv := mockrouting.NewServer() r := serv.ClientWithDatastore(context.Background(), &identity{p}, dstore) - entry, err := ipns.Create(privKey, value, seqnum, eol, 0) + rec, err := ipns.NewRecord(privKey, value, seqnum, eol, 0) if err != nil { t.Fatal(err) } - err = PutRecordToRouting(ctx, r, pubKey, entry) + err = PutRecordToRouting(ctx, r, pubKey, rec) if err != nil { t.Fatal(err) } diff --git a/namesys/republisher/repub.go b/namesys/republisher/repub.go index 276899859..87200ff5c 100644 --- a/namesys/republisher/repub.go +++ b/namesys/republisher/repub.go @@ -12,10 +12,8 @@ import ( "github.com/ipfs/boxo/path" "go.opentelemetry.io/otel/attribute" - "github.com/gogo/protobuf/proto" opts "github.com/ipfs/boxo/coreiface/options/namesys" "github.com/ipfs/boxo/ipns" - pb "github.com/ipfs/boxo/ipns/pb" ds "github.com/ipfs/go-datastore" logging "github.com/ipfs/go-log/v2" "github.com/jbenet/goprocess" @@ -140,7 +138,7 @@ func (rp *Republisher) republishEntry(ctx context.Context, priv ic.PrivKey) erro log.Debugf("republishing ipns entry for %s", id) // Look for it locally only - e, err := rp.getLastIPNSEntry(ctx, id) + rec, err := rp.getLastIPNSRecord(ctx, id) if err != nil { if err == errNoEntry { span.SetAttributes(attribute.Bool("NoEntry", true)) @@ -150,8 +148,13 @@ func (rp *Republisher) republishEntry(ctx context.Context, priv ic.PrivKey) erro return err } - p := path.Path(e.GetValue()) - prevEol, err := ipns.GetEOL(e) + p, err := rec.Value() + if err != nil { + span.RecordError(err) + return err + } + + prevEol, err := rec.Validity() if err != nil { span.RecordError(err) return err @@ -162,12 +165,12 @@ func (rp *Republisher) republishEntry(ctx context.Context, priv ic.PrivKey) erro if prevEol.After(eol) { eol = prevEol } - err = rp.ns.Publish(ctx, priv, p, opts.PublishWithEOL(eol)) + err = rp.ns.Publish(ctx, priv, path.Path(p.String()), opts.PublishWithEOL(eol)) span.RecordError(err) return err } -func (rp *Republisher) getLastIPNSEntry(ctx context.Context, id peer.ID) (*pb.IpnsEntry, error) { +func (rp *Republisher) getLastIPNSRecord(ctx context.Context, id peer.ID) (*ipns.Record, error) { // Look for it locally only val, err := rp.ds.Get(ctx, namesys.IpnsDsKey(id)) switch err { @@ -178,9 +181,5 @@ func (rp *Republisher) getLastIPNSEntry(ctx context.Context, id peer.ID) (*pb.Ip return nil, err } - e := new(pb.IpnsEntry) - if err := proto.Unmarshal(val, e); err != nil { - return nil, err - } - return e, nil + return ipns.UnmarshalRecord(val) } diff --git a/namesys/republisher/repub_test.go b/namesys/republisher/repub_test.go index bfd8730c0..d6c7b0d85 100644 --- a/namesys/republisher/repub_test.go +++ b/namesys/republisher/repub_test.go @@ -6,8 +6,6 @@ import ( "testing" "time" - "github.com/gogo/protobuf/proto" - "github.com/jbenet/goprocess" "github.com/libp2p/go-libp2p" dht "github.com/libp2p/go-libp2p-kad-dht" @@ -18,7 +16,6 @@ import ( opts "github.com/ipfs/boxo/coreiface/options/namesys" "github.com/ipfs/boxo/ipns" - ipns_pb "github.com/ipfs/boxo/ipns/pb" "github.com/ipfs/boxo/path" ds "github.com/ipfs/go-datastore" dssync "github.com/ipfs/go-datastore/sync" @@ -208,12 +205,12 @@ func TestLongEOLRepublish(t *testing.T) { t.Fatal(err) } - entry, err := getLastIPNSEntry(ctx, publisher.store, publisher.h.ID()) + rec, err := getLastIPNSRecord(ctx, publisher.store, publisher.h.ID()) if err != nil { t.Fatal(err) } - finalEol, err := ipns.GetEOL(entry) + finalEol, err := rec.Validity() if err != nil { t.Fatal(err) } @@ -223,18 +220,14 @@ func TestLongEOLRepublish(t *testing.T) { } } -func getLastIPNSEntry(ctx context.Context, dstore ds.Datastore, id peer.ID) (*ipns_pb.IpnsEntry, error) { +func getLastIPNSRecord(ctx context.Context, dstore ds.Datastore, id peer.ID) (*ipns.Record, error) { // Look for it locally only val, err := dstore.Get(ctx, namesys.IpnsDsKey(id)) if err != nil { return nil, err } - e := new(ipns_pb.IpnsEntry) - if err := proto.Unmarshal(val, e); err != nil { - return nil, err - } - return e, nil + return ipns.UnmarshalRecord(val) } func verifyResolution(nsystems []namesys.NameSystem, key string, exp path.Path) error { diff --git a/namesys/resolve_test.go b/namesys/resolve_test.go index d2da31215..3aecdccaf 100644 --- a/namesys/resolve_test.go +++ b/namesys/resolve_test.go @@ -54,7 +54,7 @@ func TestPrexistingExpiredRecord(t *testing.T) { h := path.FromString("/ipfs/QmZULkCELmmk5XNfCgTnCyFgAVxBRBXyDHGGMVoLFLiXEN") eol := time.Now().Add(time.Hour * -1) - entry, err := ipns.Create(identity.PrivateKey(), []byte(h), 0, eol, 0) + entry, err := ipns.NewRecord(identity.PrivateKey(), h, 0, eol, 0) if err != nil { t.Fatal(err) } @@ -87,7 +87,7 @@ func TestPrexistingRecord(t *testing.T) { // Make a good record and put it in the datastore h := path.FromString("/ipfs/QmZULkCELmmk5XNfCgTnCyFgAVxBRBXyDHGGMVoLFLiXEN") eol := time.Now().Add(time.Hour) - entry, err := ipns.Create(identity.PrivateKey(), []byte(h), 0, eol, 0) + entry, err := ipns.NewRecord(identity.PrivateKey(), h, 0, eol, 0) if err != nil { t.Fatal(err) } diff --git a/namesys/routing.go b/namesys/routing.go index 4ec7fe377..566150f3a 100644 --- a/namesys/routing.go +++ b/namesys/routing.go @@ -5,17 +5,13 @@ import ( "strings" "time" - "github.com/gogo/protobuf/proto" opts "github.com/ipfs/boxo/coreiface/options/namesys" "github.com/ipfs/boxo/ipns" - pb "github.com/ipfs/boxo/ipns/pb" "github.com/ipfs/boxo/path" - "github.com/ipfs/go-cid" logging "github.com/ipfs/go-log/v2" dht "github.com/libp2p/go-libp2p-kad-dht" "github.com/libp2p/go-libp2p/core/peer" "github.com/libp2p/go-libp2p/core/routing" - mh "github.com/multiformats/go-multihash" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/trace" ) @@ -81,7 +77,7 @@ func (r *IpnsResolver) resolveOnceAsync(ctx context.Context, name string, option // Use the routing system to get the name. // Note that the DHT will call the ipns validator when retrieving // the value, which in turn verifies the ipns record signature - ipnsKey := ipns.RecordKey(pid) + ipnsKey := ipns.RoutingKey(pid) vals, err := r.routing.SearchValue(ctx, ipnsKey, dht.Quorum(int(options.DhtRecordCount))) if err != nil { @@ -105,34 +101,25 @@ func (r *IpnsResolver) resolveOnceAsync(ctx context.Context, name string, option return } - entry := new(pb.IpnsEntry) - err = proto.Unmarshal(val, entry) + rec, err := ipns.UnmarshalRecord(val) if err != nil { log.Debugf("RoutingResolver: could not unmarshal value for name %s: %s", name, err) emitOnceResult(ctx, out, onceResult{err: err}) return } - var p path.Path - // check for old style record: - if valh, err := mh.Cast(entry.GetValue()); err == nil { - // Its an old style multihash record - log.Debugf("encountered CIDv0 ipns entry: %s", valh) - p = path.FromCid(cid.NewCidV0(valh)) - } else { - // Not a multihash, probably a new style record - p, err = path.ParsePath(string(entry.GetValue())) - if err != nil { - emitOnceResult(ctx, out, onceResult{err: err}) - return - } + p, err := rec.Value() + if err != nil { + emitOnceResult(ctx, out, onceResult{err: err}) + return } ttl := DefaultResolverCacheTTL - if entry.Ttl != nil { - ttl = time.Duration(*entry.Ttl) + if recordTTL, err := rec.TTL(); err == nil { + ttl = recordTTL } - switch eol, err := ipns.GetEOL(entry); err { + + switch eol, err := rec.Validity(); err { case ipns.ErrUnrecognizedValidity: // No EOL. case nil: @@ -149,7 +136,7 @@ func (r *IpnsResolver) resolveOnceAsync(ctx context.Context, name string, option return } - emitOnceResult(ctx, out, onceResult{value: p, ttl: ttl}) + emitOnceResult(ctx, out, onceResult{value: path.Path(p.String()), ttl: ttl}) case <-ctx.Done(): return }