Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

RFC: Signed Address Records #217

Merged
merged 17 commits into from
Nov 19, 2020
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions RFC/0002-signed-envelopes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# RFC 0002 - Signed Envelopes

- Start Date: 2019-10-21
- Related RFC: [0003 Address Records][addr-records-rfc]

## Abstract

This RFC proposes a "signed envelope" structure that contains an arbitray byte
raulk marked this conversation as resolved.
Show resolved Hide resolved
string payload, a signature of the payload, and the public key that can be used
to verify the signature.

This was spun out of an earlier draft of the [address records
RFC][addr-records-rfc], since it's generically useful.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It may be worth considering how generically useful this structure is given that the payload must be kept exactly as it is received (instead of allowing it to be deserailized and then reserialized).

If we chose to use a deterministic encoding scheme (e.g. Canonical CBOR or IPLD) instead of Protobufs this would be less of a problem. However, if we'd like to keep using Protobufs then it'd be great to have some documentation letting people know.

Thanks @yusefnapora for the great work putting this together

Copy link
Member

@raulk raulk Nov 11, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The envelope contains both the byte payload, and the signature over that byte payload. The serialisation scheme is irrelevant at this layer.

The recipient of this payload validates that the signature matches the plaintext and the key, then deserialises the payload with the serialisation format mandated for the payload type, in order to process it (e.g. to consume the multiaddrs).

If the recipient intends to relay this payload (as is the case of p2p discovery mechanisms), it does not send a re-serialised form, but rather it forwards the original envelope. In general, it's bad and fragile practice to reconstitute a payload in the hope that it'll continue matching the original signature that was annexed to it.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The serialisation scheme is irrelevant at this layer....
In general, it's bad and fragile practice to reconstitute a payload in the hope that it'll continue matching the original signature that was annexed to it.

These two statements are tied together and are following a rule set that you may think is correct, but is not obvious. Not obvious restrictions dictating how the data may be interacted with should be documented. Additionally, this restriction does not have to exist it's just something that's been decided is ok/insufficiently problematic to bother dealing with.

I also disagree with it being "bad" to allow consistent serialization/deserialization of objects. If I have data which I need to propagate frequently and access infrequently then I'll just store the message bytes and deserialize every time I need to access the data. If I frequently propagate and access the data I'll store the data both serialized and deserialized. If however, I infrequently propagate the data and frequently access it I'm now forced to waste space by storing both the serialized and deserialized versions for no reason other than we like Protobufs.

Copy link
Member

@raulk raulk Nov 12, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These two statements are tied together and are following a rule set that you may think is correct, but is not obvious.

They are not. You are mixing up the concerns of a cryptographic envelope, with the details of how the inner opaque payload is constructed. These two layers are decoupled, and @yusefnapora has done a good job of modelling that in this spec.

I also disagree with it being "bad" to allow consistent serialization/deserialization of objects.

That's not what I said.

If however, I infrequently propagate the data and frequently access it I'm now forced to waste space by storing both the serialized and deserialized versions...

Yes, and it's a cost you assume to preserve the integrity of a signature.

for no reason other than we like Protobufs.

Incorrect. Systems preserve the original data along with the signature for many reasons including reducing the surface for bugs, traceability/auditability, and others.


I insist it is a terrible idea to assume that, even with canonical serialisation, your system will be perennially capable of reconstituting a payload out of its constituents, in a way that it matches the original signature. Developers introduce bugs, systems change, schemas change, and maintaining such hypothetical logic is error-prone, brittle and convoluted.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you please explain what you think the downsides are of utilizing a format with canonical serialization?

I've already given a use case that would be helped by enabling canonical serialization, a record type that is infrequently propagated but frequently used would benefit from reduced memory and storage consumption.

Are you suggesting that we are intentionally using a format that has non-canonical serialization to dissuade other people from making design decisions you think are "terrible"? Are there other reasons you feel using IPLD or Canonical CBOR would be bad?

The point I'm trying to make here is that you seem to think "it's a terrible idea" for people to assume de+re serializing data will keep it identical, I think that in some situations it could be useful. Could you please list some of the negatives of utilizing a canonical serialization format and enabling developers to make their own decisions about whether to rely on its ability to de+re serialize accurately?

Copy link
Contributor

@aschmahmann aschmahmann Nov 12, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@raulk this "generic and not opinionated" wrapper cannot be used if someone wanted to share (using a CID as a reference) a collection of envelopes and still access them efficiently.

Concrete example. If IPNS was being created today it could easily use one of these signed envelopes to contain its data. However, if I wanted to share over IPFS a set of IPNS records (e.g. here are the 10 public keys corresponding to my favorite website authors) I could not just take the IPNS records and stuff them into an IPLD object without compromising on storing two copies of the envelope.

I'm not saying the above example is common or something we should definitely do, but it shows a use case that your approach blocks. If we have a justification for ignoring this use case (e.g. you think protobuf is a more "amply supported, performant, well-vetted format" then the alternatives that support canonical serialization) then that's fine rationale.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@aschmahmann

  1. This spec does not rely on the serialization of the outer object. When signing and verifying the signature, it takes the outer envelope and deterministically re-serializes it (not using protobuf).
  2. The inner object is just bytes. Those bytes can be CBOR or anything else.

Even better, we have a type field so we can:

  1. Put the IPLD codec in the type field.
  2. Put an arbitrary embedded IPLD object in the content field.

TL;DR: you absolutely free to re-serialize the content on the fly as long as you have chosen a format with a deterministic serialization.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for all the discussion :) I definitely feel @aschmahmann about signing structured data that doesn't have a deterministic encoding - it just feels kind of wrong. Serializing to bytes before signing side-steps the issue, but it's also a bit awkward.

My first pass at this did use IPLD, mostly because of this issue of deterministic encoding, and also because I think the IPLD schema DSL is pretty cool. I ended up backing away from that, but I don't think I explained my thought process very well.

IPLD is attractive because you can get deterministic output with the CBOR encoding, but I was hesitant to rely on that, mostly because IPLD is still pretty new. If we start assuming that we can always serialize IPLD to the same bytes, that seems like it kind of limits the future evolution of the IPLD CBOR format. If we ever need to change how IPLD gets serialized to CBOR, any signatures made with the older implementation will be invalid.

The other problem with IPLD is just that we seem to be in the middle of a Cambrian explosion of libp2p implementations, and it seems like a tough ask to make libp2p implementers also implement IPLD.

I don't think either of those arguments really apply to just using plain CBOR & requiring the canonical encoding (sorted map keys, etc). CBOR has broad language support, and the canonical encoding is (hopefully) stable. And of course, if we did use CBOR, you could embed our records into an IPLD graph as-is without having to treat them as opaque blobs, since a valid CBOR map will presumably always be valid IPLD.

Honestly, I ended up going with protobuf instead simply because it seemed easier to define a protobuf schema than to specify the map keys, value types, etc that we'd need to define for a CBOR-based format. Also, since we need to include the public key & there's already a protobuf definition for that. That's mostly just me being lazy though, and I'd rather revisit this now than after we've baked it into a bunch of implementations.

I do like the idea of having a standard way to ship signed byte arrays around, but it's also possible that because I'm focused on this one use case of routing records that it's not actually as generic or broadly useful as I'm hoping.

You could certainly argue that it would be even more useful to have a standard way of shipping signed structured data around. We could potentially define an envelope as something like

{
  publicKey: {
    // cbor map containing key
  },
  contents: {
    // cbor map containing whatever you want
  },
  signature: "byte blob containing sig of canonically-serialized contents map"
}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I realized I didn't address @raulk's point in my last comment

I insist it is a terrible idea to assume that, even with canonical serialisation, your system will be perennially capable of reconstituting a payload out of its constituents, in a way that it matches the original signature

That's the other reason I "gave up" on IPLD / CBOR and just went with the signed binary blob, although I don't know if I feel as strongly as Raúl does about it. We could potentially try to guard against differences in encoder implementations by having a ton of test vectors, but of course there's no way to guarantee we'd catch everything.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@yusefnapora thanks for the detailed explanation here. I get IPLD being a big ask here, although it's probably worth thinking about (for the future) if there's a minimal subset of IPLD that it would be useful for libp2p to have access to.

It being easier to implement and wanting to get this shipped are totally reasonable reasons for us to want to go with protobufs. I guess I just wanted to clarify why the decision was made.

Also, I'm not sure if this is what @raulk was trying to explain but after speaking with @Stebalien I see that if a new format came along that wanted to have a consistent hash for a set of envelopes that we could just define the encoding for that new format. It's unfortunate, from a developer perspective, that we'd have to define and implement a canonical protobuf encoding instead of just using a pre-standardized and packaged encoder but it's still achievable within the spec. Given that IPLD defines codecs for each serialization format we import if we're not going with a pre-supported IPLD format then we'd have to define a new codec anyway.

@yusefnapora your suggestion would certainly do the job.


## Problem Statement

Sometimes we'd like to store some data in a public location (e.g. a DHT, etc),
or make use of potentially untrustworthy intermediaries to relay information. It
would be nice to have an all-purpose data container that includes a signature of
the data, so we can verify that the data came from a specific peer and that it hasn't
been tampered with.

## Domain Separation

Signatures can be used for a variety of purposes, and a signature made for a
specific purpose MUST NOT be considered valid for a different purpose.

Without this property, an attacker could convince a peer to sign a paylod in one
context and present it as valid in another, for example, presenting a signed
address record as a pubsub message.

We separate signatures into "domains" by prefixing the data to be signed with a
string unique to each domain. This string is not contained within the payload or
the outer envelope structure. Instead, each libp2p subystem that makes use of
signed envelopes will provide their own domain string when constructing the
envelope, and again when validating the envelope. If the domain string used to
validate is different from the one used to sign, the signature validation will
fail.

Domain strings may be any valid UTF-8 string, but MUST NOT contain the `:`
character (UTF-8 code point `0x3A`), as this is used to separate the domain
string from the content when signing.

## Wire Format

Since we already have a [protobuf definition for public keys][peer-id-spec], we
can use protobuf for this as well and easily embed the key in the envelope:


```protobuf
message SignedEnvelope {
jacobheun marked this conversation as resolved.
Show resolved Hide resolved
PublicKey publicKey = 1; // see peer id spec for definition
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Food for thought. Including the pubkey may be superfluous for some signature schemes.

Given an ECDSA signature, one can recover the public key provided we know the curve, the hash function, and the plaintext that was signed. Bitcoin and Ethereum use that trick heavily to validate transactions.

See:

https://crypto.stackexchange.com/questions/18105/how-does-recovering-the-public-key-from-an-ecdsa-signature-work
https://crypto.stackexchange.com/questions/60218/recovery-public-key-from-secp256k1-signature-and-message

bytes contents = 2; // payload
bytes signature = 3; // signature of domain string + contents
}
raulk marked this conversation as resolved.
Show resolved Hide resolved
```

The `publicKey` field contains the public key whose secret counterpart was used
to sign the message. This MUST be consistent with the peer id of the signing
peer, as the recipient will derive the peer id of the signer from this key.


## Signature Production / Verification

When signing, a peer will prepare a buffer by concatenating the following:

- The [domain separation string](#domain-separation), encoded as UTF-8
- The UTF-8 encoded `:` character
- The `contents` field

Then they will sign the buffer according to the rules in the [peer id
spec][peer-id-spec] and set the `signature` field accordingly.

To verify, a peer will "inflate" the `publicKey` into a domain object that can
verify signatures, prepare a buffer as above and verify the `signature` field
against it.

[addr-records-rfc]: ./0003-address-records.md
jacobheun marked this conversation as resolved.
Show resolved Hide resolved
[peer-id-spec]: ../peer-ids/peer-ids.md
267 changes: 267 additions & 0 deletions RFC/0003-address-records.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,267 @@
# RFC 0003 - Address Records with Metadata

- Start Date: 2019-10-04
- Related Issues:
- [libp2p/issues/47](https://github.com/libp2p/libp2p/issues/47)
- [go-libp2p/issues/436](https://github.com/libp2p/go-libp2p/issues/436)

## Abstract

This RFC proposes a method for distributing address records, which contain a
peer's publicly reachable listen addresses, as well as some metadata that can
help other peers categorize addresses and prioritize thme when dialing.

The record described here does not include a signature, but it is expected to
be serialized and wrapped in a [signed envelope][envelope-rfc], which will
prove the identity of the issuing peer. The dialer can then prioritize
self-certified addresses over addresses from an unknown origin.

## Problem Statement

All libp2p peers keep a "peer store" (called a peer book in some
implementations), which maps [peer ids][peer-id-spec] to a set of known
addresses for each peer. When the application layer wants to contact a peer, the
dialer will pull addresses from the peer store and try to initiate a connection
on one or more addresses.

Addresses for a peer can come from a variety of sources. If we have already made
a connection to a peer, the libp2p [identify protocol][identify-spec] will
inform us of other addresses that they are listening on. We may also discover
their address by querying the DHT, checking a fixed "bootstrap list", or perhaps
through a pubsub message or an application-specific protocol.

In the case of the identify protocol, we can be fairly certain that the
addresses originate from the peer we're speaking to, assuming that we're using a
secure, authenticated communication channel. However, more "ambient" discovery
methods such as DHT traversal and pubsub depend on potentially untrustworthy
third parties to relay address information.

Even in the case of receiving addresses via the identify protocol, our
confidence that the address came directly from the peer is not actionable, because
the peer store does not track the origin of an address. Once added to the peer
store, all addresses are considered equally valid, regardless of their source.

We would like to have a means of distributing _verifiable_ address records,
which we can prove originated from the addressed peer itself. We also need a way to
track the "provenance" of an address within libp2p's internal components such as
the peer store. Once those pieces are in place, we will also need a way to
prioritize addresses based on their authenticity, with the most strict strategy
being to only dial certified addresses.

### Complications

While producing a signed record is fairly trivial, there are a few aspects to
this problem that complicate things.

1. Addresses are not static. A given peer may have several addresses at any given
time, and the set of addresses can change at arbitrary times.
2. Peers may not know their own addresses. It's often impossible to automatically
infer one's own public address, and peers may need to rely on third party
peers to inform them of their observed public addresses.
3. A peer may inadvertently or maliciously sign an address that they do not
control. In other words, a signature isn't a guarantee that a given address is
valid.
4. Some addresses may be ambiguous. For example, addresses on a private subnet
are valid within that subnet but are useless on the public internet.

The first point implies that the address record should include some kind of
temporal component, so that newer records can replace older ones as the state
changes over time. This could be a timestamp and/or a simple sequence number
that each node increments whenever they publish a new record.

The second and third points highlight the limits of certifying information that
is itself uncertain. While a signature can prove that the addresses originated
from the peer, it cannot prove that the addresses are correct or useful. Given
the asymmetric nature of real-world NATs, it's often the case that a peer is
_less likely_ to have correct information about its own address than an outside
observer, at least initially.

This suggests that we should include some measure of "confidence" in our
records, so that peers can distribute addresses that they are not fully certain
are correct, while still asserting that they created the record. For example,
when requesting a dial-back via the [AutoNAT service][autonat], a peer could
send a "provisional" address record. When the AutoNAT peer confirms the address,
that address could be marked as confirmed and advertised in a new record.

Regarding the fourth point about ambiguous addresses, it would also be desirable
for the address record to include a notion of "routability," which would
indicate how "accessible" the address is likely to be. This would allow us to
mark an address as "LAN-only," if we know that it is not mapped to a publicly
reachable address but would still like to distribute it to local peers.

## Address Record Format

Here's a protobuf that might work:

```protobuf
// Routability indicates the "scope" of an address, meaning how visible
// or accessible it is. This allows us to distinguish between LAN and
// WAN addresses.
//
// Side Note: we could potentially have a GLOBAL_RELAY case, which would
// make it easy to prioritize non-relay addresses in the dialer. Bit of
// a mix of concerns though.
enum Routability {
// catch-all default / unknown scope
UNKNOWN = 1;

// another process on the same machine
LOOPBACK = 2;

// a local area network
LOCAL = 3;

// public internet
GLOBAL = 4;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We'll need a PROXIMITY enum value for network-less transports that rely on physical proximity, e.g. Bluetooth.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
GLOBAL = 4;
PUBLIC = 4;


// reserved for future use
INTERPLANETARY = 100;
}


// Confidence indicates how much we believe in the validity of the
// address.
enum Confidence {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What classes of problems do you foresee communicating our perceived confidence of our own addresses would solve?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If it's to signal which dials to prioritise, we can simplify this by conveying a 1-byte precedence value in the range (-128, 128).

// default, unknown confidence. we don't know one way or another
UNKNOWN = 1;

// INVALID means we know that this address is invalid and should be deleted
INVALID = 2;

// UNCONFIRMED means that we suspect this address is valid, but we haven't
// fully confirmed that we're reachable.
UNCONFIRMED = 3;

// CONFIRMED means that we fully believe this address is valid.
// Each node / implementation can have their own criteria for confirmation.
CONFIRMED = 4;
}

// AddressInfo is a multiaddr plus some metadata.
message AddressInfo {
bytes multiaddr = 1;
Routability routability = 2;
Confidence confidence = 3;
}

// AddressState contains the listen addresses (and their metadata)
// for a peer at a particular point in time.
//
// Although this record contains a wall-clock `issuedAt` timestamp,
// there are no guarantees about node clocks being in sync or correct.
// As such, the `issuedAt` field should be considered informational,
// and `version` should be preferred when ordering records.
message AddressState {
// the peer id of the subject of the record.
bytes subjectPeer = 1;

// `version` is an increment-only counter that can be used to
// order AddressState records chronologically. Newer records
// MUST have a higher `version` than older records, but there
// can be gaps between version numbers.
uint64 version = 2;

// The `issuedAt` timestamp stores the creation time of this record in
// seconds from the unix epoch, according to the issuer's clock. There
// are no guarantees about clock sync or correctness. SHOULD NOT be used
// to order AddressState records; use `version` instead.
uint64 issuedAt = 3;

// All current listen addresses and their metadata.
repeated AddressInfo addresses = 4;
}
```

The idea with the structure above is that you send some metadata along with your
addresses: your "routability", and your own confidence in the validity of the
address. This is wrapped in an `AddressInfo` struct along with the address
itself.

Then you have a big list of `AddressInfo`s, which we put in an `AddressState`.
An `AddressState` identifies the `subjectPeer`, which is the peer that the
record is about, to whom the addresses belong. It also includes a `version`
number, so that we can replace earlier `AddressState`s with newer ones, and a
timestamp for informational purposes.

#### Example

Here's an example. Alice has an address that she thinks is publicly reachable
but has not confirmed. She also has a LAN-local address that she knows is valid,
but not routable via the public internet:

```javascript
{
subjectPeer: "QmAlice...",
version: 23456,
issuedAt: 1570215229,

addresses: [
{
addr: "/ip4/1.2.3.4/tcp/42/p2p/QmAlice",
routability: "GLOBAL",
confidence: "UNCONFIRMED"
},
{
addr: "/ip4/10.0.1.2/tcp/42/p2p/QmAlice",
routability: "LOCAL",
confidence: "CONFIRMED"
}
]
}
```

If Alice wants to publish her address to a public shared resource like a DHT,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we recommend that records are rejected outright when they leak addresses that are outside the scope of the discovery mechanism?

she should omit `LOCAL` and other unreachable addresses, and peers should
likewise filter out `LOCAL` addresses from public sources.

## Certification / Verification

This structure can be contained in a [signed envelope][envelope-rfc], which lets
us issue "self-certified" address records that are signed by the `subjectPeer`.

## Peer Store APIs

This section is a WIP, and I'd love input.

We need to figure out how to surface the address metadata in the peerstore APIs.

In go, extending the [`AddrInfo`
struct](https://github.com/libp2p/go-libp2p-core/blob/master/peer/addrinfo.go)
to include metadata seems like a decent place to start, and js likewise has
[js-peer-info](https://github.com/libp2p/js-peer-info) that could be extended.

When storing this metadata internally, we may want to make a distinction between
the remote peer's confidence in an address and our own confidence; we may decide
an address is invalid when the remote peer thinks otherwise. One idea is to have
our local confidence just be a numeric score (for easy sorting) that takes the
remote peer's confidence value as an input.

The go [AddrBook
interface](https://github.com/libp2p/go-libp2p-core/blob/master/peerstore/peerstore.go#L89)
would also need to be updated - it currently deals with "raw" multiaddrs, and
the only metadata exposed is a TTL for expiration. Changing this interface seems
like a fairly big refactor to me, especially with the implementation in another
repo. I'd love if some gophers could weigh in on a good way forward.

## Dialing Strategies

Once we're surfacing routability info alongside addresses, the dialer can decide
to optionally prioritize addresses it thinks are most likely to be reachable. We
can also add an option to only dial self-certified addresses, although that
likely won't be practical until self-certified addresses become commonplace.

## Changes to core libp2p protocols

How to publish these to the DHT? Are the backward compatibility issues with
older unsigned address records? Maybe we just publish these to a different key
prefix...

Should we update identify and mDNS discovery to use signed records?


[identify-spec]: ../identify/README.md
[peer-id-spec]: ../peer-ids/peer-ids.md
[autonat]: https://github.com/libp2p/specs/issues/180
[ipld]: https://ipld.io/
[ipld-schema-schema]: https://github.com/ipld/specs/blob/master/schemas/schema-schema.ipldsch
[envelope-rfc]: ./0002-signed-envelopes.md