Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
1 change: 1 addition & 0 deletions bindings/python/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ Python3_add_library(python-libtorrent MODULE WITH_SOABI
src/torrent_status.cpp
src/utility.cpp
src/version.cpp
src/ed25519.cpp
)

set_target_properties(python-libtorrent
Expand Down
1 change: 1 addition & 0 deletions bindings/python/Jamfile
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,7 @@ my-python-extension libtorrent
src/magnet_uri.cpp
src/error_code.cpp
src/load_torrent.cpp
src/ed25519.cpp
: # requirements
<include>src
# the bindings will expose deprecated functions, so there's no point in
Expand Down
120 changes: 120 additions & 0 deletions bindings/python/src/ed25519.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
// Copyright James Keane 2026. Use, modification and distribution is
// subject to the Boost Software License, Version 1.0. (See accompanying
// file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt)

#include "boost_python.hpp"
#include <libtorrent/kademlia/ed25519.hpp>

Copilot AI Apr 7, 2026

Copy link

Choose a reason for hiding this comment

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

This file uses std::array, std::copy, std::invalid_argument, and std::ptrdiff_t but doesn’t include the corresponding standard headers. Relying on transitive includes can break builds depending on include order/toolchain. Add explicit includes for the standard library facilities used (e.g., , , , and ).

Suggested change
#include <libtorrent/kademlia/ed25519.hpp>
#include <libtorrent/kademlia/ed25519.hpp>
#include <algorithm>
#include <array>
#include <cstddef>
#include <stdexcept>

Copilot uses AI. Check for mistakes.
#include "bytes.hpp"

using namespace boost::python;
using namespace lt;

namespace {

bytes create_seed()
{
auto const seed = dht::ed25519_create_seed();
return bytes(seed.data(), seed.size());
}

tuple create_keypair(bytes const& seed)
{
if (seed.arr.size() != 32)
throw std::invalid_argument("seed must be 32 bytes");

std::array<char, 32> s;
std::copy(seed.arr.begin(), seed.arr.end(), s.begin());

auto const [pk, sk] = dht::ed25519_create_keypair(s);
return make_tuple(
bytes(pk.bytes.data(), pk.bytes.size()),
bytes(sk.bytes.data(), sk.bytes.size()));
}

bytes sign(bytes const& msg, bytes const& pk, bytes const& sk)
{
if (pk.arr.size() != 32)
throw std::invalid_argument("public key must be 32 bytes");
if (sk.arr.size() != 64)
throw std::invalid_argument("secret key must be 64 bytes");

dht::public_key public_key(pk.arr.data());
dht::secret_key secret_key(sk.arr.data());

auto const sig = dht::ed25519_sign(
{msg.arr.data(), static_cast<std::ptrdiff_t>(msg.arr.size())},
public_key, secret_key);
return bytes(sig.bytes.data(), sig.bytes.size());
}

bool verify(bytes const& sig, bytes const& msg, bytes const& pk)
{
if (sig.arr.size() != 64)
throw std::invalid_argument("signature must be 64 bytes");
if (pk.arr.size() != 32)
throw std::invalid_argument("public key must be 32 bytes");

dht::signature signature(sig.arr.data());
dht::public_key public_key(pk.arr.data());

return dht::ed25519_verify(signature,
{msg.arr.data(), static_cast<std::ptrdiff_t>(msg.arr.size())},
public_key);
}

bytes add_scalar_public(bytes const& pk, bytes const& scalar)
{
if (pk.arr.size() != 32)
throw std::invalid_argument("public key must be 32 bytes");
if (scalar.arr.size() != 32)
throw std::invalid_argument("scalar must be 32 bytes");

dht::public_key public_key(pk.arr.data());
std::array<char, 32> s;
std::copy(scalar.arr.begin(), scalar.arr.end(), s.begin());

auto const result = dht::ed25519_add_scalar(public_key, s);
return bytes(result.bytes.data(), result.bytes.size());
}

bytes add_scalar_secret(bytes const& sk, bytes const& scalar)
{
if (sk.arr.size() != 64)
throw std::invalid_argument("secret key must be 64 bytes");
if (scalar.arr.size() != 32)
throw std::invalid_argument("scalar must be 32 bytes");

dht::secret_key secret_key(sk.arr.data());
std::array<char, 32> s;
std::copy(scalar.arr.begin(), scalar.arr.end(), s.begin());

auto const result = dht::ed25519_add_scalar(secret_key, s);
return bytes(result.bytes.data(), result.bytes.size());
}

bytes key_exchange(bytes const& pk, bytes const& sk)
{
if (pk.arr.size() != 32)
throw std::invalid_argument("public key must be 32 bytes");
if (sk.arr.size() != 64)
throw std::invalid_argument("secret key must be 64 bytes");

dht::public_key public_key(pk.arr.data());
dht::secret_key secret_key(sk.arr.data());

auto const secret = dht::ed25519_key_exchange(public_key, secret_key);
return bytes(secret.data(), secret.size());
}

} // anonymous namespace

void bind_ed25519()
{
def("ed25519_create_seed", &create_seed);
def("ed25519_create_keypair", &create_keypair);
def("ed25519_sign", &sign);
def("ed25519_verify", &verify);
def("ed25519_add_scalar_public", &add_scalar_public);
def("ed25519_add_scalar_secret", &add_scalar_secret);
def("ed25519_key_exchange", &key_exchange);
}
2 changes: 2 additions & 0 deletions bindings/python/src/module.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ void bind_create_torrent();
void bind_file_storage();
void bind_error_code();
void bind_load_torrent();
void bind_ed25519();

BOOST_PYTHON_MODULE(libtorrent)
{
Expand Down Expand Up @@ -60,4 +61,5 @@ BOOST_PYTHON_MODULE(libtorrent)
bind_create_torrent();
bind_file_storage();
bind_load_torrent();
bind_ed25519();
}
141 changes: 141 additions & 0 deletions bindings/python/tests/ed25519_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import unittest

import libtorrent as lt


class Ed25519CreateSeedTest(unittest.TestCase):
def test_returns_32_bytes(self) -> None:
seed = lt.ed25519_create_seed()
self.assertEqual(len(seed), 32)

def test_seeds_are_unique(self) -> None:
seed1 = lt.ed25519_create_seed()
seed2 = lt.ed25519_create_seed()
self.assertNotEqual(seed1, seed2)
Comment on lines +11 to +14

Copilot AI Apr 7, 2026

Copy link

Choose a reason for hiding this comment

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

This test is probabilistic: two consecutive random seeds can (in theory) be equal, which can introduce an extremely rare but real CI flake. Consider removing the uniqueness assertion, or replacing it with a non-probabilistic property test (e.g., only assert length/type here, and keep determinism tests focused on create_keypair with a fixed input).

Copilot uses AI. Check for mistakes.


class Ed25519CreateKeypairTest(unittest.TestCase):
def test_keypair_sizes(self) -> None:
seed = lt.ed25519_create_seed()
pk, sk = lt.ed25519_create_keypair(seed)
self.assertEqual(len(pk), 32)
self.assertEqual(len(sk), 64)

def test_deterministic(self) -> None:
seed = lt.ed25519_create_seed()
pk1, sk1 = lt.ed25519_create_keypair(seed)
pk2, sk2 = lt.ed25519_create_keypair(seed)
self.assertEqual(pk1, pk2)
self.assertEqual(sk1, sk2)

def test_different_seeds_produce_different_keys(self) -> None:
pk1, _ = lt.ed25519_create_keypair(lt.ed25519_create_seed())
pk2, _ = lt.ed25519_create_keypair(lt.ed25519_create_seed())
self.assertNotEqual(pk1, pk2)

def test_invalid_seed_length(self) -> None:
with self.assertRaises(Exception):

Copilot AI Apr 7, 2026

Copy link

Choose a reason for hiding this comment

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

assertRaises(Exception) is very broad and can mask unexpected failures (e.g., crashes in the binding vs. a deliberate argument validation error). Prefer asserting a more specific exception type that the bindings are expected to raise for invalid input (once standardized), so tests validate API behavior rather than just “something failed.”

Suggested change
with self.assertRaises(Exception):
with self.assertRaises(ValueError):

Copilot uses AI. Check for mistakes.
lt.ed25519_create_keypair(b"\x00" * 16)


class Ed25519SignVerifyTest(unittest.TestCase):
def setUp(self) -> None:
seed = lt.ed25519_create_seed()
self.pk, self.sk = lt.ed25519_create_keypair(seed)

def test_sign_and_verify(self) -> None:
msg = b"hello world"
sig = lt.ed25519_sign(msg, self.pk, self.sk)
self.assertEqual(len(sig), 64)
self.assertTrue(lt.ed25519_verify(sig, msg, self.pk))

def test_verify_wrong_message(self) -> None:
sig = lt.ed25519_sign(b"hello", self.pk, self.sk)
self.assertFalse(lt.ed25519_verify(sig, b"world", self.pk))

def test_verify_wrong_key(self) -> None:
msg = b"hello"
sig = lt.ed25519_sign(msg, self.pk, self.sk)
other_pk, _ = lt.ed25519_create_keypair(lt.ed25519_create_seed())
self.assertFalse(lt.ed25519_verify(sig, msg, other_pk))

def test_empty_message(self) -> None:
sig = lt.ed25519_sign(b"", self.pk, self.sk)
self.assertTrue(lt.ed25519_verify(sig, b"", self.pk))

def test_sign_invalid_pk_length(self) -> None:
with self.assertRaises(Exception):
lt.ed25519_sign(b"msg", b"\x00" * 16, self.sk)

def test_sign_invalid_sk_length(self) -> None:
with self.assertRaises(Exception):
lt.ed25519_sign(b"msg", self.pk, b"\x00" * 16)

def test_verify_invalid_sig_length(self) -> None:
with self.assertRaises(Exception):
lt.ed25519_verify(b"\x00" * 32, b"msg", self.pk)

def test_verify_invalid_pk_length(self) -> None:
sig = lt.ed25519_sign(b"msg", self.pk, self.sk)
with self.assertRaises(Exception):
lt.ed25519_verify(sig, b"msg", b"\x00" * 16)


class Ed25519AddScalarTest(unittest.TestCase):
def setUp(self) -> None:
seed = lt.ed25519_create_seed()
self.pk, self.sk = lt.ed25519_create_keypair(seed)
self.scalar = b"\x01" + b"\x00" * 31

def test_add_scalar_public(self) -> None:
result = lt.ed25519_add_scalar_public(self.pk, self.scalar)
self.assertEqual(len(result), 32)
self.assertNotEqual(result, self.pk)

def test_add_scalar_secret(self) -> None:
result = lt.ed25519_add_scalar_secret(self.sk, self.scalar)
self.assertEqual(len(result), 64)
self.assertNotEqual(result, self.sk)

def test_derived_keypair_signs_and_verifies(self) -> None:
derived_pk = lt.ed25519_add_scalar_public(self.pk, self.scalar)
derived_sk = lt.ed25519_add_scalar_secret(self.sk, self.scalar)
msg = b"test message"
sig = lt.ed25519_sign(msg, derived_pk, derived_sk)
self.assertTrue(lt.ed25519_verify(sig, msg, derived_pk))

def test_invalid_pk_length(self) -> None:
with self.assertRaises(Exception):
lt.ed25519_add_scalar_public(b"\x00" * 16, self.scalar)

def test_invalid_scalar_length(self) -> None:
with self.assertRaises(Exception):
lt.ed25519_add_scalar_public(self.pk, b"\x00" * 16)

def test_invalid_sk_length(self) -> None:
with self.assertRaises(Exception):
lt.ed25519_add_scalar_secret(b"\x00" * 16, self.scalar)


class Ed25519KeyExchangeTest(unittest.TestCase):
def test_shared_secret(self) -> None:
seed1 = lt.ed25519_create_seed()
pk1, sk1 = lt.ed25519_create_keypair(seed1)
seed2 = lt.ed25519_create_seed()
pk2, sk2 = lt.ed25519_create_keypair(seed2)

shared1 = lt.ed25519_key_exchange(pk2, sk1)
shared2 = lt.ed25519_key_exchange(pk1, sk2)

self.assertEqual(len(shared1), 32)
self.assertEqual(shared1, shared2)

def test_invalid_pk_length(self) -> None:
_, sk = lt.ed25519_create_keypair(lt.ed25519_create_seed())
with self.assertRaises(Exception):
lt.ed25519_key_exchange(b"\x00" * 16, sk)

def test_invalid_sk_length(self) -> None:
pk, _ = lt.ed25519_create_keypair(lt.ed25519_create_seed())
with self.assertRaises(Exception):
lt.ed25519_key_exchange(pk, b"\x00" * 16)
Loading