Skip to content
Open
Show file tree
Hide file tree
Changes from 6 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
27 changes: 27 additions & 0 deletions libp2p/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,12 @@
PROTOCOL_ID as TLS_PROTOCOL_ID,
TLSTransport
)
from libp2p.identity_utils import (
create_identity_from_seed,
identity_exists,
load_identity,
save_identity,
)

import libp2p.security.secio.transport as secio
from libp2p.stream_muxer.mplex.mplex import (
Expand Down Expand Up @@ -264,6 +270,16 @@ def generate_new_ed25519_identity() -> KeyPair:


def generate_peer_id_from(key_pair: KeyPair) -> ID:
"""
Generate a deterministic peer ID from a keypair.

The peer ID is derived from the public key, so the same keypair will
always produce the same peer ID. This enables identity persistence:
if you save and reuse the same keypair, you'll get the same peer ID.

:param key_pair: The keypair to generate a peer ID from
:return: A deterministic peer ID
"""
public_key = key_pair.public_key
return ID.from_pubkey(public_key)

Expand Down Expand Up @@ -321,11 +337,22 @@ def new_swarm(
Note: Ed25519 keys are used by default for better interoperability with
other libp2p implementations (Rust, Go) which often disable RSA support.
"""
# Identity Generation Flow:
# 1. If no keypair is provided, generate a new random Ed25519 keypair
# 2. If a keypair IS provided, use it (enables identity persistence)
# 3. Derive a deterministic peer ID from the keypair's public key
#
# For identity persistence, users can:
# - Save the keypair to disk and reload it on restart
# - Generate a keypair from a seed for deterministic identity
# - Pass the same keypair to new_host() or new_swarm()
if key_pair is None:
# Use Ed25519 by default for better interoperability with Rust/Go libp2p
# which often compile without RSA support
key_pair = generate_new_ed25519_identity()

# Generate deterministic peer ID from keypair
# Same keypair always produces the same peer ID
id_opt = generate_peer_id_from(key_pair)

transport: TCP | QUICTransport | ITransport
Expand Down
156 changes: 156 additions & 0 deletions libp2p/identity_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
"""
Identity persistence utilities for py-libp2p.

This module provides helper functions for saving, loading, and creating
peer identities. These utilities enable opt-in identity persistence without
changing the default behavior of generating random identities.

Example usage:
>>> from libp2p.identity_utils import save_identity, load_identity
>>> from libp2p.crypto.ed25519 import create_new_key_pair
>>>
>>> # Create and save an identity
>>> key_pair = create_new_key_pair()
>>> save_identity(key_pair, "my_peer.key")
>>>
>>> # Load it later
>>> loaded_key_pair = load_identity("my_peer.key")
"""

from pathlib import Path

from libp2p.crypto.ed25519 import (
Ed25519PrivateKey,
create_new_key_pair as create_new_ed25519_key_pair,
)
from libp2p.crypto.keys import KeyPair


def save_identity(key_pair: KeyPair, filepath: str | Path) -> None:
"""
Save a keypair to disk for later reuse.

The private key is serialized and saved to the specified file.
The file should be kept secure as it contains the peer's private key.

Args:
key_pair: The KeyPair to save
filepath: Path where the private key will be saved

Raises:
OSError: If the file cannot be written

Example:
>>> from libp2p.crypto.ed25519 import create_new_key_pair
>>> key_pair = create_new_key_pair()
>>> save_identity(key_pair, "my_peer_identity.key")

"""
filepath = Path(filepath)
Copy link
Contributor

Choose a reason for hiding this comment

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

Issue: Between write_bytes() (line 55) and chmod() (line 59), the file exists with default permissions (often 0644). In a multi-user system, another process could read the private key during this window.

Recommendation: Create file with restrictive permissions atomically:

import os

def save_identity(key_pair: KeyPair, filepath: str | Path) -> None:
    filepath = Path(filepath)
    private_key_bytes = key_pair.private_key.to_bytes()
    
    # Create file with 0600 permissions atomically
    fd = os.open(filepath, os.O_CREAT | os.O_WRONLY | os.O_TRUNC, 0o600)
    try:
        os.write(fd, private_key_bytes)
    finally:
        os.close(fd)


# Serialize the private key to bytes
private_key_bytes = key_pair.private_key.to_bytes()

# Write to file with restrictive permissions (owner read/write only)
filepath.write_bytes(private_key_bytes)

Copy link
Contributor

Choose a reason for hiding this comment

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

Issue: If the parent directory doesn't exist, filepath.write_bytes() raises FileNotFoundError.

Recommendation: Create parent directories:

filepath = Path(filepath)
filepath.parent.mkdir(parents=True, exist_ok=True)
private_key_bytes = key_pair.private_key.to_bytes()
filepath.write_bytes(private_key_bytes)

# Set file permissions to 0600 (owner read/write only) for security
try:
filepath.chmod(0o600)
except (OSError, NotImplementedError):
# Some filesystems don't support chmod, ignore the error
pass


def load_identity(filepath: str | Path) -> KeyPair:
"""
Load a keypair from disk.

Reads a previously saved private key and reconstructs the full keypair.
Currently only supports Ed25519 keys.

Args:
filepath: Path to the saved private key file

Returns:
KeyPair loaded from the file

Raises:
FileNotFoundError: If the file doesn't exist
ValueError: If the file contains invalid key data

Example:
>>> key_pair = load_identity("my_peer_identity.key")
>>> from libp2p import new_host
>>> host = new_host(key_pair=key_pair)

"""
filepath = Path(filepath)

# Read the private key bytes
private_key_bytes = filepath.read_bytes()

# Reconstruct the Ed25519 private key
private_key = Ed25519PrivateKey.from_bytes(private_key_bytes)

# Derive the public key
public_key = private_key.get_public_key()

return KeyPair(private_key, public_key)

Copy link
Contributor

Choose a reason for hiding this comment

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

No Private Key Validation on Load

Issue:

  1. No validation of file integrity (checksum, length check, version header)
  2. No error handling for corrupted/truncated files
  3. Directly passes raw bytes to Ed25519PrivateKey.from_bytes()

Impact: Corrupted identity files could:

  • Produce invalid keys silently
  • Cause subtle cryptographic failures
  • Be difficult to debug

Recommendation: Add validation:

def load_identity(filepath: str | Path) -> KeyPair:
    filepath = Path(filepath)
    private_key_bytes = filepath.read_bytes()
    
    # Ed25519 private keys are exactly 32 bytes
    if len(private_key_bytes) != 32:
        raise ValueError(f"Invalid private key file: expected 32 bytes, got {len(private_key_bytes)}")
    
    # Reconstruct and validate
    private_key = Ed25519PrivateKey.from_bytes(private_key_bytes)
    
    # Verify key is valid by attempting to get public key
    try:
        public_key = private_key.get_public_key()
        # Additional validation: verify we can serialize/deserialize roundtrip
        _ = public_key.serialize()
    except Exception as e:
        raise ValueError(f"Invalid private key in file: {e}")
    
    return KeyPair(private_key, public_key)


def create_identity_from_seed(seed: bytes) -> KeyPair:
"""
Create a deterministic identity from a seed.

The same seed will always produce the same keypair and peer ID.
This is useful for testing or when you want a deterministic identity
without saving keys to disk.

Args:
seed: A 32-byte seed for key generation. Must be exactly 32 bytes.

Returns:
KeyPair generated deterministically from the seed

Raises:
ValueError: If the seed is not 32 bytes or produces an invalid key

Example:
>>> seed = b"my_secret_seed_32_bytes_long!!!!"
>>> key_pair = create_identity_from_seed(seed)
>>> from libp2p import new_host
>>> host = new_host(key_pair=key_pair)
>>> # Same seed always produces same peer ID
>>> print(host.get_id())

"""
if len(seed) != 32:
raise ValueError(
f"Seed must be exactly 32 bytes, got {len(seed)} bytes. "
"Consider using hashlib.sha256(your_seed).digest() "
"to derive a 32-byte seed."
)

return create_new_ed25519_key_pair(seed=seed)


def identity_exists(filepath: str | Path) -> bool:
"""
Check if an identity file exists at the given path.

Args:
filepath: Path to check for an existing identity file

Returns:
True if the file exists, False otherwise

Example:
>>> if identity_exists("my_peer.key"):
... key_pair = load_identity("my_peer.key")
... else:
... key_pair = create_new_key_pair()
... save_identity(key_pair, "my_peer.key")

"""
return Path(filepath).exists()
27 changes: 27 additions & 0 deletions libp2p/peer/id.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,33 @@ def from_base58(cls, b58_encoded_peer_id_str: str) -> "ID":

@classmethod
def from_pubkey(cls, key: PublicKey) -> "ID":
"""
Create a deterministic peer ID from a public key.

This method generates a peer ID by hashing the serialized public key.
The same public key will ALWAYS produce the same peer ID, which is
fundamental for identity persistence in libp2p.

For small keys (≤42 bytes, like Ed25519), the key is embedded directly
in the peer ID using an identity multihash. For larger keys (like RSA),
a SHA-256 hash is used.

:param key: The public key to generate a peer ID from
:return: A deterministic peer ID derived from the public key

Example:
>>> from libp2p.crypto.ed25519 import create_new_key_pair
>>> from libp2p.peer.id import ID
>>> kp1 = create_new_key_pair()
>>> kp2 = create_new_key_pair()
>>> # Same keypair produces same peer ID
>>> ID.from_pubkey(kp1.public_key) == ID.from_pubkey(kp1.public_key)
True
>>> # Different keypairs produce different peer IDs
>>> ID.from_pubkey(kp1.public_key) == ID.from_pubkey(kp2.public_key)
False

"""
serialized_key = key.serialize()
algo = multihash.Func.sha2_256
if ENABLE_INLINING and len(serialized_key) <= MAX_INLINE_KEY_LENGTH:
Expand Down
Loading