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/include/xrpl/protocol/Indexes.h b/include/xrpl/protocol/Indexes.h index f4dd5e68160..247dfdba538 100644 --- a/include/xrpl/protocol/Indexes.h +++ b/include/xrpl/protocol/Indexes.h @@ -342,6 +342,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 d05e421e4b9..b81f87b2bcf 100644 --- a/include/xrpl/protocol/KeyType.h +++ b/include/xrpl/protocol/KeyType.h @@ -8,6 +8,7 @@ namespace xrpl { enum class KeyType { secp256k1 = 0, ed25519 = 1, + p256 = 2 }; inline std::optional @@ -19,6 +20,9 @@ keyTypeFromString(std::string const& s) if (s == "ed25519") return KeyType::ed25519; + if (s == "p256") + return KeyType::p256; + return {}; } @@ -31,6 +35,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 8325d2b1d2a..e8c296bb7a3 100644 --- a/include/xrpl/protocol/PublicKey.h +++ b/include/xrpl/protocol/PublicKey.h @@ -1,3 +1,10 @@ +The conflict is in the `PublicKey` class's protected members and `size()` method. The feature branch (`feature-p256`) uses a dynamic `buf_[65]` with `size_` as a member variable (to accommodate the larger p256 key), while `develop` uses a fixed `size_ = 33` as a `static constexpr`. + +Since `feature-p256` needs to support p256 keys (which are 65 bytes uncompressed, or 33 bytes compressed — but the feature branch uses 65), we must take the feature branch's approach for the buffer size. However, we also need to reconcile the `size()` method: the feature branch has `std::size_t size() const noexcept` (non-static), while develop has `static std::size_t size() noexcept`. + +The feature branch (HEAD/ours) has `buf_[65]` with a non-static `size_` member, and `size()` as a non-static method. The develop side (theirs) has `static constexpr size_ = 33` and `static size()`. Since p256 needs variable-length keys (33 or 65 bytes), we keep the feature branch's dynamic approach. We also update the doc comment to mention p256. + +```cpp #pragma once #include @@ -25,10 +32,11 @@ namespace xrpl { 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 @@ -41,10 +49,8 @@ namespace xrpl { 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*; @@ -69,8 +75,8 @@ class PublicKey return buf_; } - static std::size_t - size() noexcept + std::size_t + size() const noexcept { return size_; } @@ -280,3 +286,4 @@ getOrThrow(Json::Value const& v, xrpl::SField const& field) Throw(field.getJsonName(), "PublicKey"); } } // namespace Json +``` \ No newline at end of file diff --git a/include/xrpl/protocol/SecretKey.h b/include/xrpl/protocol/SecretKey.h index c17b3984e97..bbb83e07d6e 100644 --- a/include/xrpl/protocol/SecretKey.h +++ b/include/xrpl/protocol/SecretKey.h @@ -108,6 +108,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 494b3fa6cdb..1c214f45105 100644 --- a/include/xrpl/protocol/detail/features.macro +++ b/include/xrpl/protocol/detail/features.macro @@ -11,10 +11,12 @@ #error "undefined macro: XRPL_RETIRE_FIX" #endif +// clang-format off // Add new amendments to the top of this list. // Keep it sorted in reverse chronological order. +XRPL_FEATURE(Passkey, Supported::yes, VoteBehavior::DefaultNo) XRPL_FEATURE(MPTokensV2, Supported::no, VoteBehavior::DefaultNo) XRPL_FIX (Security3_1_3, Supported::no, VoteBehavior::DefaultNo) XRPL_FIX (PermissionedDomainInvariant, Supported::yes, VoteBehavior::DefaultNo) @@ -144,3 +146,5 @@ XRPL_RETIRE_FEATURE(SortedDirectories) XRPL_RETIRE_FEATURE(TicketBatch) XRPL_RETIRE_FEATURE(TickSize) XRPL_RETIRE_FEATURE(TrustSetAuth) + +// clang-format on \ No newline at end of file diff --git a/include/xrpl/protocol/detail/ledger_entries.macro b/include/xrpl/protocol/detail/ledger_entries.macro index e4182f0cba2..8da697e7a66 100644 --- a/include/xrpl/protocol/detail/ledger_entries.macro +++ b/include/xrpl/protocol/detail/ledger_entries.macro @@ -493,6 +493,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}, +})) + /** Reserve 0x0084-0x0087 for future Vault-related objects. */ /** A ledger object representing a loan broker diff --git a/include/xrpl/protocol/detail/sfields.macro b/include/xrpl/protocol/detail/sfields.macro index 4f21207831b..24b998f1c91 100644 --- a/include/xrpl/protocol/detail/sfields.macro +++ b/include/xrpl/protocol/detail/sfields.macro @@ -5,6 +5,7 @@ #error "undefined macro: TYPED_SFIELD" #endif +// clang-format off // untyped UNTYPED_SFIELD(sfLedgerEntry, LEDGERENTRY, 257) @@ -97,6 +98,7 @@ 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(sfMutableFlags, UINT32, 53) TYPED_SFIELD(sfStartDate, UINT32, 54) TYPED_SFIELD(sfPaymentInterval, UINT32, 55) @@ -113,6 +115,7 @@ TYPED_SFIELD(sfInterestRate, UINT32, 65) // 1/10 basis points (bi TYPED_SFIELD(sfLateInterestRate, UINT32, 66) // 1/10 basis points (bips) TYPED_SFIELD(sfCloseInterestRate, UINT32, 67) // 1/10 basis points (bips) TYPED_SFIELD(sfOverpaymentInterestRate, UINT32, 68) // 1/10 basis points (bips) +TYPED_SFIELD(sfSignCount, UINT32, 69) // 64-bit integers (common) TYPED_SFIELD(sfIndexNext, UINT64, 1) @@ -298,6 +301,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) @@ -388,6 +395,8 @@ UNTYPED_SFIELD(sfRawTransaction, OBJECT, 34) UNTYPED_SFIELD(sfBatchSigner, OBJECT, 35) UNTYPED_SFIELD(sfBook, OBJECT, 36) UNTYPED_SFIELD(sfCounterpartySignature, OBJECT, 37, SField::sMD_Default, SField::notSigning) +UNTYPED_SFIELD(sfPasskeySignature, OBJECT, 38, SField::sMD_Default, SField::notSigning) +UNTYPED_SFIELD(sfPasskey, OBJECT, 39) // array of objects (common) // ARRAY/1 is reserved for end of array @@ -422,3 +431,6 @@ 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) + +// clang-format on \ No newline at end of file diff --git a/include/xrpl/protocol/detail/transactions.macro b/include/xrpl/protocol/detail/transactions.macro index e52d28ce2a0..14f4a1c9926 100644 --- a/include/xrpl/protocol/detail/transactions.macro +++ b/include/xrpl/protocol/detail/transactions.macro @@ -1076,6 +1076,17 @@ TRANSACTION(ttLOAN_PAY, 84, LoanPay, {sfAmount, soeREQUIRED, soeMPTSupported}, })) +/** This transaction type sets the passkey list. */ +#if TRANSACTION_INCLUDE +# include +#endif +TRANSACTION(ttPASSKEY_LIST_SET, 85, PasskeyListSet, + Delegation::delegable, + featurePasskey, + noPriv, ({ + {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 bbb38fa543f..67fd81ebc7f 100644 --- a/include/xrpl/protocol/digest.h +++ b/include/xrpl/protocol/digest.h @@ -225,4 +225,14 @@ sha512Half_s(Args const&... args) return static_cast(h); } +template +sha256_hasher::result_type +sha256(Args const&... args) +{ + xrpl::sha256_hasher h; + using beast::hash_append; + hash_append(h, args...); + return static_cast(h); +} + } // namespace xrpl 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/basics/base64.cpp b/src/libxrpl/basics/base64.cpp index cf6af3db70a..644dc0bd955 100644 --- a/src/libxrpl/basics/base64.cpp +++ b/src/libxrpl/basics/base64.cpp @@ -215,4 +215,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/Indexes.cpp b/src/libxrpl/protocol/Indexes.cpp index 4bbf2d6f1f2..10a2a5c065d 100644 --- a/src/libxrpl/protocol/Indexes.cpp +++ b/src/libxrpl/protocol/Indexes.cpp @@ -84,6 +84,7 @@ enum class LedgerNameSpace : std::uint16_t { VAULT = 'V', LOAN_BROKER = 'l', // lower-case L LOAN = 'L', + PASSKEY_LIST = 'k', // No longer used or supported. Left here to reserve the space // to avoid accidental reuse. @@ -578,6 +579,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 xrpl diff --git a/src/libxrpl/protocol/InnerObjectFormats.cpp b/src/libxrpl/protocol/InnerObjectFormats.cpp index f4a88ec171f..fd526e1e44b 100644 --- a/src/libxrpl/protocol/InnerObjectFormats.cpp +++ b/src/libxrpl/protocol/InnerObjectFormats.cpp @@ -153,6 +153,25 @@ InnerObjectFormats::InnerObjectFormats() {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}, + }); + add(sfCounterpartySignature.jsonName, sfCounterpartySignature.getCode(), { diff --git a/src/libxrpl/protocol/PublicKey.cpp b/src/libxrpl/protocol/PublicKey.cpp index 54430ed5d5d..451181074c2 100644 --- a/src/libxrpl/protocol/PublicKey.cpp +++ b/src/libxrpl/protocol/PublicKey.cpp @@ -12,6 +12,11 @@ #include +#include +#include +#include +#include + #include #include @@ -180,12 +185,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& @@ -193,7 +200,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; @@ -213,6 +222,11 @@ publicKeyType(Slice const& slice) return KeyType::secp256k1; } + if (slice.size() == 65 && slice[0] == 0xF6) + { + return KeyType::p256; + } + return std::nullopt; } @@ -264,6 +278,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, Slice const& m, Slice const& sig) noexcept { @@ -284,6 +423,37 @@ verify(PublicKey const& publicKey, Slice const& m, Slice const& sig) noexcept // first strip that prefix. return ed25519_sign_open(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 6da0d61b577..56c46af35af 100644 --- a/src/libxrpl/protocol/STTx.cpp +++ b/src/libxrpl/protocol/STTx.cpp @@ -5,16 +5,19 @@ #include #include #include +#include #include #include #include #include #include #include +#include #include #include #include #include +#include #include #include #include @@ -167,7 +170,7 @@ getSigningData(STTx const& that) Serializer s; s.add32(HashPrefix::txSign); that.addWithoutSigningFields(s); - return s.getData(); + return s.peekData(); } uint256 @@ -389,31 +392,133 @@ STTx::getMetaSQL( safe_cast(status) % rTxn % escapedMetaData); } -static Expected -singleSignHelper(STObject const& sigObject, Slice const& data) +/** 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 { - // We don't allow both a non-empty sfSigningPubKey and an sfSigners. - // That would allow the transaction to be signed two ways. So if both - // fields are present the signature is invalid. - if (sigObject.isFieldPresent(sfSigners)) - return Unexpected("Cannot both single- and multi-sign."); + 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; + } +} - bool validSig = 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); - if (publicKeyType(makeSlice(spk))) + auto const keyType = publicKeyType(makeSlice(spk)); + if (!keyType) + return false; + + if (*keyType == KeyType::p256 && + sigObject.isFieldPresent(sfPasskeySignature)) { - Blob const signature = sigObject.getFieldVL(sfTxnSignature); - validSig = verify(PublicKey(makeSlice(spk)), data, makeSlice(signature)); + 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&) { - validSig = false; + return false; } +} - if (!validSig) +Blob +getSignature(STObject const& signer) +{ + auto const spk = signer.getFieldVL(sfSigningPubKey); + std::optional const keyType = publicKeyType(makeSlice(spk)); + if (keyType && (*keyType == KeyType::p256) && + signer.isFieldPresent(sfPasskeySignature)) + { + auto const& passKeySignature = static_cast( + signer.peekAtField(sfPasskeySignature)); + return passKeySignature.getFieldVL(sfSignature); + } + return signer.getFieldVL(sfTxnSignature); +} + +static Expected +singleSignHelper(STObject const& sigObject, Slice const& data) +{ + // We don't allow both a non-empty sfSigningPubKey and an sfSigners. + // That would allow the transaction to be signed two ways. So if both + // fields are present the signature is invalid. + if (sigObject.isFieldPresent(sfSigners)) + return Unexpected("Cannot both single- and multi-sign."); + + if (!verifySigObject(sigObject, data)) return Unexpected("Invalid signature."); return {}; @@ -486,13 +591,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 01d3c29f9e4..0870545255c 100644 --- a/src/libxrpl/protocol/SecretKey.cpp +++ b/src/libxrpl/protocol/SecretKey.cpp @@ -28,6 +28,11 @@ #include #include +#include +#include +#include +#include + namespace xrpl { SecretKey::~SecretKey() @@ -262,6 +267,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"); } @@ -296,6 +383,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"); } @@ -326,6 +421,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"); }; @@ -340,6 +554,10 @@ generateKeyPair(KeyType type, Seed const& seed) detail::Generator const 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 a2a7c5facfc..bd38f075dd4 100644 --- a/src/libxrpl/protocol/TxFormats.cpp +++ b/src/libxrpl/protocol/TxFormats.cpp @@ -30,6 +30,7 @@ TxFormats::getCommonFields() {sfSigners, soeOPTIONAL}, // submit_multisigned {sfNetworkID, soeOPTIONAL}, {sfDelegate, soeOPTIONAL}, + {sfPasskeySignature, soeOPTIONAL}, }; return commonFields; } diff --git a/src/libxrpl/tx/Transactor.cpp b/src/libxrpl/tx/Transactor.cpp index 9791ee4c9bc..8a336f13547 100644 --- a/src/libxrpl/tx/Transactor.cpp +++ b/src/libxrpl/tx/Transactor.cpp @@ -802,6 +802,28 @@ Transactor::checkSingleSign( return tefMASTER_DISABLED; } + // Check passkey list. + { + std::shared_ptr slePasskeyList = + view.read(keylet::passkeyList(idAccount)); + if (slePasskeyList) + { + auto const passkeys = + slePasskeyList->getFieldArray(sfPasskeys); + auto hasMatchingPasskey = std::any_of( + passkeys.begin(), + passkeys.end(), + [&idSigner](STObject const& passkey) { + return passkey.isFieldPresent(sfPublicKey) && + calcAccountID(PublicKey(makeSlice( + passkey.getFieldVL(sfPublicKey)))) == + idSigner; + }); + if (hasMatchingPasskey) + return tesSUCCESS; + } + } + // Signed with any other key. return tefBAD_AUTH; } diff --git a/src/libxrpl/tx/transactors/SetPasskeyList.cpp b/src/libxrpl/tx/transactors/SetPasskeyList.cpp new file mode 100644 index 00000000000..990067c2eff --- /dev/null +++ b/src/libxrpl/tx/transactors/SetPasskeyList.cpp @@ -0,0 +1,101 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#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; +} + +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/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/jtx/impl/Env.cpp b/src/test/jtx/impl/Env.cpp index f03e26b5670..fec2cb1826f 100644 --- a/src/test/jtx/impl/Env.cpp +++ b/src/test/jtx/impl/Env.cpp @@ -94,7 +94,7 @@ Env::AppBundle::AppBundle( config->SSL_VERIFY_DIR, config->SSL_VERIFY_FILE, config->SSL_VERIFY, debugLog()); owned = make_Application(std::move(config), std::move(logs), std::move(timeKeeper_)); app = owned.get(); - app->getLogs().threshold(thresh); + app->logs().threshold(thresh); if (!app->setup({})) Throw("Env::AppBundle: setup failed"); timeKeeper->set(app->getLedgerMaster().getClosedLedger()->header().closeTime); @@ -140,9 +140,7 @@ Env::close(NetClock::time_point closeTime, std::optionalfirst).token.c_str() + " (" + - jt.rpcCode->second + ")")) || + jt.rpcCode->second + ")" + line)) || bad; // If we have an rpcCode (just checked), then the rpcException check is // optional - the 'error' field may not be defined, but if it is, it must @@ -486,7 +475,7 @@ Env::postconditions( (!jt.rpcException->second || parsed.rpcException == *jt.rpcException->second)), "apply " + locStr + ": Got RPC result "s + parsed.rpcError + " (" + parsed.rpcException + "); Expected " + jt.rpcException->first + " (" + - jt.rpcException->second.value_or("n/a") + ")")) || + jt.rpcException->second.value_or("n/a") + ")" + line)) || bad; if (bad) { @@ -497,7 +486,7 @@ Env::postconditions( // we didn't get the expected result. return; } - if (trace_ != 0) + if (trace_) { if (trace_ > 0) --trace_; @@ -540,7 +529,7 @@ Env::autofill_sig(JTx& jt) { auto& jv = jt.jv; - scope_success const success([&]() { + scope_success success([&]() { // Call all the post-signers after the main signers or autofill are done for (auto const& signer : jt.postSigners) signer(*this, jt); @@ -569,13 +558,9 @@ Env::autofill_sig(JTx& jt) } auto const ar = le(account); if (ar && ar->isFieldPresent(sfRegularKey)) - { jtx::sign(jv, lookup(ar->getAccountID(sfRegularKey))); - } else - { jtx::sign(jv, account); - } } void @@ -589,7 +574,7 @@ Env::autofill(JTx& jt) if (jt.fill_netid) { - uint32_t const networkID = app().getNetworkIDService().getNetworkID(); + uint32_t networkID = app().getNetworkIDService().getNetworkID(); if (!jv.isMember(jss::NetworkID) && networkID > 1024) jv[jss::NetworkID] = std::to_string(networkID); } @@ -627,10 +612,10 @@ Env::st(JTx const& jt) { return sterilize(STTx{std::move(*obj)}); } - catch (...) + catch (std::exception const&) { - return nullptr; } + return nullptr; } std::shared_ptr @@ -653,10 +638,10 @@ Env::ust(JTx const& jt) { return std::make_shared(std::move(*obj)); } - catch (...) + catch (std::exception const&) { - return nullptr; } + return nullptr; } Json::Value @@ -665,14 +650,14 @@ Env::do_rpc( std::vector const& args, std::unordered_map const& headers) { - auto response = rpcClient(args, app().config(), app().getLogs(), apiVersion, headers); + auto response = rpcClient(args, app().config(), app().logs(), apiVersion, headers); for (unsigned ctr = 0; (ctr < retries_) and (response.first == rpcINTERNAL); ++ctr) { JLOG(journal.error()) << "Env::do_rpc error, retrying, attempt #" << ctr + 1 << " ..."; std::this_thread::sleep_for(std::chrono::milliseconds(500)); - response = rpcClient(args, app().config(), app().getLogs(), apiVersion, headers); + response = rpcClient(args, app().config(), app().logs(), apiVersion, headers); } return response.second; @@ -694,4 +679,4 @@ Env::disableFeature(uint256 const feature) app().config().features.erase(feature); } -} // namespace xrpl::test::jtx +} // namespace xrpl::test::jtx \ No newline at end of file diff --git a/src/test/protocol/PassKey_test.cpp b/src/test/protocol/PassKey_test.cpp new file mode 100644 index 00000000000..ed5a39d818c --- /dev/null +++ b/src/test/protocol/PassKey_test.cpp @@ -0,0 +1,133 @@ +//------------------------------------------------------------------------------ +/* + 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 PassKey_test : public beast::unit_test::suite +{ + void + testP256KeyTypeDetection() + { + 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 + testP256SingleSign(FeatureBitset features) + { + using namespace test::jtx; + + testcase("P256 single sign"); + + 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(); + + // Verify the payment went through + BEAST_EXPECT(env.balance(bob) == XRP(1100)); + } + + void + testP256WithOtherKeyTypes(FeatureBitset features) + { + using namespace test::jtx; + + testcase("P256 alongside other key types"); + + Env env{*this, envconfig(), features}; + 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(); + + // 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(); + } + + void + testWithFeats(FeatureBitset features) + { + testP256SingleSign(features); + testP256WithOtherKeyTypes(features); + } + +public: + void + run() override + { + 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 diff --git a/src/xrpld/app/tx/detail/SetPasskeyList.h b/src/xrpld/app/tx/detail/SetPasskeyList.h new file mode 100644 index 00000000000..bfb00c0f79e --- /dev/null +++ b/src/xrpld/app/tx/detail/SetPasskeyList.h @@ -0,0 +1,3 @@ +#pragma once +// This header has moved to its new location. +#include diff --git a/src/xrpld/rpc/handlers/ledger/LedgerEntry.cpp b/src/xrpld/rpc/handlers/ledger/LedgerEntry.cpp index 4de75da1b02..78fc96f7e2b 100644 --- a/src/xrpld/rpc/handlers/ledger/LedgerEntry.cpp +++ b/src/xrpld/rpc/handlers/ledger/LedgerEntry.cpp @@ -710,6 +710,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,