diff --git a/libp2p/abc.py b/libp2p/abc.py index b2601ed6..dc28d072 100644 --- a/libp2p/abc.py +++ b/libp2p/abc.py @@ -47,6 +47,7 @@ from libp2p.peer.peerinfo import ( ) if TYPE_CHECKING: + from libp2p.peer.peer_record import PeerRecord from libp2p.pubsub.pubsub import ( Pubsub, ) @@ -1752,6 +1753,59 @@ class IPeerRecord(ABC): """ +# -------------------------- envelope interface.py -------------------------- +class IEnvelope(ABC): + @abstractmethod + def marshal_envelope(self) -> bytes: + """ + Serialize this Envelope into its protobuf wire format. + + Converts all envelope fields into a `pb.Envelope` protobuf message + and returns the serialized bytes. + + :return: Serialized envelope as bytes. + """ + + @abstractmethod + def validate(self, domain: str) -> None: + """ + Verify the envelope's signature within the given domain scope. + + This ensures that the envelope has not been tampered with + and was signed under the correct usage context. + + :param domain: Domain string that contextualizes the signature. + :raises ValueError: If the signature is invalid. + """ + + @abstractmethod + def record(self) -> "PeerRecord": + """ + Lazily decode and return the embedded PeerRecord. + + This method unmarshals the payload bytes into a `PeerRecord` instance, + using the registered codec to identify the type. The decoded result + is cached for future use. + + :return: Decoded PeerRecord object. + :raises Exception: If decoding fails or payload type is unsupported. + """ + + @abstractmethod + def equal(self, other: Any) -> bool: + """ + Compare this Envelope with another for structural equality. + + Two envelopes are considered equal if: + - They have the same public key + - The payload type and payload bytes match + - Their signatures are identical + + :param other: Another object to compare. + :return: True if equal, False otherwise. + """ + + # -------------------------- peerdata interface.py -------------------------- diff --git a/libp2p/peer/envelope.py b/libp2p/peer/envelope.py new file mode 100644 index 00000000..f058dbf7 --- /dev/null +++ b/libp2p/peer/envelope.py @@ -0,0 +1,252 @@ +from typing import Any + +from libp2p.crypto.ed25519 import Ed25519PublicKey +from libp2p.crypto.keys import PrivateKey, PublicKey +from libp2p.crypto.rsa import RSAPublicKey +from libp2p.crypto.secp256k1 import Secp256k1PublicKey +import libp2p.peer.pb.crypto_pb2 as cryto_pb +import libp2p.peer.pb.envelope_pb2 as pb +import libp2p.peer.pb.peer_record_pb2 as record_pb +from libp2p.peer.peer_record import PeerRecord, peer_record_from_protobuf +from libp2p.utils.varint import encode_uvarint + +ENVELOPE_DOMAIN = "libp2p-peer-record" +PEER_RECORD_CODEC = b"\x03\x01" + + +class Envelope: + """ + A signed wrapper around a serialized libp2p record. + + Envelopes are cryptographically signed by the author's private key + and are scoped to a specific 'domain' to prevent cross-protocol replay. + + Attributes: + public_key: The public key that can verify the envelope's signature. + payload_type: A multicodec code identifying the type of payload inside. + raw_payload: The raw serialized record data. + signature: Signature over the domain-scoped payload content. + + """ + + public_key: PublicKey + payload_type: bytes + raw_payload: bytes + signature: bytes + + _cached_record: PeerRecord | None = None + _unmarshal_error: Exception | None = None + + def __init__( + self, + public_key: PublicKey, + payload_type: bytes, + raw_payload: bytes, + signature: bytes, + ): + self.public_key = public_key + self.payload_type = payload_type + self.raw_payload = raw_payload + self.signature = signature + + def marshal_envelope(self) -> bytes: + """ + Serialize this Envelope into its protobuf wire format. + + Converts all envelope fields into a `pb.Envelope` protobuf message + and returns the serialized bytes. + + :return: Serialized envelope as bytes. + """ + pb_env = pb.Envelope( # type: ignore[attr-defined] + public_key=pub_key_to_protobuf(self.public_key), + payload_type=self.payload_type, + payload=self.raw_payload, + signature=self.signature, + ) + return pb_env.SerializeToString() + + def validate(self, domain: str) -> None: + """ + Verify the envelope's signature within the given domain scope. + + This ensures that the envelope has not been tampered with + and was signed under the correct usage context. + + :param domain: Domain string that contextualizes the signature. + :raises ValueError: If the signature is invalid. + """ + unsigned = make_unsigned(domain, self.payload_type, self.raw_payload) + if not self.public_key.verify(unsigned, self.signature): + raise ValueError("Invalid envelope signature") + + def record(self) -> PeerRecord: + """ + Lazily decode and return the embedded PeerRecord. + + This method unmarshals the payload bytes into a `PeerRecord` instance, + using the registered codec to identify the type. The decoded result + is cached for future use. + + :return: Decoded PeerRecord object. + :raises Exception: If decoding fails or payload type is unsupported. + """ + if self._cached_record is not None: + return self._cached_record + + try: + if self.payload_type != PEER_RECORD_CODEC: + raise ValueError("Unsuported payload type in envelope") + msg = record_pb.PeerRecord() # type: ignore[attr-defined] + msg.ParseFromString(self.raw_payload) + + self._cached_record = peer_record_from_protobuf(msg) + return self._cached_record + except Exception as e: + self._unmarshal_error = e + raise + + def equal(self, other: Any) -> bool: + """ + Compare this Envelope with another for structural equality. + + Two envelopes are considered equal if: + - They have the same public key + - The payload type and payload bytes match + - Their signatures are identical + + :param other: Another object to compare. + :return: True if equal, False otherwise. + """ + if isinstance(other, Envelope): + return ( + self.public_key.__eq__(other.public_key) + and self.payload_type == other.payload_type + and self.signature == other.signature + and self.raw_payload == other.raw_payload + ) + return False + + +def pub_key_to_protobuf(pub_key: PublicKey) -> cryto_pb.PublicKey: # type: ignore[name-defined] + """ + Convert a Python PublicKey object to its protobuf equivalent. + + :param pub_key: A libp2p-compatible PublicKey instance. + :return: Serialized protobuf PublicKey message. + """ + key_type = pub_key.get_type().value + data = pub_key.to_bytes() + protobuf_key = cryto_pb.PublicKey(Type=key_type, Data=data) # type: ignore[attr-defined] + return protobuf_key + + +def pub_key_from_protobuf(pb_key: cryto_pb.PublicKey) -> PublicKey: # type: ignore[name-defined] + """ + Parse a protobuf PublicKey message into a native libp2p PublicKey. + + Supports Ed25519, RSA, and Secp256k1 key types. + + :param pb_key: Protobuf representation of a public key. + :return: Parsed PublicKey object. + :raises ValueError: If the key type is unrecognized. + """ + if pb_key.Type == cryto_pb.KeyType.Ed25519: # type: ignore[attr-defined] + return Ed25519PublicKey.from_bytes(pb_key.Data) + elif pb_key.Type == cryto_pb.KeyType.RSA: # type: ignore[attr-defined] + return RSAPublicKey.from_bytes(pb_key.Data) + elif pb_key.Type == cryto_pb.KeyType.Secp256k1: # type: ignore[attr-defined] + return Secp256k1PublicKey.from_bytes(pb_key.Data) + # TODO: Add suport fot ECDSA parsing also + else: + raise ValueError(f"Unknown key type: {pb_key.Type}") + + +def seal_record(record: PeerRecord, private_key: PrivateKey) -> Envelope: + """ + Create and sign a new Envelope from a PeerRecord. + + The record is serialized and signed in the scope of its domain and codec. + The result is a self-contained, verifiable Envelope. + + :param record: A PeerRecord to encapsulate. + :param private_key: The signer's private key. + :return: A signed Envelope instance. + """ + payload = record.marshal_record() + + unsigned = make_unsigned(record.domain(), record.codec(), payload) + signature = private_key.sign(unsigned) + + return Envelope( + public_key=private_key.get_public_key(), + payload_type=record.codec(), + raw_payload=payload, + signature=signature, + ) + + +def consume_envelope(data: bytes, domain: str) -> tuple[Envelope, PeerRecord]: + """ + Parse, validate, and decode an Envelope from bytes. + + Validates the envelope's signature using the given domain and decodes + the inner payload into a PeerRecord. + + :param data: Serialized envelope bytes. + :param domain: Domain string to verify signature against. + :return: Tuple of (Envelope, PeerRecord). + :raises ValueError: If signature validation or decoding fails. + """ + env = unmarshal_envelope(data) + env.validate(domain) + record = env.record() + return env, record + + +def unmarshal_envelope(data: bytes) -> Envelope: + """ + Deserialize an Envelope from its wire format. + + This parses the protobuf fields without verifying the signature. + + :param data: Serialized envelope bytes. + :return: Parsed Envelope object. + :raises DecodeError: If protobuf parsing fails. + """ + pb_env = pb.Envelope() # type: ignore[attr-defined] + pb_env.ParseFromString(data) + pk = pub_key_from_protobuf(pb_env.public_key) + + return Envelope( + public_key=pk, + payload_type=pb_env.payload_type, + raw_payload=pb_env.payload, + signature=pb_env.signature, + ) + + +def make_unsigned(domain: str, payload_type: bytes, payload: bytes) -> bytes: + """ + Build a byte buffer to be signed for an Envelope. + + The unsigned byte structure is: + varint(len(domain)) || domain || + varint(len(payload_type)) || payload_type || + varint(len(payload)) || payload + + This is the exact input used during signing and verification. + + :param domain: Domain string for signature scoping. + :param payload_type: Identifier for the type of payload. + :param payload: Raw serialized payload bytes. + :return: Byte buffer to be signed or verified. + """ + fields = [domain.encode(), payload_type, payload] + buf = bytearray() + + for field in fields: + buf.extend(encode_uvarint(len(field))) + buf.extend(field) + + return bytes(buf) diff --git a/libp2p/peer/pb/crypto.proto b/libp2p/peer/pb/crypto.proto new file mode 100644 index 00000000..b2327e68 --- /dev/null +++ b/libp2p/peer/pb/crypto.proto @@ -0,0 +1,22 @@ +syntax = "proto3"; + +package libp2p.peer.pb.crypto; + +option go_package = "github.com/libp2p/go-libp2p/core/crypto/pb"; + +enum KeyType { + RSA = 0; + Ed25519 = 1; + Secp256k1 = 2; + ECDSA = 3; +} + +message PublicKey { + KeyType Type = 1; + bytes Data = 2; +} + +message PrivateKey { + KeyType Type = 1; + bytes Data = 2; +} diff --git a/libp2p/peer/pb/crypto_pb2.py b/libp2p/peer/pb/crypto_pb2.py new file mode 100644 index 00000000..66c81cea --- /dev/null +++ b/libp2p/peer/pb/crypto_pb2.py @@ -0,0 +1,30 @@ +# -*- coding: utf-8 -*- +# Generated by the protocol buffer compiler. DO NOT EDIT! +# source: libp2p/peer/pb/crypto.proto +# Protobuf Python Version: 4.25.3 +"""Generated protocol buffer code.""" +from google.protobuf import descriptor as _descriptor +from google.protobuf import descriptor_pool as _descriptor_pool +from google.protobuf import symbol_database as _symbol_database +from google.protobuf.internal import builder as _builder +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + + + +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1blibp2p/peer/pb/crypto.proto\x12\x15libp2p.peer.pb.crypto\"G\n\tPublicKey\x12,\n\x04Type\x18\x01 \x01(\x0e\x32\x1e.libp2p.peer.pb.crypto.KeyType\x12\x0c\n\x04\x44\x61ta\x18\x02 \x01(\x0c\"H\n\nPrivateKey\x12,\n\x04Type\x18\x01 \x01(\x0e\x32\x1e.libp2p.peer.pb.crypto.KeyType\x12\x0c\n\x04\x44\x61ta\x18\x02 \x01(\x0c*9\n\x07KeyType\x12\x07\n\x03RSA\x10\x00\x12\x0b\n\x07\x45\x64\x32\x35\x35\x31\x39\x10\x01\x12\r\n\tSecp256k1\x10\x02\x12\t\n\x05\x45\x43\x44SA\x10\x03\x42,Z*github.com/libp2p/go-libp2p/core/crypto/pbb\x06proto3') # type: ignore[no-untyped-call] +_globals = globals() +_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) +_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'libp2p.peer.pb.crypto_pb2', _globals) +if _descriptor._USE_C_DESCRIPTORS == False: + _globals['DESCRIPTOR']._options = None + _globals['DESCRIPTOR']._serialized_options = b'Z*github.com/libp2p/go-libp2p/core/crypto/pb' + _globals['_KEYTYPE']._serialized_start=201 + _globals['_KEYTYPE']._serialized_end=258 + _globals['_PUBLICKEY']._serialized_start=54 + _globals['_PUBLICKEY']._serialized_end=125 + _globals['_PRIVATEKEY']._serialized_start=127 + _globals['_PRIVATEKEY']._serialized_end=199 +# @@protoc_insertion_point(module_scope) diff --git a/libp2p/peer/pb/envelope.proto b/libp2p/peer/pb/envelope.proto new file mode 100644 index 00000000..7eb498fb --- /dev/null +++ b/libp2p/peer/pb/envelope.proto @@ -0,0 +1,14 @@ +syntax = "proto3"; + +package libp2p.peer.pb.record; + +import "libp2p/peer/pb/crypto.proto"; + +option go_package = "github.com/libp2p/go-libp2p/core/record/pb"; + +message Envelope { + libp2p.peer.pb.crypto.PublicKey public_key = 1; + bytes payload_type = 2; + bytes payload = 3; + bytes signature = 5; +} diff --git a/libp2p/peer/pb/envelope_pb2.py b/libp2p/peer/pb/envelope_pb2.py new file mode 100644 index 00000000..c031a54b --- /dev/null +++ b/libp2p/peer/pb/envelope_pb2.py @@ -0,0 +1,27 @@ +# -*- coding: utf-8 -*- +# Generated by the protocol buffer compiler. DO NOT EDIT! +# source: libp2p/peer/pb/envelope.proto +# Protobuf Python Version: 4.25.3 +"""Generated protocol buffer code.""" +from google.protobuf import descriptor as _descriptor +from google.protobuf import descriptor_pool as _descriptor_pool +from google.protobuf import symbol_database as _symbol_database +from google.protobuf.internal import builder as _builder +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + +from libp2p.peer.pb import crypto_pb2 as libp2p_dot_peer_dot_pb_dot_crypto__pb2 + + +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1dlibp2p/peer/pb/envelope.proto\x12\x15libp2p.peer.pb.record\x1a\x1blibp2p/peer/pb/crypto.proto\"z\n\x08\x45nvelope\x12\x34\n\npublic_key\x18\x01 \x01(\x0b\x32 .libp2p.peer.pb.crypto.PublicKey\x12\x14\n\x0cpayload_type\x18\x02 \x01(\x0c\x12\x0f\n\x07payload\x18\x03 \x01(\x0c\x12\x11\n\tsignature\x18\x05 \x01(\x0c\x42,Z*github.com/libp2p/go-libp2p/core/record/pbb\x06proto3') # type: ignore[no-untyped-call] +_globals = globals() +_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) +_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'libp2p.peer.pb.envelope_pb2', _globals) +if _descriptor._USE_C_DESCRIPTORS == False: + _globals['DESCRIPTOR']._options = None + _globals['DESCRIPTOR']._serialized_options = b'Z*github.com/libp2p/go-libp2p/core/record/pb' + _globals['_ENVELOPE']._serialized_start=85 + _globals['_ENVELOPE']._serialized_end=207 +# @@protoc_insertion_point(module_scope) diff --git a/tests/core/peer/test_envelope.py b/tests/core/peer/test_envelope.py new file mode 100644 index 00000000..5377968e --- /dev/null +++ b/tests/core/peer/test_envelope.py @@ -0,0 +1,85 @@ +from multiaddr import Multiaddr + +from libp2p.crypto.rsa import ( + create_new_key_pair, +) +from libp2p.peer.envelope import ( + Envelope, + consume_envelope, + make_unsigned, + seal_record, + unmarshal_envelope, +) +from libp2p.peer.id import ID +import libp2p.peer.pb.crypto_pb2 as crypto_pb +import libp2p.peer.pb.envelope_pb2 as env_pb +from libp2p.peer.peer_record import PeerRecord + +DOMAIN = "libp2p-peer-record" + + +def test_basic_protobuf_serialization_deserialization(): + pubkey = crypto_pb.PublicKey() # type: ignore[attr-defined] + pubkey.Type = crypto_pb.KeyType.Ed25519 # type: ignore[attr-defined] + pubkey.Data = b"\x01\x02\x03" + + env = env_pb.Envelope() # type: ignore[attr-defined] + env.public_key.CopyFrom(pubkey) + env.payload_type = b"\x03\x01" + env.payload = b"test-payload" + env.signature = b"signature-bytes" + + serialized = env.SerializeToString() + + new_env = env_pb.Envelope() # type: ignore[attr-defined] + new_env.ParseFromString(serialized) + + assert new_env.public_key.Type == crypto_pb.KeyType.Ed25519 # type: ignore[attr-defined] + assert new_env.public_key.Data == b"\x01\x02\x03" + assert new_env.payload_type == b"\x03\x01" + assert new_env.payload == b"test-payload" + assert new_env.signature == b"signature-bytes" + + +def test_enevelope_marshal_unmarshal_roundtrip(): + keypair = create_new_key_pair() + pubkey = keypair.public_key + private_key = keypair.private_key + + payload_type = b"\x03\x01" + payload = b"test-record" + sig = private_key.sign(make_unsigned(DOMAIN, payload_type, payload)) + + env = Envelope(pubkey, payload_type, payload, sig) + serialized = env.marshal_envelope() + new_env = unmarshal_envelope(serialized) + + assert new_env.public_key == pubkey + assert new_env.payload_type == payload_type + assert new_env.raw_payload == payload + assert new_env.signature == sig + + +def test_seal_and_consume_envelope_roundtrip(): + keypair = create_new_key_pair() + priv_key = keypair.private_key + pub_key = keypair.public_key + + peer_id = ID.from_pubkey(pub_key) + addrs = [Multiaddr("/ip4/127.0.0.1/tcp/4001"), Multiaddr("/ip4/127.0.0.1/tcp/4002")] + seq = 12345 + + record = PeerRecord(peer_id=peer_id, addrs=addrs, seq=seq) + + # Seal + envelope = seal_record(record, priv_key) + serialized = envelope.marshal_envelope() + + # Consume + env, rec = consume_envelope(serialized, record.domain()) + + # Assertions + assert env.public_key == pub_key + assert rec.peer_id == peer_id + assert rec.seq == seq + assert rec.addrs == addrs