diff --git a/CHANGELOG.md b/CHANGELOG.md index 9859da9a..f93be6de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -108,6 +108,19 @@ ### Identity * Fixed crash conditions when identities were missing from known destinations. +* Fixed X25519 seeded-key derivation to clamp the scalar per RFC 7748 + (`X25519PrivateKey::from_private_bytes`): the raw Curve25519 ladder left + clamping to the caller, so identities derived from an unclamped seed + (e.g. `SHA-256(name)` used directly as the private scalar) produced public + keys and Diffie-Hellman secrets that disagreed with the Python reference + implementation, which clamps inside X25519. Clamping is idempotent, so + generated and file-loaded keys are unaffected. + +### Tests + +* Added a `test_crypto` interop vector pinning seeded X25519 public-key + derivation, Diffie-Hellman, and `Identity::load_private_key` agreement + with the Python reference. * Fixed identity recall from cached announces. * Added gating for updated identity recall behavior. diff --git a/src/microReticulum/Cryptography/X25519.h b/src/microReticulum/Cryptography/X25519.h index 738908fa..6c209764 100644 --- a/src/microReticulum/Cryptography/X25519.h +++ b/src/microReticulum/Cryptography/X25519.h @@ -79,6 +79,27 @@ namespace RNS { namespace Cryptography { using Ptr = std::shared_ptr; + private: + /* + RFC 7748 X25519 scalar clamping: clear the three low bits, clear the + high bit, and set the second-highest bit of the scalar. + + The raw Curve25519::eval ladder leaves clamping to the caller (as + Curve25519::dh1 does for generated keys), but the Python reference + implementation clamps *inside* X25519 (the cryptography package + performs the clamp on every operation). Seeds loaded via + from_private_bytes must therefore be clamped here, or public-key + derivation and Diffie-Hellman shared secrets disagree with Python for + any seed that is not already clamped — e.g. deterministic identities + derived from "SHA-256(name) used directly as the private scalar". + Clamping is idempotent, so already-clamped keys (generated or loaded + from Python-written files) are unaffected. + */ + static void clamp(uint8_t scalar[32]) { + scalar[0] &= 0xF8; + scalar[31] = (scalar[31] & 0x7F) | 0x40; + } + public: /* X25519PrivateKey(const Bytes& a) { @@ -87,8 +108,11 @@ namespace RNS { namespace Cryptography { */ X25519PrivateKey(const Bytes& privateKey) { if (privateKey) { - // use specified private key - _privateKey = privateKey; + // Use the specified private key. Assign from the raw chunk so + // the buffer is exclusive (a plain Bytes copy shares its data + // under COW builds), then clamp in place. + _privateKey.assign(privateKey.data(), privateKey.size()); + clamp(_privateKey.writable(_privateKey.size())); // similar to derive public key from private key // second param "f" is secret //eval(uint8_t result[32], const uint8_t s[32], const uint8_t x[32]) diff --git a/test/test_crypto/test_crypto.cpp b/test/test_crypto/test_crypto.cpp index b3f0b668..9cb37ae1 100644 --- a/test/test_crypto/test_crypto.cpp +++ b/test/test_crypto/test_crypto.cpp @@ -244,6 +244,69 @@ void testRebroadcastRatchetAnnounceValidate() { TEST_ASSERT_TRUE(RNS::Identity::validate_announce(packet)); } +/* +Seeded X25519 keys must agree with the Python reference implementation +(Identity.load_private_key with a raw seed → cryptography clamps inside +X25519). Vectors generated by the Python reference: + + seed = SHA-256("microReticulum X25519 clamp interop vector") + pub = X25519PrivateKey.from_private_bytes(seed).public_key().public_bytes_raw() + shared = priv.exchange(X25519PublicKey.from_public_bytes(peer_pub)) +*/ +void testX25519SeededKeyClamping() { + const uint8_t seed_arr[] = { + 0x88, 0xae, 0x11, 0x8b, 0xf0, 0x3b, 0xb8, 0x5a, + 0x39, 0x7e, 0x04, 0x42, 0xc4, 0x77, 0x21, 0x26, + 0x89, 0x3b, 0x76, 0x64, 0xc1, 0xae, 0xa3, 0xbd, + 0xb6, 0x8a, 0x44, 0x53, 0x3e, 0x79, 0xed, 0xfd + }; + const uint8_t expected_pub_arr[] = { + 0x43, 0x61, 0x81, 0xc1, 0xbf, 0xcf, 0x7a, 0x3c, + 0xcb, 0x0a, 0xa7, 0x10, 0x21, 0x4c, 0x8a, 0x57, + 0x23, 0x8c, 0x35, 0xd7, 0x8a, 0x9a, 0xc8, 0x3b, + 0xeb, 0x05, 0x12, 0x1d, 0xe4, 0xc9, 0xc8, 0x1d + }; + const uint8_t peer_pub_arr[] = { + 0xaa, 0x70, 0x4c, 0xd9, 0x1e, 0x8f, 0xa7, 0x13, + 0x53, 0xd9, 0xa6, 0x4b, 0x1c, 0xcc, 0xf8, 0x67, + 0x0e, 0x0a, 0x26, 0x4d, 0xfb, 0x37, 0x52, 0xec, + 0x7b, 0x21, 0xc2, 0xaf, 0xa0, 0xab, 0x47, 0x5c + }; + const uint8_t expected_shared_arr[] = { + 0x22, 0x96, 0x0a, 0x2d, 0xbb, 0x10, 0xbc, 0x2e, + 0x67, 0x4d, 0x77, 0x04, 0x8f, 0x6d, 0xb9, 0x02, + 0x08, 0x10, 0x9f, 0xb5, 0x98, 0xcb, 0x14, 0xf8, + 0x81, 0x4e, 0x1e, 0x9d, 0x3c, 0x5a, 0x7b, 0x26 + }; + const RNS::Bytes seed(seed_arr, sizeof(seed_arr)); + const RNS::Bytes expected_pub(expected_pub_arr, sizeof(expected_pub_arr)); + const RNS::Bytes peer_pub(peer_pub_arr, sizeof(peer_pub_arr)); + const RNS::Bytes expected_shared(expected_shared_arr, sizeof(expected_shared_arr)); + + // Public-key derivation from an unclamped seed must match Python. + auto priv = RNS::Cryptography::X25519PrivateKey::from_private_bytes(seed); + TEST_ASSERT_EQUAL_STRING( + expected_pub.toHex().c_str(), priv->public_key()->public_bytes().toHex().c_str()); + + // Diffie-Hellman with a Python-generated peer key must match Python. + RNS::Bytes shared = priv->exchange(peer_pub); + TEST_ASSERT_EQUAL_STRING( + expected_shared.toHex().c_str(), shared.toHex().c_str()); + + // The same seed loaded through Identity::load_private_key (the path + // deterministic identities take) derives the same key material. + RNS::Identity identity(false); + TEST_ASSERT_TRUE(identity.load_private_key(seed + seed)); + TEST_ASSERT_EQUAL_STRING( + expected_pub.toHex().c_str(), identity.get_public_key().left(32).toHex().c_str()); + + // Idempotence: clamping an already-clamped scalar changes nothing. + auto clamped_again = RNS::Cryptography::X25519PrivateKey::from_private_bytes( + priv->private_bytes()); + TEST_ASSERT_EQUAL_STRING( + expected_pub.toHex().c_str(), clamped_again->public_key()->public_bytes().toHex().c_str()); +} + void setUp(void) { // set stuff up here before each test } @@ -265,6 +328,7 @@ int runUnityTests(void) { RUN_TEST(testRebroadcastAnnounceValidate); RUN_TEST(testDirectRatchetAnnounceValidate); RUN_TEST(testRebroadcastRatchetAnnounceValidate); + RUN_TEST(testX25519SeededKeyClamping); return UNITY_END(); }