From 92cea3d9f71d79b8f62e4451502fde07d4354094 Mon Sep 17 00:00:00 2001 From: Denis Angell Date: Tue, 1 Jul 2025 09:44:43 +0200 Subject: [PATCH 1/6] feature p256 --- include/xrpl/protocol/Indexes.h | 4 + include/xrpl/protocol/KeyType.h | 7 + include/xrpl/protocol/PublicKey.h | 9 +- include/xrpl/protocol/SecretKey.h | 3 + include/xrpl/protocol/detail/features.macro | 1 + .../xrpl/protocol/detail/ledger_entries.macro | 12 + include/xrpl/protocol/detail/sfields.macro | 9 + .../xrpl/protocol/detail/transactions.macro | 5 + include/xrpl/protocol/digest.h | 10 + src/libxrpl/protocol/Indexes.cpp | 14 ++ src/libxrpl/protocol/InnerObjectFormats.cpp | 19 ++ src/libxrpl/protocol/PublicKey.cpp | 176 +++++++++++++- src/libxrpl/protocol/STTx.cpp | 51 +++- src/libxrpl/protocol/SecretKey.cpp | 218 ++++++++++++++++++ src/libxrpl/protocol/TxFormats.cpp | 1 + src/test/jtx/impl/Env.cpp | 1 + src/test/jtx/impl/utility.cpp | 51 +++- src/test/protocol/PassKey_test.cpp | 109 +++++++++ src/xrpld/app/tx/detail/InvariantCheck.cpp | 1 + src/xrpld/app/tx/detail/SetPasskeyList.cpp | 79 +++++++ src/xrpld/app/tx/detail/SetPasskeyList.h | 53 +++++ src/xrpld/app/tx/detail/Transactor.cpp | 29 ++- src/xrpld/app/tx/detail/Transactor.h | 1 + src/xrpld/app/tx/detail/applySteps.cpp | 1 + 24 files changed, 843 insertions(+), 21 deletions(-) create mode 100644 src/test/protocol/PassKey_test.cpp create mode 100644 src/xrpld/app/tx/detail/SetPasskeyList.cpp create mode 100644 src/xrpld/app/tx/detail/SetPasskeyList.h diff --git a/include/xrpl/protocol/Indexes.h b/include/xrpl/protocol/Indexes.h index 57c8727ae6c..7edb00ec704 100644 --- a/include/xrpl/protocol/Indexes.h +++ b/include/xrpl/protocol/Indexes.h @@ -348,6 +348,10 @@ permissionedDomain(AccountID const& account, std::uint32_t seq) noexcept; Keylet permissionedDomain(uint256 const& domainID) noexcept; + +Keylet +passkeyList(AccountID const& account) noexcept; + } // namespace keylet // Everything below is deprecated and should be removed in favor of keylets: diff --git a/include/xrpl/protocol/KeyType.h b/include/xrpl/protocol/KeyType.h index 810e3e6fdf4..e763b66b966 100644 --- a/include/xrpl/protocol/KeyType.h +++ b/include/xrpl/protocol/KeyType.h @@ -28,6 +28,7 @@ namespace ripple { enum class KeyType { secp256k1 = 0, ed25519 = 1, + p256 = 2 }; inline std::optional @@ -39,6 +40,9 @@ keyTypeFromString(std::string const& s) if (s == "ed25519") return KeyType::ed25519; + if (s == "p256") + return KeyType::p256; + return {}; } @@ -51,6 +55,9 @@ to_string(KeyType type) if (type == KeyType::ed25519) return "ed25519"; + if (type == KeyType::p256) + return "p256"; + return "INVALID"; } diff --git a/include/xrpl/protocol/PublicKey.h b/include/xrpl/protocol/PublicKey.h index c68656877c9..33aade49f7a 100644 --- a/include/xrpl/protocol/PublicKey.h +++ b/include/xrpl/protocol/PublicKey.h @@ -45,10 +45,11 @@ namespace ripple { information needed to determine the cryptosystem parameters used is stored inside the key. - As of this writing two systems are supported: + As of this writing three systems are supported: secp256k1 ed25519 + p256 secp256k1 public keys consist of a 33 byte compressed public key, with the lead byte equal @@ -61,10 +62,8 @@ namespace ripple { class PublicKey { protected: - // All the constructed public keys are valid, non-empty and contain 33 - // bytes of data. - static constexpr std::size_t size_ = 33; - std::uint8_t buf_[size_]; // should be large enough + std::uint8_t buf_[65]; + std::size_t size_ = 0; public: using const_iterator = std::uint8_t const*; diff --git a/include/xrpl/protocol/SecretKey.h b/include/xrpl/protocol/SecretKey.h index 1a13cd17c98..e0a8fa149e0 100644 --- a/include/xrpl/protocol/SecretKey.h +++ b/include/xrpl/protocol/SecretKey.h @@ -127,6 +127,9 @@ toBase58(TokenType type, SecretKey const& sk) SecretKey randomSecretKey(); +// SecretKey +// randomSecretKey(KeyType type); + /** Generate a new secret key deterministically. */ SecretKey generateSecretKey(KeyType type, Seed const& seed); diff --git a/include/xrpl/protocol/detail/features.macro b/include/xrpl/protocol/detail/features.macro index 3584d8f8cf6..2bfd2da5ff8 100644 --- a/include/xrpl/protocol/detail/features.macro +++ b/include/xrpl/protocol/detail/features.macro @@ -35,6 +35,7 @@ // If you add an amendment here, then do not forget to increment `numFeatures` // in include/xrpl/protocol/Feature.h. +XRPL_FEATURE(Passkey, Supported::yes, VoteBehavior::DefaultNo) XRPL_FEATURE(TokenEscrow, Supported::yes, VoteBehavior::DefaultNo) XRPL_FIX (EnforceNFTokenTrustlineV2, Supported::yes, VoteBehavior::DefaultNo) XRPL_FIX (AMMv1_3, Supported::yes, VoteBehavior::DefaultNo) diff --git a/include/xrpl/protocol/detail/ledger_entries.macro b/include/xrpl/protocol/detail/ledger_entries.macro index 46c6e60db35..6e51ad33616 100644 --- a/include/xrpl/protocol/detail/ledger_entries.macro +++ b/include/xrpl/protocol/detail/ledger_entries.macro @@ -504,6 +504,18 @@ LEDGER_ENTRY(ltVAULT, 0x0084, Vault, vault, ({ // no PermissionedDomainID ever (use MPTIssuance.sfDomainID) })) +/** A ledger object representing a passkey list. + + \sa keylet::passkeyList + */ +LEDGER_ENTRY(ltPASSKEY_LIST, 0x0085, PasskeyList, passkey_list, ({ + {sfPreviousTxnID, soeREQUIRED}, + {sfPreviousTxnLgrSeq, soeREQUIRED}, + {sfOwnerNode, soeREQUIRED}, + {sfOwner, soeREQUIRED}, + {sfPasskeys, soeREQUIRED}, +})) + #undef EXPAND #undef LEDGER_ENTRY_DUPLICATE diff --git a/include/xrpl/protocol/detail/sfields.macro b/include/xrpl/protocol/detail/sfields.macro index 537fcae479b..47b96b4b653 100644 --- a/include/xrpl/protocol/detail/sfields.macro +++ b/include/xrpl/protocol/detail/sfields.macro @@ -114,6 +114,8 @@ TYPED_SFIELD(sfVoteWeight, UINT32, 48) TYPED_SFIELD(sfFirstNFTokenSequence, UINT32, 50) TYPED_SFIELD(sfOracleDocumentID, UINT32, 51) TYPED_SFIELD(sfPermissionValue, UINT32, 52) +// TYPED_SFIELD(sfAlgorithm, UINT32, 53) +TYPED_SFIELD(sfSignCount, UINT32, 54) // 64-bit integers (common) TYPED_SFIELD(sfIndexNext, UINT64, 1) @@ -275,6 +277,10 @@ TYPED_SFIELD(sfAssetClass, VL, 28) TYPED_SFIELD(sfProvider, VL, 29) TYPED_SFIELD(sfMPTokenMetadata, VL, 30) TYPED_SFIELD(sfCredentialType, VL, 31) +TYPED_SFIELD(sfPasskeyID, VL, 32) +TYPED_SFIELD(sfAuthenticatorData, VL, 33) +TYPED_SFIELD(sfClientDataJSON, VL, 34); + // account (common) TYPED_SFIELD(sfAccount, ACCOUNT, 1) @@ -362,6 +368,8 @@ UNTYPED_SFIELD(sfCredential, OBJECT, 33) UNTYPED_SFIELD(sfRawTransaction, OBJECT, 34) UNTYPED_SFIELD(sfBatchSigner, OBJECT, 35) UNTYPED_SFIELD(sfBook, OBJECT, 36) +UNTYPED_SFIELD(sfPasskeySignature, OBJECT, 37, SField::sMD_Default, SField::notSigning) +UNTYPED_SFIELD(sfPasskey, OBJECT, 38) // array of objects (common) // ARRAY/1 is reserved for end of array @@ -396,3 +404,4 @@ UNTYPED_SFIELD(sfAcceptedCredentials, ARRAY, 28) UNTYPED_SFIELD(sfPermissions, ARRAY, 29) UNTYPED_SFIELD(sfRawTransactions, ARRAY, 30) UNTYPED_SFIELD(sfBatchSigners, ARRAY, 31, SField::sMD_Default, SField::notSigning) +UNTYPED_SFIELD(sfPasskeys, ARRAY, 32) diff --git a/include/xrpl/protocol/detail/transactions.macro b/include/xrpl/protocol/detail/transactions.macro index 1d59e718506..015a631930a 100644 --- a/include/xrpl/protocol/detail/transactions.macro +++ b/include/xrpl/protocol/detail/transactions.macro @@ -522,6 +522,11 @@ TRANSACTION(ttBATCH, 71, Batch, Delegation::notDelegatable, ({ {sfBatchSigners, soeOPTIONAL}, })) +/** This transaction type passkey list. */ +TRANSACTION(ttPASSKEY_LIST_SET, 72, PasskeyListSet, Delegation::delegatable, ({ + {sfPasskeys, soeREQUIRED}, +})) + /** This system-generated transaction type is used to update the status of the various amendments. For details, see: https://xrpl.org/amendments.html diff --git a/include/xrpl/protocol/digest.h b/include/xrpl/protocol/digest.h index efec616a0c8..e173ccdb318 100644 --- a/include/xrpl/protocol/digest.h +++ b/include/xrpl/protocol/digest.h @@ -246,6 +246,16 @@ sha512Half_s(Args const&... args) return static_cast(h); } +template +sha256_hasher::result_type +sha256(Args const&... args) +{ + ripple::sha256_hasher h; + using beast::hash_append; + hash_append(h, args...); + return static_cast(h); +} + } // namespace ripple #endif diff --git a/src/libxrpl/protocol/Indexes.cpp b/src/libxrpl/protocol/Indexes.cpp index 486945992ab..9a715d56ec2 100644 --- a/src/libxrpl/protocol/Indexes.cpp +++ b/src/libxrpl/protocol/Indexes.cpp @@ -96,6 +96,7 @@ enum class LedgerNameSpace : std::uint16_t { PERMISSIONED_DOMAIN = 'm', DELEGATE = 'E', VAULT = 'V', + PASSKEY_LIST = 'l', // No longer used or supported. Left here to reserve the space // to avoid accidental reuse. @@ -580,6 +581,19 @@ permissionedDomain(uint256 const& domainID) noexcept return {ltPERMISSIONED_DOMAIN, domainID}; } +static Keylet +passkeyList(AccountID const& account, std::uint32_t page) noexcept +{ + return { + ltPASSKEY_LIST, indexHash(LedgerNameSpace::PASSKEY_LIST, account, page)}; +} + +Keylet +passkeyList(AccountID const& account) noexcept +{ + return passkeyList(account, 0); +} + } // namespace keylet } // namespace ripple diff --git a/src/libxrpl/protocol/InnerObjectFormats.cpp b/src/libxrpl/protocol/InnerObjectFormats.cpp index 2de5e6624ef..1b30c1a7678 100644 --- a/src/libxrpl/protocol/InnerObjectFormats.cpp +++ b/src/libxrpl/protocol/InnerObjectFormats.cpp @@ -172,6 +172,25 @@ InnerObjectFormats::InnerObjectFormats() {sfBookDirectory, soeREQUIRED}, {sfBookNode, soeREQUIRED}, }); + + add(sfPasskey.jsonName, + sfPasskey.getCode(), + { + {sfPasskeyID, soeREQUIRED}, + {sfPublicKey, soeREQUIRED}, + // {sfSignCount, soeREQUIRED}, + // {sfAlgorithm, soeREQUIRED}, + }); + + add(sfPasskeySignature.jsonName, + sfPasskeySignature.getCode(), + { + {sfPasskeyID, soeREQUIRED}, + {sfAuthenticatorData, soeREQUIRED}, + {sfClientDataJSON, soeREQUIRED}, + {sfSignature, soeREQUIRED}, + // {sfAlgorithm, soeREQUIRED}, + }); } InnerObjectFormats const& diff --git a/src/libxrpl/protocol/PublicKey.cpp b/src/libxrpl/protocol/PublicKey.cpp index cdf646e0f86..91cbf24c251 100644 --- a/src/libxrpl/protocol/PublicKey.cpp +++ b/src/libxrpl/protocol/PublicKey.cpp @@ -31,6 +31,11 @@ #include #include +#include +#include +#include +#include + #include #include @@ -198,12 +203,14 @@ PublicKey::PublicKey(Slice const& slice) if (!publicKeyType(slice)) LogicError("PublicKey::PublicKey invalid type"); + size_ = slice.size(); std::memcpy(buf_, slice.data(), size_); } -PublicKey::PublicKey(PublicKey const& other) +PublicKey::PublicKey(PublicKey const& other) : size_(other.size_) { - std::memcpy(buf_, other.buf_, size_); + if (size_) + std::memcpy(buf_, other.buf_, size_); } PublicKey& @@ -211,7 +218,9 @@ PublicKey::operator=(PublicKey const& other) { if (this != &other) { - std::memcpy(buf_, other.buf_, size_); + size_ = other.size_; + if (size_) + std::memcpy(buf_, other.buf_, size_); } return *this; @@ -231,6 +240,11 @@ publicKeyType(Slice const& slice) return KeyType::secp256k1; } + if (slice.size() == 65) + { + return KeyType::p256; + } + return std::nullopt; } @@ -284,6 +298,131 @@ verifyDigest( &pubkey_imp) == 1; } +struct ECDSASignature +{ + std::array r; + std::array s; +}; + +std::optional +parseDERSignature(Slice const& derSig) noexcept +{ + if (derSig.size() < 8) + return std::nullopt; + + uint8_t const* data = derSig.data(); + size_t offset = 0; + + // Check sequence tag + if (data[offset++] != 0x30) + return std::nullopt; + + // Skip total length + offset++; + + // Parse R + if (data[offset++] != 0x02) + return std::nullopt; + uint8_t rLen = data[offset++]; + if (offset + rLen >= derSig.size()) + return std::nullopt; + + ECDSASignature result{}; + + // Copy R, handling leading zeros + int rStart = (rLen > 32 && data[offset] == 0x00) ? 1 : 0; + int rCopyLen = std::min(32, static_cast(rLen - rStart)); + std::memcpy( + result.r.data() + (32 - rCopyLen), data + offset + rStart, rCopyLen); + offset += rLen; + + // Parse S + if (data[offset++] != 0x02) + return std::nullopt; + uint8_t sLen = data[offset++]; + if (offset + sLen > derSig.size()) + return std::nullopt; + + // Copy S, handling leading zeros + int sStart = (sLen > 32 && data[offset] == 0x00) ? 1 : 0; + int sCopyLen = std::min(32, static_cast(sLen - sStart)); + std::memcpy( + result.s.data() + (32 - sCopyLen), data + offset + sStart, sCopyLen); + + return result; +} + +bool +verifyP256ECDSA( + uint8_t const* hash, + size_t hashLen, + uint8_t const* r, + size_t rLen, + uint8_t const* s, + size_t sLen, + uint8_t const* x, + size_t xLen, + uint8_t const* y, + size_t yLen) noexcept +{ + if (hashLen != 32 || rLen > 32 || sLen > 32 || xLen > 32 || yLen > 32) + return false; + + // Create curve object + EC_GROUP* group = EC_GROUP_new_by_curve_name(NID_X9_62_prime256v1); + if (!group) + return false; + + // Set group to EC_KEY + EC_KEY* key = EC_KEY_new(); + if (!key) + { + EC_GROUP_free(group); + return false; + } + EC_KEY_set_group(key, group); + + // Restore public key point from coordinates + EC_POINT* point = EC_POINT_new(group); + BIGNUM* bn_x = BN_bin2bn(x, xLen, nullptr); + BIGNUM* bn_y = BN_bin2bn(y, yLen, nullptr); + + bool success = false; + if (point && bn_x && bn_y && + EC_POINT_set_affine_coordinates_GFp( + group, point, bn_x, bn_y, nullptr) == 1 && + EC_KEY_set_public_key(key, point) == 1) + { + // Pack r/s into ECDSA_SIG structure + ECDSA_SIG* sig = ECDSA_SIG_new(); + BIGNUM* bn_r = BN_bin2bn(r, rLen, nullptr); + BIGNUM* bn_s = BN_bin2bn(s, sLen, nullptr); + + if (sig && bn_r && bn_s && ECDSA_SIG_set0(sig, bn_r, bn_s) == 1) + { + // Verify (ECDSA_SIG_set0 takes ownership of bn_r, bn_s) + int verified = ECDSA_do_verify(hash, hashLen, sig, key); + success = (verified == 1); + bn_r = nullptr; // ownership transferred + bn_s = nullptr; // ownership transferred + } + + ECDSA_SIG_free(sig); + if (bn_r) + BN_free(bn_r); + if (bn_s) + BN_free(bn_s); + } + + EC_POINT_free(point); + BN_free(bn_x); + BN_free(bn_y); + EC_KEY_free(key); + EC_GROUP_free(group); + + return success; +} + bool verify( PublicKey const& publicKey, @@ -311,6 +450,37 @@ verify( m.data(), m.size(), publicKey.data() + 1, sig.data()) == 0; } + else if (*type == KeyType::p256) + { + // Parse DER signature to extract r and s values + auto parsedSig = parseDERSignature(sig); + if (!parsedSig) + return false; + + // Hash the message with SHA-256 (P-256 uses ECDSA-SHA256) + auto hash = sha256(m); + + // We internally prefix P-256 keys with a prefix byte + // so strip it to get the raw public key coordinates + if (publicKey.size() != 65) // 1 prefix + 32-byte x + 32-byte y + return false; + + // Extract x and y coordinates (skip prefix byte) + uint8_t const* x_coord = publicKey.data() + 1; + uint8_t const* y_coord = publicKey.data() + 33; + + return verifyP256ECDSA( + hash.data(), + hash.size(), + parsedSig->r.data(), + 32, // r component + parsedSig->s.data(), + 32, // s component + x_coord, + 32, // x coordinate + y_coord, + 32); // y coordinate + } } return false; } diff --git a/src/libxrpl/protocol/STTx.cpp b/src/libxrpl/protocol/STTx.cpp index 615012dba4d..252f96d941f 100644 --- a/src/libxrpl/protocol/STTx.cpp +++ b/src/libxrpl/protocol/STTx.cpp @@ -17,6 +17,7 @@ */ //============================================================================== +#include #include #include #include @@ -187,10 +188,32 @@ STTx::getMentionedAccounts() const static Blob getSigningData(STTx const& that) { - Serializer s; - s.add32(HashPrefix::txSign); - that.addWithoutSigningFields(s); - return s.getData(); + std::optional const keyType = publicKeyType(makeSlice(that.getFieldVL(sfSigningPubKey))); + if (keyType && (keyType == KeyType::p256)) + { + auto const& passKeySignature = static_cast(that.peekAtField(sfPasskeySignature)); + auto const authenticatorData = passKeySignature.getFieldVL(sfAuthenticatorData); + auto const clientDataJSON = passKeySignature.getFieldVL(sfClientDataJSON); + auto const clientDataHash = sha256(makeSlice(clientDataJSON)); + Buffer concatenatedData(authenticatorData.size() + clientDataHash.size()); + std::memcpy( + concatenatedData.data(), + authenticatorData.data(), + authenticatorData.size()); + std::memcpy( + concatenatedData.data() + authenticatorData.size(), + clientDataHash.data(), + clientDataHash.size()); + return Blob{concatenatedData.begin(), concatenatedData.end()}; + } + else + { + Serializer s; + s.add32(HashPrefix::txSign); + that.addWithoutSigningFields(s); + return s.peekData(); + } + return {}; } uint256 @@ -380,6 +403,24 @@ STTx::getMetaSQL( getFieldU32(sfSequence) % inLedger % status % rTxn % escapedMetaData); } +Blob +getSignature(STObject const& signer) +{ + auto const spk = signer.getFieldVL(sfSigningPubKey); + std::optional const keyType = publicKeyType(makeSlice(spk)); + if (keyType && (keyType == KeyType::p256)) + { + auto const& passKeySignature = + static_cast(signer.peekAtField(sfPasskeySignature)); + return passKeySignature.getFieldVL(sfSignature); + } + else + { + // Handle ed25519 signing + return signer.getFieldVL(sfTxnSignature); + } +} + static Expected singleSignHelper( STObject const& signer, @@ -398,7 +439,7 @@ singleSignHelper( auto const spk = signer.getFieldVL(sfSigningPubKey); if (publicKeyType(makeSlice(spk))) { - Blob const signature = signer.getFieldVL(sfTxnSignature); + Blob const signature = getSignature(signer); validSig = verify( PublicKey(makeSlice(spk)), data, diff --git a/src/libxrpl/protocol/SecretKey.cpp b/src/libxrpl/protocol/SecretKey.cpp index 06b1061c1e8..a9bf729b3e7 100644 --- a/src/libxrpl/protocol/SecretKey.cpp +++ b/src/libxrpl/protocol/SecretKey.cpp @@ -46,6 +46,11 @@ #include #include +#include +#include +#include +#include + namespace ripple { SecretKey::~SecretKey() @@ -290,6 +295,88 @@ sign(PublicKey const& pk, SecretKey const& sk, Slice const& m) return Buffer{sig, len}; } + case KeyType::p256: { + // Hash the message with SHA-256 (P-256 uses ECDSA-SHA256) + auto digest = sha256(m); + + // Create curve object + EC_GROUP* group = EC_GROUP_new_by_curve_name(NID_X9_62_prime256v1); + if (!group) + LogicError("sign: EC_GROUP_new_by_curve_name failed"); + + // Create EC_KEY and set the group + EC_KEY* key = EC_KEY_new(); + if (!key) + { + EC_GROUP_free(group); + LogicError("sign: EC_KEY_new failed"); + } + + if (EC_KEY_set_group(key, group) != 1) + { + EC_KEY_free(key); + EC_GROUP_free(group); + LogicError("sign: EC_KEY_set_group failed"); + } + + // Convert secret key to BIGNUM and set as private key + BIGNUM* priv_key = BN_bin2bn( + reinterpret_cast(sk.data()), + sk.size(), + nullptr); + + if (!priv_key || EC_KEY_set_private_key(key, priv_key) != 1) + { + BN_free(priv_key); + EC_KEY_free(key); + EC_GROUP_free(group); + LogicError("sign: failed to set private key"); + } + + // Sign the digest + ECDSA_SIG* sig_obj = ECDSA_do_sign( + reinterpret_cast(digest.data()), + digest.size(), + key); + + if (!sig_obj) + { + BN_free(priv_key); + EC_KEY_free(key); + EC_GROUP_free(group); + LogicError("sign: ECDSA_do_sign failed"); + } + + // Convert signature to DER format + unsigned char sig[72]; + int len = i2d_ECDSA_SIG(sig_obj, nullptr); + if (len <= 0 || len > 72) + { + ECDSA_SIG_free(sig_obj); + BN_free(priv_key); + EC_KEY_free(key); + EC_GROUP_free(group); + LogicError("sign: i2d_ECDSA_SIG length check failed"); + } + + unsigned char* sig_ptr = sig; + if (i2d_ECDSA_SIG(sig_obj, &sig_ptr) != len) + { + ECDSA_SIG_free(sig_obj); + BN_free(priv_key); + EC_KEY_free(key); + EC_GROUP_free(group); + LogicError("sign: i2d_ECDSA_SIG serialization failed"); + } + + // Cleanup + ECDSA_SIG_free(sig_obj); + BN_free(priv_key); + EC_KEY_free(key); + EC_GROUP_free(group); + + return Buffer{sig, static_cast(len)}; + } default: LogicError("sign: invalid type"); } @@ -324,6 +411,14 @@ generateSecretKey(KeyType type, Seed const& seed) return sk; } + if (type == KeyType::p256) + { + auto key = detail::deriveDeterministicRootKey(seed); + SecretKey sk{Slice{key.data(), key.size()}}; + secure_erase(key.data(), key.size()); + return sk; + } + LogicError("generateSecretKey: unknown key type"); } @@ -360,6 +455,125 @@ derivePublicKey(KeyType type, SecretKey const& sk) ed25519_publickey(sk.data(), &buf[1]); return PublicKey(Slice{buf, sizeof(buf)}); } + case KeyType::p256: { + // Create curve object + EC_GROUP* group = EC_GROUP_new_by_curve_name(NID_X9_62_prime256v1); + if (!group) + LogicError("derivePublicKey: EC_GROUP_new_by_curve_name failed"); + + // Create EC_KEY and set the group + EC_KEY* key = EC_KEY_new(); + if (!key) + { + EC_GROUP_free(group); + LogicError("derivePublicKey: EC_KEY_new failed"); + } + + if (EC_KEY_set_group(key, group) != 1) + { + EC_KEY_free(key); + EC_GROUP_free(group); + LogicError("derivePublicKey: EC_KEY_set_group failed"); + } + + // Convert secret key to BIGNUM + BIGNUM* priv_key = BN_bin2bn( + reinterpret_cast(sk.data()), + sk.size(), + nullptr); + + if (!priv_key) + { + EC_KEY_free(key); + EC_GROUP_free(group); + LogicError("derivePublicKey: BN_bin2bn failed"); + } + + // Set the private key + if (EC_KEY_set_private_key(key, priv_key) != 1) + { + BN_free(priv_key); + EC_KEY_free(key); + EC_GROUP_free(group); + LogicError("derivePublicKey: EC_KEY_set_private_key failed"); + } + + // Generate the public key from the private key + EC_POINT* pub_key_point = EC_POINT_new(group); + if (!pub_key_point) + { + BN_free(priv_key); + EC_KEY_free(key); + EC_GROUP_free(group); + LogicError("derivePublicKey: EC_POINT_new failed"); + } + + if (EC_POINT_mul(group, pub_key_point, priv_key, nullptr, nullptr, nullptr) != 1) + { + EC_POINT_free(pub_key_point); + BN_free(priv_key); + EC_KEY_free(key); + EC_GROUP_free(group); + LogicError("derivePublicKey: EC_POINT_mul failed"); + } + + // Extract x and y coordinates + BIGNUM* x = BN_new(); + BIGNUM* y = BN_new(); + if (!x || !y || + EC_POINT_get_affine_coordinates_GFp(group, pub_key_point, x, y, nullptr) != 1) + { + BN_free(x); + BN_free(y); + EC_POINT_free(pub_key_point); + BN_free(priv_key); + EC_KEY_free(key); + EC_GROUP_free(group); + LogicError("derivePublicKey: EC_POINT_get_affine_coordinates_GFp failed"); + } + + // Convert coordinates to bytes + unsigned char buf[65]; // 1 prefix + 32-byte x + 32-byte y + buf[0] = 0xF6; // P-256 prefix byte (choose your own prefix) + + // Convert x coordinate to 32 bytes + int x_len = BN_bn2binpad(x, &buf[1], 32); + if (x_len != 32) + { + BN_free(x); + BN_free(y); + EC_POINT_free(pub_key_point); + BN_free(priv_key); + EC_KEY_free(key); + EC_GROUP_free(group); + LogicError("derivePublicKey: BN_bn2binpad failed for x coordinate"); + } + + // Convert y coordinate to 32 bytes + int y_len = BN_bn2binpad(y, &buf[33], 32); + if (y_len != 32) + { + BN_free(x); + BN_free(y); + EC_POINT_free(pub_key_point); + BN_free(priv_key); + EC_KEY_free(key); + EC_GROUP_free(group); + LogicError("derivePublicKey: BN_bn2binpad failed for y coordinate"); + } + + // Cleanup + BN_free(x); + BN_free(y); + EC_POINT_free(pub_key_point); + BN_free(priv_key); + EC_KEY_free(key); + EC_GROUP_free(group); + + return PublicKey{Slice{buf, sizeof(buf)}}; + + } + default: LogicError("derivePublicKey: bad key type"); }; @@ -374,6 +588,10 @@ generateKeyPair(KeyType type, Seed const& seed) detail::Generator g(seed); return g(0); } + case KeyType::p256: { + auto const sk = generateSecretKey(type, seed); + return {derivePublicKey(type, sk), sk}; + } default: case KeyType::ed25519: { auto const sk = generateSecretKey(type, seed); diff --git a/src/libxrpl/protocol/TxFormats.cpp b/src/libxrpl/protocol/TxFormats.cpp index 5edffeb6660..c92fd215d5d 100644 --- a/src/libxrpl/protocol/TxFormats.cpp +++ b/src/libxrpl/protocol/TxFormats.cpp @@ -47,6 +47,7 @@ TxFormats::TxFormats() {sfSigners, soeOPTIONAL}, // submit_multisigned {sfNetworkID, soeOPTIONAL}, {sfDelegate, soeOPTIONAL}, + {sfPasskeySignature, soeOPTIONAL}, }; #pragma push_macro("UNWRAP") diff --git a/src/test/jtx/impl/Env.cpp b/src/test/jtx/impl/Env.cpp index 7c17687eee9..619a1a0c9ac 100644 --- a/src/test/jtx/impl/Env.cpp +++ b/src/test/jtx/impl/Env.cpp @@ -379,6 +379,7 @@ Env::submit(JTx const& jt) { // Parsing failed or the JTx is // otherwise missing the stx field. + std::cout << "Env::submit: JTx is malformed or missing stx field.\n"; parsedResult.ter = ter_ = temMALFORMED; return Json::Value(); diff --git a/src/test/jtx/impl/utility.cpp b/src/test/jtx/impl/utility.cpp index afa7ee8f352..9babd90d638 100644 --- a/src/test/jtx/impl/utility.cpp +++ b/src/test/jtx/impl/utility.cpp @@ -46,12 +46,51 @@ parse(Json::Value const& jv) void sign(Json::Value& jv, Account const& account) { - jv[jss::SigningPubKey] = strHex(account.pk().slice()); - Serializer ss; - ss.add32(HashPrefix::txSign); - parse(jv).addWithoutSigningFields(ss); - auto const sig = ripple::sign(account.pk(), account.sk(), ss.slice()); - jv[jss::TxnSignature] = strHex(Slice{sig.data(), sig.size()}); + std::optional const keyType = publicKeyType(account.pk()); + if (keyType && (keyType == KeyType::p256)) + { + jv[jss::SigningPubKey] = strHex(account.pk().slice()); + Serializer ss; + ss.add32(HashPrefix::txSign); + parse(jv).addWithoutSigningFields(ss); + auto const hash256 = sha512Half(ss.slice()); + std::string clientDataJSON = R"({"type":"webauthn.get","challenge":")" + + strHex(hash256) + R"(","origin":"https://xrpl.org"})"; + Buffer authenticatorData(37); + Buffer credentialId(16); + Buffer clientDataBuffer(clientDataJSON.data(), clientDataJSON.size()); + auto const clientDataHash = sha256(Slice{clientDataBuffer.data(), clientDataBuffer.size()}); + Buffer concatenatedData( + authenticatorData.size() + clientDataHash.size()); + std::memcpy( + concatenatedData.data(), + authenticatorData.data(), + authenticatorData.size()); + std::memcpy( + concatenatedData.data() + authenticatorData.size(), + clientDataHash.data(), + clientDataHash.size()); + auto const sig = ripple::sign( + account.pk(), + account.sk(), + Slice(concatenatedData.data(), concatenatedData.size())); + jv[sfPasskeySignature][sfPasskeyID] = "DEADBEEF"; + jv[sfPasskeySignature][sfAuthenticatorData] = strHex(authenticatorData); + jv[sfPasskeySignature][sfClientDataJSON] = strHex(clientDataBuffer); + jv[sfPasskeySignature][sfSignature] = + strHex(Slice{sig.data(), sig.size()}); + // jv[sfPasskeySignature][sfAlgorithm] = -8; + // jv[jss::TxnSignature] = "00"; + } + else + { + jv[jss::SigningPubKey] = strHex(account.pk().slice()); + Serializer ss; + ss.add32(HashPrefix::txSign); + parse(jv).addWithoutSigningFields(ss); + auto const sig = ripple::sign(account.pk(), account.sk(), ss.slice()); + jv[jss::TxnSignature] = strHex(Slice{sig.data(), sig.size()}); + } } void diff --git a/src/test/protocol/PassKey_test.cpp b/src/test/protocol/PassKey_test.cpp new file mode 100644 index 00000000000..8a63c814862 --- /dev/null +++ b/src/test/protocol/PassKey_test.cpp @@ -0,0 +1,109 @@ +//------------------------------------------------------------------------------ +/* + This file is part of rippled: https://github.com/ripple/rippled + Copyright (c) 2023 XRPL-Labs. + + Permission to use, copy, modify, and/or distribute this software for any + purpose with or without fee is hereby granted, provided that the above + copyright notice and this permission notice appear in all copies. + + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + ANY SPECIAL , DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +*/ +//============================================================================== + +#include +#include +#include + +namespace ripple { +namespace test { +class PassKey_test : public beast::unit_test::suite +{ + + Json::Value + passkeyListSet(jtx::Account const& account) + { + Json::Value jv; + jv[sfAccount.jsonName] = account.human(); + jv[sfTransactionType.jsonName] = jss::PasskeyListSet; + jv[sfPasskeys] = Json::arrayValue; + jv[sfPasskeys][0u][sfPasskey][sfPasskeyID] = "DEADBEEF"; + jv[sfPasskeys][0u][sfPasskey][sfPublicKey] = strHex(account.pk()); + return jv; + } + + void + testP256(FeatureBitset features) + { + using namespace test::jtx; + + testcase("p256"); + + Env env{*this, envconfig(), features}; + Account const alice{"alice", KeyType::p256}; + Account const bob{"bob"}; + env.fund(XRP(1000), alice, bob); + env.close(); + + env(pay(alice, bob, XRP(100))); + env.close(); + + Json::Value params; + params[jss::ledger_index] = env.current()->seq() - 1; + params[jss::transactions] = true; + params[jss::expand] = true; + auto const jrr = env.rpc("json", "ledger", to_string(params)); + std::cout << jrr << std::endl; + } + + void + testSimplePayment(FeatureBitset features) + { + using namespace test::jtx; + + testcase("simple payment"); + + Env env{*this, envconfig(), features}; + Account const alice{"alice"}; + Account const bob{"bob"}; + Account const dave{"dave", KeyType::p256}; + env.fund(XRP(1000), alice, bob, dave); + env.close(); + + env(passkeyListSet(alice)); + env(pay(alice, bob, XRP(100)), sig(dave)); + env.close(); + + Json::Value params; + params[jss::ledger_index] = env.current()->seq() - 1; + params[jss::transactions] = true; + params[jss::expand] = true; + auto const jrr = env.rpc("json", "ledger", to_string(params)); + std::cout << jrr << std::endl; + } + + void + testWithFeats(FeatureBitset features) + { + testP256(features); + } + +public: + void + run() override + { + using namespace test::jtx; + auto const sa = supported_amendments(); + testWithFeats(sa); + } +}; + +BEAST_DEFINE_TESTSUITE(PassKey, protocol, ripple); +} // namespace test +} // namespace ripple \ No newline at end of file diff --git a/src/xrpld/app/tx/detail/InvariantCheck.cpp b/src/xrpld/app/tx/detail/InvariantCheck.cpp index d93378d3cde..83b62058a28 100644 --- a/src/xrpld/app/tx/detail/InvariantCheck.cpp +++ b/src/xrpld/app/tx/detail/InvariantCheck.cpp @@ -543,6 +543,7 @@ LedgerEntryTypesMatch::visitEntry( case ltCREDENTIAL: case ltPERMISSIONED_DOMAIN: case ltVAULT: + case ltPASSKEY_LIST: break; default: invalidTypeAdded_ = true; diff --git a/src/xrpld/app/tx/detail/SetPasskeyList.cpp b/src/xrpld/app/tx/detail/SetPasskeyList.cpp new file mode 100644 index 00000000000..6ec4745717f --- /dev/null +++ b/src/xrpld/app/tx/detail/SetPasskeyList.cpp @@ -0,0 +1,79 @@ +//------------------------------------------------------------------------------ +/* + This file is part of rippled: https://github.com/ripple/rippled + Copyright (c) 2014 Ripple Labs Inc. + + Permission to use, copy, modify, and/or distribute this software for any + purpose with or without fee is hereby granted, provided that the above + copyright notice and this permission notice appear in all copies. + + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + ANY SPECIAL , DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +*/ +//============================================================================== + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace ripple { + +NotTEC +SetPasskeyList::preflight(PreflightContext const& ctx) +{ + if (auto const ret = preflight1(ctx); !isTesSuccess(ret)) + return ret; + + if (ctx.tx.getFlags() & tfUniversalMask) + { + JLOG(ctx.j.debug()) << "SetPasskeyList: invalid flags."; + return temINVALID_FLAG; + } + + return preflight2(ctx); +} + +TER +SetPasskeyList::doApply() +{ + auto viewJ = ctx_.app.journal("View"); + auto const sleAccount = ctx_.view().peek(keylet::account(account_)); + if (!sleAccount) + return tecINTERNAL; + + auto const passkeyID = keylet::passkeyList(account_); + auto sle = std::make_shared(passkeyID); + sle->setAccountID(sfOwner, ctx_.tx.getAccountID(sfAccount)); + auto const& passkeys = ctx_.tx.getFieldArray(sfPasskeys); + sle->setFieldArray(sfPasskeys, passkeys); + + auto page = ctx_.view().dirInsert( + keylet::ownerDir(account_), sle->key(), describeOwnerDir(account_)); + if (!page) + return tecDIR_FULL; // LCOV_EXCL_LINE + + (*sle)[sfOwnerNode] = *page; + + adjustOwnerCount(ctx_.view(), sleAccount, 1, viewJ); + + ctx_.view().insert(sle); + return tesSUCCESS; +} + +} // namespace ripple diff --git a/src/xrpld/app/tx/detail/SetPasskeyList.h b/src/xrpld/app/tx/detail/SetPasskeyList.h new file mode 100644 index 00000000000..737574fc905 --- /dev/null +++ b/src/xrpld/app/tx/detail/SetPasskeyList.h @@ -0,0 +1,53 @@ +//------------------------------------------------------------------------------ +/* + This file is part of rippled: https://github.com/ripple/rippled + Copyright (c) 2014 Ripple Labs Inc. + + Permission to use, copy, modify, and/or distribute this software for any + purpose with or without fee is hereby granted, provided that the above + copyright notice and this permission notice appear in all copies. + + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + ANY SPECIAL , DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +*/ +//============================================================================== + +#ifndef RIPPLE_TX_SETPASSKEYLIST_H_INCLUDED +#define RIPPLE_TX_SETPASSKEYLIST_H_INCLUDED + +#include + +#include +#include + +#include +#include + +namespace ripple { + +class SetPasskeyList : public Transactor +{ +public: + static constexpr ConsequencesFactoryType ConsequencesFactory{Blocker}; + + explicit SetPasskeyList(ApplyContext& ctx) : Transactor(ctx) + { + } + + static NotTEC + preflight(PreflightContext const& ctx); + + TER + doApply() override; +}; + +using PasskeyListSet = SetPasskeyList; + +} // namespace ripple + +#endif diff --git a/src/xrpld/app/tx/detail/Transactor.cpp b/src/xrpld/app/tx/detail/Transactor.cpp index 0db04848426..b700c717cdc 100644 --- a/src/xrpld/app/tx/detail/Transactor.cpp +++ b/src/xrpld/app/tx/detail/Transactor.cpp @@ -628,7 +628,7 @@ Transactor::checkSign(PreclaimContext const& ctx) return terNO_ACCOUNT; return checkSingleSign( - idSigner, idAccount, sleAccount, ctx.view.rules(), ctx.j); + ctx.view, idSigner, idAccount, sleAccount, ctx.view.rules(), ctx.j); } NotTEC @@ -670,7 +670,7 @@ Transactor::checkBatchSign(PreclaimContext const& ctx) } if (ret = checkSingleSign( - idSigner, idAccount, sleAccount, ctx.view.rules(), ctx.j); + ctx.view, idSigner, idAccount, sleAccount, ctx.view.rules(), ctx.j); !isTesSuccess(ret)) return ret; } @@ -680,6 +680,7 @@ Transactor::checkBatchSign(PreclaimContext const& ctx) NotTEC Transactor::checkSingleSign( + ReadView const& view, AccountID const& idSigner, AccountID const& idAccount, std::shared_ptr sleAccount, @@ -708,6 +709,30 @@ Transactor::checkSingleSign( return tefMASTER_DISABLED; } + if (rules.enabled(featurePasskey)) + { + std::shared_ptr slePasskeyList = view.read(keylet::passkeyList(idAccount)); + if (!slePasskeyList) + { + return tefBAD_AUTH; + } + + auto const passkeys = slePasskeyList->getFieldArray(sfPasskeys); + if (passkeys.empty()) + { + JLOG(j.trace()) << "checkSingleSign: No passkeys found for account."; + return tefBAD_AUTH; + } + auto hasMatchingPasskey = std::any_of( + passkeys.begin(), passkeys.end(), + [&idAccount](STObject const& passkey) { + return passkey.isFieldPresent(sfPublicKey) && + calcAccountID(PublicKey(makeSlice(passkey.getFieldVL(sfPublicKey)))) == idAccount; + }); + if (hasMatchingPasskey) + return tesSUCCESS; + } + // Signed with any other key. return tefBAD_AUTH; } diff --git a/src/xrpld/app/tx/detail/Transactor.h b/src/xrpld/app/tx/detail/Transactor.h index 42d4861a63a..60880635dce 100644 --- a/src/xrpld/app/tx/detail/Transactor.h +++ b/src/xrpld/app/tx/detail/Transactor.h @@ -254,6 +254,7 @@ class Transactor payFee(); static NotTEC checkSingleSign( + ReadView const& view, AccountID const& idSigner, AccountID const& idAccount, std::shared_ptr sleAccount, diff --git a/src/xrpld/app/tx/detail/applySteps.cpp b/src/xrpld/app/tx/detail/applySteps.cpp index 34259ebef0d..bf8613f5b95 100644 --- a/src/xrpld/app/tx/detail/applySteps.cpp +++ b/src/xrpld/app/tx/detail/applySteps.cpp @@ -59,6 +59,7 @@ #include #include #include +#include #include #include #include From 4f482ea00ccde053c463796f9c4e24b3bef67382 Mon Sep 17 00:00:00 2001 From: Denis Angell Date: Mon, 2 Mar 2026 06:04:21 +0100 Subject: [PATCH 2/6] Update STTx.cpp --- src/libxrpl/protocol/STTx.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/src/libxrpl/protocol/STTx.cpp b/src/libxrpl/protocol/STTx.cpp index e2984b1eba7..938b58e833a 100644 --- a/src/libxrpl/protocol/STTx.cpp +++ b/src/libxrpl/protocol/STTx.cpp @@ -13,6 +13,7 @@ #include #include #include +#include #include #include #include From c0fc1d2416bf2851da363dd224319efd0bd08874 Mon Sep 17 00:00:00 2001 From: Denis Angell Date: Mon, 2 Mar 2026 06:15:02 +0100 Subject: [PATCH 3/6] fix conflicts --- .../xrpl/protocol/detail/transactions.macro | 3 + include/xrpl/tx/transactors/SetPasskeyList.h | 30 +++++++ src/libxrpl/tx/transactors/SetPasskeyList.cpp | 50 ++++++++++++ src/xrpld/app/tx/detail/SetPasskeyList.cpp | 79 ------------------- src/xrpld/app/tx/detail/SetPasskeyList.h | 56 +------------ 5 files changed, 86 insertions(+), 132 deletions(-) create mode 100644 include/xrpl/tx/transactors/SetPasskeyList.h create mode 100644 src/libxrpl/tx/transactors/SetPasskeyList.cpp delete mode 100644 src/xrpld/app/tx/detail/SetPasskeyList.cpp diff --git a/include/xrpl/protocol/detail/transactions.macro b/include/xrpl/protocol/detail/transactions.macro index 91ac87e0156..b132a2c3199 100644 --- a/include/xrpl/protocol/detail/transactions.macro +++ b/include/xrpl/protocol/detail/transactions.macro @@ -1059,6 +1059,9 @@ TRANSACTION(ttLOAN_PAY, 84, LoanPay, })) /** This transaction type sets the passkey list. */ +#if TRANSACTION_INCLUDE +# include +#endif TRANSACTION(ttPASSKEY_LIST_SET, 85, PasskeyListSet, Delegation::delegable, featurePasskey, diff --git a/include/xrpl/tx/transactors/SetPasskeyList.h b/include/xrpl/tx/transactors/SetPasskeyList.h new file mode 100644 index 00000000000..72ce91e3c3d --- /dev/null +++ b/include/xrpl/tx/transactors/SetPasskeyList.h @@ -0,0 +1,30 @@ +#pragma once + +#include +#include +#include + +#include +#include + +namespace xrpl { + +class SetPasskeyList : public Transactor +{ +public: + static constexpr ConsequencesFactoryType ConsequencesFactory{Blocker}; + + explicit SetPasskeyList(ApplyContext& ctx) : Transactor(ctx) + { + } + + static NotTEC + preflight(PreflightContext const& ctx); + + TER + doApply() override; +}; + +using PasskeyListSet = SetPasskeyList; + +} // namespace xrpl diff --git a/src/libxrpl/tx/transactors/SetPasskeyList.cpp b/src/libxrpl/tx/transactors/SetPasskeyList.cpp new file mode 100644 index 00000000000..de727a2bcb9 --- /dev/null +++ b/src/libxrpl/tx/transactors/SetPasskeyList.cpp @@ -0,0 +1,50 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace xrpl { + +NotTEC +SetPasskeyList::preflight(PreflightContext const& ctx) +{ + return tesSUCCESS; +} + +TER +SetPasskeyList::doApply() +{ + auto viewJ = ctx_.registry.journal("View"); + auto const sleAccount = ctx_.view().peek(keylet::account(account_)); + if (!sleAccount) + return tecINTERNAL; + + auto const passkeyID = keylet::passkeyList(account_); + auto sle = std::make_shared(passkeyID); + sle->setAccountID(sfOwner, ctx_.tx.getAccountID(sfAccount)); + auto const& passkeys = ctx_.tx.getFieldArray(sfPasskeys); + sle->setFieldArray(sfPasskeys, passkeys); + + auto page = ctx_.view().dirInsert( + keylet::ownerDir(account_), sle->key(), describeOwnerDir(account_)); + if (!page) + return tecDIR_FULL; // LCOV_EXCL_LINE + + (*sle)[sfOwnerNode] = *page; + + adjustOwnerCount(ctx_.view(), sleAccount, 1, viewJ); + + ctx_.view().insert(sle); + return tesSUCCESS; +} + +} // namespace xrpl diff --git a/src/xrpld/app/tx/detail/SetPasskeyList.cpp b/src/xrpld/app/tx/detail/SetPasskeyList.cpp deleted file mode 100644 index 6ec4745717f..00000000000 --- a/src/xrpld/app/tx/detail/SetPasskeyList.cpp +++ /dev/null @@ -1,79 +0,0 @@ -//------------------------------------------------------------------------------ -/* - This file is part of rippled: https://github.com/ripple/rippled - Copyright (c) 2014 Ripple Labs Inc. - - Permission to use, copy, modify, and/or distribute this software for any - purpose with or without fee is hereby granted, provided that the above - copyright notice and this permission notice appear in all copies. - - THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES - WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF - MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR - ANY SPECIAL , DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES - WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN - ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF - OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. -*/ -//============================================================================== - -#include -#include -#include - -#include -#include -#include -#include -#include -#include -#include - -#include -#include - -namespace ripple { - -NotTEC -SetPasskeyList::preflight(PreflightContext const& ctx) -{ - if (auto const ret = preflight1(ctx); !isTesSuccess(ret)) - return ret; - - if (ctx.tx.getFlags() & tfUniversalMask) - { - JLOG(ctx.j.debug()) << "SetPasskeyList: invalid flags."; - return temINVALID_FLAG; - } - - return preflight2(ctx); -} - -TER -SetPasskeyList::doApply() -{ - auto viewJ = ctx_.app.journal("View"); - auto const sleAccount = ctx_.view().peek(keylet::account(account_)); - if (!sleAccount) - return tecINTERNAL; - - auto const passkeyID = keylet::passkeyList(account_); - auto sle = std::make_shared(passkeyID); - sle->setAccountID(sfOwner, ctx_.tx.getAccountID(sfAccount)); - auto const& passkeys = ctx_.tx.getFieldArray(sfPasskeys); - sle->setFieldArray(sfPasskeys, passkeys); - - auto page = ctx_.view().dirInsert( - keylet::ownerDir(account_), sle->key(), describeOwnerDir(account_)); - if (!page) - return tecDIR_FULL; // LCOV_EXCL_LINE - - (*sle)[sfOwnerNode] = *page; - - adjustOwnerCount(ctx_.view(), sleAccount, 1, viewJ); - - ctx_.view().insert(sle); - return tesSUCCESS; -} - -} // namespace ripple diff --git a/src/xrpld/app/tx/detail/SetPasskeyList.h b/src/xrpld/app/tx/detail/SetPasskeyList.h index 737574fc905..bfb00c0f79e 100644 --- a/src/xrpld/app/tx/detail/SetPasskeyList.h +++ b/src/xrpld/app/tx/detail/SetPasskeyList.h @@ -1,53 +1,3 @@ -//------------------------------------------------------------------------------ -/* - This file is part of rippled: https://github.com/ripple/rippled - Copyright (c) 2014 Ripple Labs Inc. - - Permission to use, copy, modify, and/or distribute this software for any - purpose with or without fee is hereby granted, provided that the above - copyright notice and this permission notice appear in all copies. - - THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES - WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF - MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR - ANY SPECIAL , DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES - WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN - ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF - OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. -*/ -//============================================================================== - -#ifndef RIPPLE_TX_SETPASSKEYLIST_H_INCLUDED -#define RIPPLE_TX_SETPASSKEYLIST_H_INCLUDED - -#include - -#include -#include - -#include -#include - -namespace ripple { - -class SetPasskeyList : public Transactor -{ -public: - static constexpr ConsequencesFactoryType ConsequencesFactory{Blocker}; - - explicit SetPasskeyList(ApplyContext& ctx) : Transactor(ctx) - { - } - - static NotTEC - preflight(PreflightContext const& ctx); - - TER - doApply() override; -}; - -using PasskeyListSet = SetPasskeyList; - -} // namespace ripple - -#endif +#pragma once +// This header has moved to its new location. +#include From d9bd28adafabe5d07af2ca60b3974e68a8f8e355 Mon Sep 17 00:00:00 2001 From: Denis Angell Date: Mon, 2 Mar 2026 06:17:11 +0100 Subject: [PATCH 4/6] Update LedgerEntry.cpp --- src/xrpld/rpc/handlers/LedgerEntry.cpp | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/xrpld/rpc/handlers/LedgerEntry.cpp b/src/xrpld/rpc/handlers/LedgerEntry.cpp index e4e8f52fd77..8799bae3dfd 100644 --- a/src/xrpld/rpc/handlers/LedgerEntry.cpp +++ b/src/xrpld/rpc/handlers/LedgerEntry.cpp @@ -690,6 +690,15 @@ parseRippleState( return keylet::line(*id1, *id2, uCurrency).key; } +static Expected +parsePasskeyList( + Json::Value const& params, + Json::StaticString const fieldName, + [[maybe_unused]] unsigned const apiVersion) +{ + return parseObjectID(params, fieldName, "hex string"); +} + static Expected parseSignerList( Json::Value const& params, From 29b8b25e079e94fe31ff38c19f3d216bbb26ca0c Mon Sep 17 00:00:00 2001 From: Denis Angell Date: Mon, 2 Mar 2026 06:20:23 +0100 Subject: [PATCH 5/6] Update PassKey_test.cpp --- src/test/protocol/PassKey_test.cpp | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/test/protocol/PassKey_test.cpp b/src/test/protocol/PassKey_test.cpp index 8a63c814862..b303308b6c8 100644 --- a/src/test/protocol/PassKey_test.cpp +++ b/src/test/protocol/PassKey_test.cpp @@ -21,7 +21,7 @@ #include #include -namespace ripple { +namespace xrpl { namespace test { class PassKey_test : public beast::unit_test::suite { @@ -99,11 +99,11 @@ class PassKey_test : public beast::unit_test::suite run() override { using namespace test::jtx; - auto const sa = supported_amendments(); + auto const sa = testable_amendments(); testWithFeats(sa); } }; -BEAST_DEFINE_TESTSUITE(PassKey, protocol, ripple); +BEAST_DEFINE_TESTSUITE(PassKey, protocol, xrpl); } // namespace test -} // namespace ripple \ No newline at end of file +} // namespace xrpl \ No newline at end of file From 3a59e3581d1d1a4384dc498c30d0cd8d13ba4aff Mon Sep 17 00:00:00 2001 From: Denis Angell Date: Mon, 2 Mar 2026 09:53:44 +0100 Subject: [PATCH 6/6] fix bugs --- include/xrpl/basics/base64.h | 6 + src/libxrpl/basics/base64.cpp | 16 + src/libxrpl/protocol/PublicKey.cpp | 2 +- src/libxrpl/protocol/STTx.cpp | 174 ++++++--- src/libxrpl/protocol/SecretKey.cpp | 2 +- src/libxrpl/tx/transactors/SetPasskeyList.cpp | 51 +++ src/test/app/PasskeyListSet_test.cpp | 329 ++++++++++++++++++ src/test/protocol/PassKey_test.cpp | 94 +++-- 8 files changed, 580 insertions(+), 94 deletions(-) create mode 100644 src/test/app/PasskeyListSet_test.cpp diff --git a/include/xrpl/basics/base64.h b/include/xrpl/basics/base64.h index 1725ee42f6b..ef8c1a40149 100644 --- a/include/xrpl/basics/base64.h +++ b/include/xrpl/basics/base64.h @@ -51,4 +51,10 @@ base64_encode(std::string const& s) std::string base64_decode(std::string_view data); +/** Decode a base64url-encoded string (RFC 4648 S5). + Converts '-' to '+' and '_' to '/', adds padding, then decodes. +*/ +std::string +base64url_decode(std::string_view data); + } // namespace xrpl diff --git a/src/libxrpl/basics/base64.cpp b/src/libxrpl/basics/base64.cpp index fa06ac2cdcb..58d5a5f7714 100644 --- a/src/libxrpl/basics/base64.cpp +++ b/src/libxrpl/basics/base64.cpp @@ -214,4 +214,20 @@ base64_decode(std::string_view data) return dest; } +std::string +base64url_decode(std::string_view data) +{ + std::string b64(data); + for (auto& c : b64) + { + if (c == '-') + c = '+'; + else if (c == '_') + c = '/'; + } + while (b64.size() % 4 != 0) + b64 += '='; + return base64_decode(b64); +} + } // namespace xrpl diff --git a/src/libxrpl/protocol/PublicKey.cpp b/src/libxrpl/protocol/PublicKey.cpp index e26c7ba473f..adc76fe3360 100644 --- a/src/libxrpl/protocol/PublicKey.cpp +++ b/src/libxrpl/protocol/PublicKey.cpp @@ -219,7 +219,7 @@ publicKeyType(Slice const& slice) return KeyType::secp256k1; } - if (slice.size() == 65) + if (slice.size() == 65 && slice[0] == 0xF6) { return KeyType::p256; } diff --git a/src/libxrpl/protocol/STTx.cpp b/src/libxrpl/protocol/STTx.cpp index 938b58e833a..e1e13617203 100644 --- a/src/libxrpl/protocol/STTx.cpp +++ b/src/libxrpl/protocol/STTx.cpp @@ -3,12 +3,14 @@ #include #include #include +#include #include #include #include #include #include #include +#include #include #include #include @@ -166,32 +168,10 @@ STTx::getMentionedAccounts() const static Blob getSigningData(STTx const& that) { - std::optional const keyType = publicKeyType(makeSlice(that.getFieldVL(sfSigningPubKey))); - if (keyType && (keyType == KeyType::p256)) - { - auto const& passKeySignature = static_cast(that.peekAtField(sfPasskeySignature)); - auto const authenticatorData = passKeySignature.getFieldVL(sfAuthenticatorData); - auto const clientDataJSON = passKeySignature.getFieldVL(sfClientDataJSON); - auto const clientDataHash = sha256(makeSlice(clientDataJSON)); - Buffer concatenatedData(authenticatorData.size() + clientDataHash.size()); - std::memcpy( - concatenatedData.data(), - authenticatorData.data(), - authenticatorData.size()); - std::memcpy( - concatenatedData.data() + authenticatorData.size(), - clientDataHash.data(), - clientDataHash.size()); - return Blob{concatenatedData.begin(), concatenatedData.end()}; - } - else - { - Serializer s; - s.add32(HashPrefix::txSign); - that.addWithoutSigningFields(s); - return s.peekData(); - } - return {}; + Serializer s; + s.add32(HashPrefix::txSign); + that.addWithoutSigningFields(s); + return s.peekData(); } uint256 @@ -397,22 +377,121 @@ STTx::getMetaSQL( escapedMetaData); } +/** Verify a P-256 passkey signature with WebAuthn challenge validation. + Validates that the clientDataJSON challenge matches the expected signing + data, then verifies the ECDSA signature against the WebAuthn authenticator + data. Returns true if the signature is valid, false otherwise. +*/ +static bool +verifyPasskeySignature( + Slice const& publicKey, + STObject const& passKeySig, + Slice const& expectedSigningData) noexcept +{ + try + { + auto const authenticatorData = + passKeySig.getFieldVL(sfAuthenticatorData); + auto const clientDataJSON = + passKeySig.getFieldVL(sfClientDataJSON); + + // Validate that the WebAuthn challenge in clientDataJSON matches + // the transaction signing data. Without this, a passkey signature + // from any website could be replayed to authorize transactions. + std::string const cdj(clientDataJSON.begin(), clientDataJSON.end()); + Json::Value parsed; + Json::Reader reader; + if (!reader.parse(cdj, parsed) || !parsed.isObject()) + return false; + + // Verify type is "webauthn.get" + if (!parsed.isMember("type") || + parsed["type"].asString() != "webauthn.get") + return false; + + // Verify challenge field exists + if (!parsed.isMember("challenge") || !parsed["challenge"].isString()) + return false; + + // Decode the base64url-encoded challenge and compare + auto const challengeBytes = + base64url_decode(parsed["challenge"].asString()); + if (challengeBytes.size() != expectedSigningData.size() || + !std::equal( + challengeBytes.begin(), + challengeBytes.end(), + expectedSigningData.data())) + return false; + + // Build WebAuthn signing data: authenticatorData || SHA-256(clientDataJSON) + auto const clientDataHash = sha256(makeSlice(clientDataJSON)); + + Blob signingData(authenticatorData.begin(), authenticatorData.end()); + signingData.insert( + signingData.end(), + clientDataHash.data(), + clientDataHash.data() + clientDataHash.size()); + + Blob const signature = passKeySig.getFieldVL(sfSignature); + return verify( + PublicKey(publicKey), + makeSlice(signingData), + makeSlice(signature)); + } + catch (std::exception const&) + { + return false; + } +} + +/** Verify a signature on a signing object. + Handles both standard signatures (sfTxnSignature) and P-256 passkey + signatures (sfPasskeySignature with WebAuthn challenge validation). +*/ +static bool +verifySigObject( + STObject const& sigObject, + Slice const& data) noexcept +{ + try + { + auto const spk = sigObject.getFieldVL(sfSigningPubKey); + auto const keyType = publicKeyType(makeSlice(spk)); + if (!keyType) + return false; + + if (*keyType == KeyType::p256 && + sigObject.isFieldPresent(sfPasskeySignature)) + { + auto const& passKeySig = static_cast( + sigObject.peekAtField(sfPasskeySignature)); + return verifyPasskeySignature( + makeSlice(spk), passKeySig, data); + } + + Blob const signature = sigObject.getFieldVL(sfTxnSignature); + return verify( + PublicKey(makeSlice(spk)), data, makeSlice(signature)); + } + catch (std::exception const&) + { + return false; + } +} + Blob getSignature(STObject const& signer) { auto const spk = signer.getFieldVL(sfSigningPubKey); std::optional const keyType = publicKeyType(makeSlice(spk)); - if (keyType && (keyType == KeyType::p256)) + if (keyType && (*keyType == KeyType::p256) && + signer.isFieldPresent(sfPasskeySignature)) { - auto const& passKeySignature = - static_cast(signer.peekAtField(sfPasskeySignature)); + auto const& passKeySignature = static_cast( + signer.peekAtField(sfPasskeySignature)); return passKeySignature.getFieldVL(sfSignature); } - else - { - // Handle ed25519 signing - return signer.getFieldVL(sfTxnSignature); - } + return signer.getFieldVL(sfTxnSignature); } static Expected @@ -424,22 +503,7 @@ singleSignHelper(STObject const& sigObject, Slice const& data) if (sigObject.isFieldPresent(sfSigners)) return Unexpected("Cannot both single- and multi-sign."); - bool validSig = false; - try - { - auto const spk = sigObject.getFieldVL(sfSigningPubKey); - if (publicKeyType(makeSlice(spk))) - { - Blob const signature = sigObject.getFieldVL(sfTxnSignature); - validSig = verify(PublicKey(makeSlice(spk)), data, makeSlice(signature)); - } - } - catch (std::exception const&) - { - validSig = false; - } - - if (!validSig) + if (!verifySigObject(sigObject, data)) return Unexpected("Invalid signature."); return {}; @@ -512,13 +576,9 @@ multiSignHelper( std::optional errorWhat; try { - auto spk = signer.getFieldVL(sfSigningPubKey); - if (publicKeyType(makeSlice(spk))) - { - Blob const signature = signer.getFieldVL(sfTxnSignature); - validSig = verify( - PublicKey(makeSlice(spk)), makeMsg(accountID).slice(), makeSlice(signature)); - } + auto const msgSerializer = makeMsg(accountID); + validSig = + verifySigObject(signer, msgSerializer.slice()); } catch (std::exception const& e) { diff --git a/src/libxrpl/protocol/SecretKey.cpp b/src/libxrpl/protocol/SecretKey.cpp index 72db2887240..b9f14f3eb4e 100644 --- a/src/libxrpl/protocol/SecretKey.cpp +++ b/src/libxrpl/protocol/SecretKey.cpp @@ -308,7 +308,7 @@ sign(PublicKey const& pk, SecretKey const& sk, Slice const& m) reinterpret_cast(digest.data()), digest.size(), key); - + if (!sig_obj) { BN_free(priv_key); diff --git a/src/libxrpl/tx/transactors/SetPasskeyList.cpp b/src/libxrpl/tx/transactors/SetPasskeyList.cpp index de727a2bcb9..990067c2eff 100644 --- a/src/libxrpl/tx/transactors/SetPasskeyList.cpp +++ b/src/libxrpl/tx/transactors/SetPasskeyList.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include #include @@ -11,12 +12,62 @@ #include #include +#include namespace xrpl { NotTEC SetPasskeyList::preflight(PreflightContext const& ctx) { + auto const& passkeys = ctx.tx.getFieldArray(sfPasskeys); + + if (passkeys.empty()) + { + JLOG(ctx.j.debug()) << "SetPasskeyList: empty passkeys array."; + return temMALFORMED; + } + + // Validate each passkey entry and check for duplicates + std::set seenPasskeyIDs; + std::set seenPublicKeys; + for (auto const& passkey : passkeys) + { + if (!passkey.isFieldPresent(sfPasskeyID) || + !passkey.isFieldPresent(sfPublicKey)) + { + JLOG(ctx.j.debug()) + << "SetPasskeyList: missing required fields."; + return temMALFORMED; + } + + // Check for duplicate PasskeyIDs + auto const passkeyID = passkey.getFieldVL(sfPasskeyID); + if (!seenPasskeyIDs.insert(passkeyID).second) + { + JLOG(ctx.j.debug()) + << "SetPasskeyList: duplicate PasskeyID."; + return temMALFORMED; + } + + // Check for duplicate PublicKeys + auto const pk = passkey.getFieldVL(sfPublicKey); + if (!seenPublicKeys.insert(pk).second) + { + JLOG(ctx.j.debug()) + << "SetPasskeyList: duplicate PublicKey."; + return temMALFORMED; + } + + // Validate public key is a valid P256 key + auto const keyType = publicKeyType(makeSlice(pk)); + if (!keyType || *keyType != KeyType::p256) + { + JLOG(ctx.j.debug()) + << "SetPasskeyList: invalid P256 public key."; + return temMALFORMED; + } + } + return tesSUCCESS; } diff --git a/src/test/app/PasskeyListSet_test.cpp b/src/test/app/PasskeyListSet_test.cpp new file mode 100644 index 00000000000..a668f6052ce --- /dev/null +++ b/src/test/app/PasskeyListSet_test.cpp @@ -0,0 +1,329 @@ +//------------------------------------------------------------------------------ +/* + This file is part of rippled: https://github.com/ripple/rippled + Copyright (c) 2023 XRPL-Labs. + + Permission to use, copy, modify, and/or distribute this software for any + purpose with or without fee is hereby granted, provided that the above + copyright notice and this permission notice appear in all copies. + + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + ANY SPECIAL , DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +*/ +//============================================================================== + +#include + +#include +#include +#include + +namespace xrpl { +namespace test { + +class PasskeyListSet_test : public beast::unit_test::suite +{ + Json::Value + passkeyListSet(jtx::Account const& account) + { + Json::Value jv; + jv[sfAccount.jsonName] = account.human(); + jv[sfTransactionType.jsonName] = jss::PasskeyListSet; + jv[sfPasskeys] = Json::arrayValue; + jv[sfPasskeys][0u][sfPasskey.jsonName][sfPasskeyID.jsonName] = + "DEADBEEF"; + jv[sfPasskeys][0u][sfPasskey.jsonName][sfPublicKey.jsonName] = + strHex(account.pk()); + return jv; + } + + Json::Value + passkeyListSetMulti( + jtx::Account const& account, + std::vector> const& entries) + { + Json::Value jv; + jv[sfAccount.jsonName] = account.human(); + jv[sfTransactionType.jsonName] = jss::PasskeyListSet; + jv[sfPasskeys] = Json::arrayValue; + for (Json::UInt i = 0; i < entries.size(); ++i) + { + jv[sfPasskeys][i][sfPasskey.jsonName][sfPasskeyID.jsonName] = + entries[i].first; + jv[sfPasskeys][i][sfPasskey.jsonName][sfPublicKey.jsonName] = + entries[i].second; + } + return jv; + } + +public: + void + testBasicPasskeyListSet(FeatureBitset features) + { + using namespace test::jtx; + + testcase("basic passkey list set"); + + Env env{*this, envconfig(), features}; + Account const alice{"alice", KeyType::p256}; + env.fund(XRP(1000), alice); + env.close(); + + env(passkeyListSet(alice)); + env.close(); + } + + void + testValidMultiplePasskeys(FeatureBitset features) + { + using namespace test::jtx; + + testcase("valid multiple passkeys"); + + Env env{*this, envconfig(), features}; + Account const alice{"alice", KeyType::p256}; + Account const bob{"bob", KeyType::p256}; + env.fund(XRP(1000), alice, bob); + env.close(); + + // Two valid entries with different IDs and different PublicKeys + auto jv = passkeyListSetMulti( + alice, + {{"DEADBEEF01", strHex(alice.pk())}, + {"DEADBEEF02", strHex(bob.pk())}}); + env(jv); + env.close(); + } + + void + testEmptyPasskeyList(FeatureBitset features) + { + using namespace test::jtx; + + testcase("empty passkey list rejected"); + + Env env{*this, envconfig(), features}; + Account const alice{"alice", KeyType::p256}; + env.fund(XRP(1000), alice); + env.close(); + + Json::Value jv; + jv[sfAccount.jsonName] = alice.human(); + jv[sfTransactionType.jsonName] = jss::PasskeyListSet; + jv[sfPasskeys] = Json::arrayValue; + env(jv, ter(temMALFORMED)); + } + + void + testDuplicatePasskeyID(FeatureBitset features) + { + using namespace test::jtx; + + testcase("duplicate passkey ID rejected"); + + Env env{*this, envconfig(), features}; + Account const alice{"alice", KeyType::p256}; + Account const bob{"bob", KeyType::p256}; + env.fund(XRP(1000), alice, bob); + env.close(); + + // Two entries with the same PasskeyID but different PublicKeys + auto jv = passkeyListSetMulti( + alice, + {{"DEADBEEF", strHex(alice.pk())}, + {"DEADBEEF", strHex(bob.pk())}}); + env(jv, ter(temMALFORMED)); + } + + void + testDuplicatePublicKey(FeatureBitset features) + { + using namespace test::jtx; + + testcase("duplicate public key rejected"); + + Env env{*this, envconfig(), features}; + Account const alice{"alice", KeyType::p256}; + env.fund(XRP(1000), alice); + env.close(); + + // Two entries with different PasskeyIDs but the same PublicKey + auto jv = passkeyListSetMulti( + alice, + {{"DEADBEEF01", strHex(alice.pk())}, + {"DEADBEEF02", strHex(alice.pk())}}); + env(jv, ter(temMALFORMED)); + } + + void + testInvalidKeyType(FeatureBitset features) + { + using namespace test::jtx; + + testcase("non-P256 key rejected"); + + Env env{*this, envconfig(), features}; + Account const alice{"alice", KeyType::p256}; + Account const bob{"bob"}; // secp256k1 + env.fund(XRP(1000), alice, bob); + env.close(); + + // A secp256k1 key should be rejected + auto jv = + passkeyListSetMulti(alice, {{"DEADBEEF", strHex(bob.pk())}}); + env(jv, ter(temMALFORMED)); + } + + void + testEd25519KeyRejected(FeatureBitset features) + { + using namespace test::jtx; + + testcase("ed25519 key rejected"); + + Env env{*this, envconfig(), features}; + Account const alice{"alice", KeyType::p256}; + Account const carol{"carol", KeyType::ed25519}; + env.fund(XRP(1000), alice, carol); + env.close(); + + // An ed25519 key should be rejected + auto jv = + passkeyListSetMulti(alice, {{"DEADBEEF", strHex(carol.pk())}}); + env(jv, ter(temMALFORMED)); + } + + void + testInvalidKeyPrefix(FeatureBitset features) + { + using namespace test::jtx; + + testcase("invalid P256 prefix rejected"); + + Env env{*this, envconfig(), features}; + Account const alice{"alice", KeyType::p256}; + env.fund(XRP(1000), alice); + env.close(); + + // Create a 65-byte key with wrong prefix (0x04 instead of 0xF6) + auto pkHex = strHex(alice.pk()); + pkHex[0] = '0'; + pkHex[1] = '4'; + + auto jv = passkeyListSetMulti(alice, {{"DEADBEEF", pkHex}}); + env(jv, ter(temMALFORMED)); + } + + void + testPasskeyPayment(FeatureBitset features) + { + using namespace test::jtx; + + testcase("payment with passkey signer"); + + Env env{*this, envconfig(), features}; + Account const alice{"alice"}; + Account const bob{"bob"}; + Account const dave{"dave", KeyType::p256}; + env.fund(XRP(1000), alice, bob, dave); + env.close(); + + env(passkeyListSet(alice)); + env(pay(alice, bob, XRP(100)), sig(dave)); + env.close(); + + BEAST_EXPECT(env.balance(bob) == XRP(1100)); + } + + void + testMultisignWithP256(FeatureBitset features) + { + using namespace test::jtx; + + testcase("multisign with P256 signers"); + + Env env{*this, envconfig(), features}; + Account const alice{"alice"}; + Account const bob{"bob", KeyType::p256}; + Account const carol{"carol", KeyType::p256}; + env.fund(XRP(1000), alice, bob, carol); + env.close(); + + // Set up a signer list with P-256 accounts + env(signers(alice, 1, {{bob, 1}, {carol, 1}})); + env.close(); + + auto const baseFee = env.current()->fees().base; + + // Multi-sign with one P-256 signer + env(noop(alice), msig(bob), fee(2 * baseFee)); + env.close(); + + // Multi-sign with both P-256 signers + env(noop(alice), msig(bob, carol), fee(3 * baseFee)); + env.close(); + } + + void + testMultisignMixedKeyTypes(FeatureBitset features) + { + using namespace test::jtx; + + testcase("multisign with mixed key types including P256"); + + Env env{*this, envconfig(), features}; + Account const alice{"alice"}; + Account const bob{"bob"}; // secp256k1 + Account const carol{"carol", KeyType::ed25519}; + Account const dave{"dave", KeyType::p256}; + env.fund(XRP(1000), alice, bob, carol, dave); + env.close(); + + // Set up a signer list with mixed key types + env(signers(alice, 2, {{bob, 1}, {carol, 1}, {dave, 1}})); + env.close(); + + auto const baseFee = env.current()->fees().base; + + // Multi-sign with secp256k1 + P-256 + env(noop(alice), msig(bob, dave), fee(3 * baseFee)); + env.close(); + + // Multi-sign with ed25519 + P-256 + env(noop(alice), msig(carol, dave), fee(3 * baseFee)); + env.close(); + + // Multi-sign with all three key types + env(noop(alice), msig(bob, carol, dave), fee(4 * baseFee)); + env.close(); + } + + void + run() override + { + using namespace test::jtx; + auto const sa = testable_amendments(); + + testBasicPasskeyListSet(sa); + testValidMultiplePasskeys(sa); + testEmptyPasskeyList(sa); + testDuplicatePasskeyID(sa); + testDuplicatePublicKey(sa); + testInvalidKeyType(sa); + testEd25519KeyRejected(sa); + testInvalidKeyPrefix(sa); + testPasskeyPayment(sa); + testMultisignWithP256(sa); + testMultisignMixedKeyTypes(sa); + } +}; + +BEAST_DEFINE_TESTSUITE(PasskeyListSet, app, xrpl); + +} // namespace test +} // namespace xrpl diff --git a/src/test/protocol/PassKey_test.cpp b/src/test/protocol/PassKey_test.cpp index b303308b6c8..ed5a39d818c 100644 --- a/src/test/protocol/PassKey_test.cpp +++ b/src/test/protocol/PassKey_test.cpp @@ -18,6 +18,7 @@ //============================================================================== #include +#include #include #include @@ -25,25 +26,51 @@ namespace xrpl { namespace test { class PassKey_test : public beast::unit_test::suite { - - Json::Value - passkeyListSet(jtx::Account const& account) + void + testP256KeyTypeDetection() { - Json::Value jv; - jv[sfAccount.jsonName] = account.human(); - jv[sfTransactionType.jsonName] = jss::PasskeyListSet; - jv[sfPasskeys] = Json::arrayValue; - jv[sfPasskeys][0u][sfPasskey][sfPasskeyID] = "DEADBEEF"; - jv[sfPasskeys][0u][sfPasskey][sfPublicKey] = strHex(account.pk()); - return jv; + testcase("P256 key type requires 0xF6 prefix"); + + using namespace test::jtx; + + // Valid P-256 key from the test framework + Account const p256acct{"p256acct", KeyType::p256}; + auto const keyType = publicKeyType(p256acct.pk()); + BEAST_EXPECT(keyType.has_value()); + BEAST_EXPECT(*keyType == KeyType::p256); + + // A 65-byte buffer with 0x04 prefix (standard uncompressed EC) + // must NOT be accepted as P-256 on XRPL + std::array badKey{}; + badKey[0] = 0x04; + auto const badType = publicKeyType(makeSlice(badKey)); + BEAST_EXPECT(!badType.has_value()); + + // A 65-byte buffer with 0xF6 prefix should be accepted + std::array goodKey{}; + goodKey[0] = 0xF6; + auto const goodType = publicKeyType(makeSlice(goodKey)); + BEAST_EXPECT(goodType.has_value()); + BEAST_EXPECT(*goodType == KeyType::p256); + + // Wrong size keys should not be detected as P-256 + std::array shortKey{}; + shortKey[0] = 0xF6; + auto const shortType = publicKeyType(makeSlice(shortKey)); + BEAST_EXPECT(!shortType.has_value() || *shortType != KeyType::p256); + + std::array longKey{}; + longKey[0] = 0xF6; + auto const longType = publicKeyType(makeSlice(longKey)); + BEAST_EXPECT(!longType.has_value()); } void - testP256(FeatureBitset features) + testP256SingleSign(FeatureBitset features) { using namespace test::jtx; - testcase("p256"); + testcase("P256 single sign"); Env env{*this, envconfig(), features}; Account const alice{"alice", KeyType::p256}; @@ -54,44 +81,36 @@ class PassKey_test : public beast::unit_test::suite env(pay(alice, bob, XRP(100))); env.close(); - Json::Value params; - params[jss::ledger_index] = env.current()->seq() - 1; - params[jss::transactions] = true; - params[jss::expand] = true; - auto const jrr = env.rpc("json", "ledger", to_string(params)); - std::cout << jrr << std::endl; + // Verify the payment went through + BEAST_EXPECT(env.balance(bob) == XRP(1100)); } void - testSimplePayment(FeatureBitset features) + testP256WithOtherKeyTypes(FeatureBitset features) { using namespace test::jtx; - testcase("simple payment"); + testcase("P256 alongside other key types"); Env env{*this, envconfig(), features}; - Account const alice{"alice"}; - Account const bob{"bob"}; - Account const dave{"dave", KeyType::p256}; - env.fund(XRP(1000), alice, bob, dave); + Account const alice{"alice", KeyType::p256}; + Account const bob{"bob"}; // secp256k1 + Account const carol{"carol", KeyType::ed25519}; + env.fund(XRP(1000), alice, bob, carol); env.close(); - env(passkeyListSet(alice)); - env(pay(alice, bob, XRP(100)), sig(dave)); + // All key types should work for payments + env(pay(alice, bob, XRP(10))); + env(pay(bob, carol, XRP(10))); + env(pay(carol, alice, XRP(10))); env.close(); - - Json::Value params; - params[jss::ledger_index] = env.current()->seq() - 1; - params[jss::transactions] = true; - params[jss::expand] = true; - auto const jrr = env.rpc("json", "ledger", to_string(params)); - std::cout << jrr << std::endl; } void testWithFeats(FeatureBitset features) { - testP256(features); + testP256SingleSign(features); + testP256WithOtherKeyTypes(features); } public: @@ -100,10 +119,15 @@ class PassKey_test : public beast::unit_test::suite { using namespace test::jtx; auto const sa = testable_amendments(); + + // Protocol-level tests (no env needed) + testP256KeyTypeDetection(); + + // Integration tests with env testWithFeats(sa); } }; BEAST_DEFINE_TESTSUITE(PassKey, protocol, xrpl); } // namespace test -} // namespace xrpl \ No newline at end of file +} // namespace xrpl