mpp-rb 0.1.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 033c7cfeecb0955e5241cac709e73c0ebd7c4fc7761881614c5ce11128b342f7
4
- data.tar.gz: d5611e4ed86e15ad49f04ca482a44b07b89e4c107cec0adc0e2a647a8fcfd04a
3
+ metadata.gz: 7accbb98302b70cc76e32afc99bf2b5672c445194f79e9fd1cdfb0e5d7f058dd
4
+ data.tar.gz: 3538fdc0cd0851816ac29f294de441e18fefb45f0bd00b44cad6295ca6a1e1f1
5
5
  SHA512:
6
- metadata.gz: 6b88426e6a71a1c46a089f2eeabf502a8ba58267dafaf7a800a8781befcb043313112f3813d8def6aa03fccfe264f03d019e9da8f6c9a2e03262a55f2ae8e500
7
- data.tar.gz: 15690c57ad85fe973be97c9c0fd0e199c865dd65793ffe86507ee118a32589e42055cf61e4bc04e9aad86e84587d882bf7e9ce5a59d8e6fe293fd3ab839d6732
6
+ metadata.gz: b6cd8af4a9376b0db632aab578f1d9fb0e9e6d3327c926e40f35badb16750d06a15774fe7444debde1a402a7d3b51133d25bfd43d577de726fe646c35bc4f522
7
+ data.tar.gz: 2997bf04f49594dce0246c917075d5aba8a9f974e6ed4ff6cda586e8ae9361db110cf5197ee977fa8d795b5f0affc701f34170f4c75654f5485853608fb6776c
@@ -80,6 +80,19 @@ module Mpp
80
80
  memo_server == fingerprint(server_id)
81
81
  end
82
82
 
83
+ # Verify challenge-bound nonce in memo.
84
+ def verify_challenge_binding(memo, challenge_id)
85
+ return false unless challenge_id
86
+ return false unless mpp_memo?(memo)
87
+
88
+ begin
89
+ memo_nonce = [memo[52, 14]].pack("H*")
90
+ rescue ArgumentError
91
+ return false
92
+ end
93
+ memo_nonce == keccak256(challenge_id.encode(Encoding::UTF_8))[0, 7]
94
+ end
95
+
83
96
  # Decoded memo structure.
84
97
  DecodedMemo = Data.define(:version, :server_fingerprint, :client_fingerprint, :nonce)
85
98
 
@@ -17,8 +17,8 @@ module Mpp
17
17
  Kernel.require "rlp"
18
18
 
19
19
  sender_sig = signed_tx.sender_signature
20
- sig_bytes = sender_sig.respond_to?(:to_bytes) ? sender_sig.to_bytes : sender_sig.to_s.b
21
- sender_addr = signed_tx.sender_address.to_s.b
20
+ sig_bytes = normalize_signature(sender_sig.respond_to?(:to_bytes) ? sender_sig.to_bytes : sender_sig.to_s.b)
21
+ sender_addr = pack_hex(signed_tx.sender_address)
22
22
 
23
23
  fields = [
24
24
  signed_tx.chain_id,
@@ -26,12 +26,12 @@ module Mpp
26
26
  signed_tx.max_fee_per_gas,
27
27
  signed_tx.gas_limit,
28
28
  signed_tx.calls.map(&:as_rlp_list),
29
- signed_tx.access_list.map(&:as_rlp_list),
29
+ signed_tx.access_list.map { |entry| entry.respond_to?(:as_rlp_list) ? entry.as_rlp_list : entry },
30
30
  signed_tx.nonce_key,
31
31
  signed_tx.nonce,
32
32
  encode_optional_uint(signed_tx.valid_before),
33
33
  encode_optional_uint(signed_tx.valid_after),
34
- signed_tx.fee_token ? signed_tx.fee_token.to_s.b : "".b,
34
+ signed_tx.fee_token ? pack_hex(signed_tx.fee_token) : "".b,
35
35
  sender_addr,
36
36
  signed_tx.tempo_authorization_list.to_a
37
37
  ]
@@ -39,7 +39,7 @@ module Mpp
39
39
  fields << RLP.decode(signed_tx.key_authorization) if signed_tx.key_authorization
40
40
  fields << sig_bytes
41
41
 
42
- [TYPE_ID].pack("C") + RLP.include(fields)
42
+ [TYPE_ID].pack("C") + RLP.encode(fields)
43
43
  end
44
44
 
45
45
  # Decode a 0x78 fee payer envelope.
@@ -58,7 +58,7 @@ module Mpp
58
58
 
59
59
  # 15 fields = key_authorization present (index 13), signature at 14
60
60
  # 14 fields = no key_authorization, signature at 13
61
- key_authorization = (RLP.include(decoded[13]) if decoded.length == 15)
61
+ key_authorization = (RLP.encode(decoded[13]) if decoded.length == 15)
62
62
 
63
63
  [decoded, sender_address.to_s.b, sender_signature.to_s.b, key_authorization]
64
64
  end
@@ -68,6 +68,21 @@ module Mpp
68
68
 
69
69
  value.is_a?(Integer) ? value : value.to_i
70
70
  end
71
+
72
+ def pack_hex(value)
73
+ [value.to_s.delete_prefix("0x")].pack("H*")
74
+ end
75
+
76
+ def normalize_signature(signature)
77
+ bytes = signature.b
78
+ Kernel.raise ArgumentError, "signature must be 65 bytes, got #{bytes.bytesize}" unless bytes.bytesize == 65
79
+
80
+ v = bytes.getbyte(64)
81
+ parity = (v >= 27) ? v - 27 : v
82
+ Kernel.raise ArgumentError, "signature parity must be 0 or 1, got #{v}" unless [0, 1].include?(parity)
83
+
84
+ bytes[0, 64] + [parity].pack("C")
85
+ end
71
86
  end
72
87
  end
73
88
  end
@@ -51,12 +51,12 @@ module Mpp
51
51
  case payload_data["type"]
52
52
  when "hash"
53
53
  payload = Schemas::HashCredentialPayload.new(type: "hash", hash: payload_data["hash"])
54
- verify_hash(payload, req)
54
+ verify_hash(payload, req, credential: credential)
55
55
  when "transaction"
56
56
  payload = Schemas::TransactionCredentialPayload.new(
57
57
  type: "transaction", signature: payload_data["signature"]
58
58
  )
59
- verify_transaction(payload, req)
59
+ verify_transaction(payload, req, credential: credential)
60
60
  when "proof"
61
61
  payload = Schemas::ProofCredentialPayload.new(
62
62
  type: "proof", signature: payload_data["signature"]
@@ -75,7 +75,7 @@ module Mpp
75
75
  @rpc_url
76
76
  end
77
77
 
78
- def verify_hash(payload, request)
78
+ def verify_hash(payload, request, credential:)
79
79
  if @store
80
80
  store_key = "mpp:charge:#{payload.hash.downcase}"
81
81
  raise Mpp::VerificationError, "Transaction hash already used" unless @store.put_if_absent(store_key,
@@ -87,17 +87,17 @@ module Mpp
87
87
 
88
88
  raise Mpp::VerificationError, "Transaction not found" unless result
89
89
  raise Mpp::VerificationError, "Transaction reverted" unless result["status"] == "0x1"
90
- unless verify_transfer_logs(
91
- result, request
92
- )
90
+ matched_logs = match_transfer_logs(result, request, expected_sender: result["from"])
91
+ unless matched_logs.any?
93
92
  raise Mpp::VerificationError,
94
93
  "Transaction must contain a Transfer log matching request parameters"
95
94
  end
95
+ assert_challenge_bound_memo(matched_logs, credential.challenge) unless request.method_details.memo
96
96
 
97
97
  Mpp::Receipt.success(payload.hash)
98
98
  end
99
99
 
100
- def verify_transaction(payload, request)
100
+ def verify_transaction(payload, request, credential:)
101
101
  validate_transaction_payload(payload.signature, request)
102
102
 
103
103
  raw_tx = payload.signature
@@ -128,18 +128,23 @@ module Mpp
128
128
 
129
129
  raise Mpp::VerificationError, "Transaction receipt not found after retries" unless receipt_data
130
130
  raise Mpp::VerificationError, "Transaction reverted" unless receipt_data["status"] == "0x1"
131
- unless verify_transfer_logs(
132
- receipt_data, request
133
- )
131
+ matched_logs = match_transfer_logs(receipt_data, request, expected_sender: receipt_data["from"])
132
+ unless matched_logs.any?
134
133
  raise Mpp::VerificationError,
135
134
  "Transaction must contain a Transfer log matching request parameters"
136
135
  end
136
+ assert_challenge_bound_memo(matched_logs, credential.challenge) unless request.method_details.memo
137
137
 
138
138
  Mpp::Receipt.success(tx_hash)
139
139
  end
140
140
 
141
141
  def verify_transfer_logs(receipt, request, expected_sender: nil)
142
+ match_transfer_logs(receipt, request, expected_sender: expected_sender).any?
143
+ end
144
+
145
+ def match_transfer_logs(receipt, request, expected_sender: nil)
142
146
  expected_memo = request.method_details.memo
147
+ matched_logs = []
143
148
 
144
149
  (receipt["logs"] || []).each do |log|
145
150
  next unless log["address"]&.downcase == request.currency.downcase
@@ -164,19 +169,42 @@ module Mpp
164
169
  memo = topics[3]
165
170
  memo_clean = expected_memo.downcase
166
171
  memo_clean = "0x#{memo_clean}" unless memo_clean.start_with?("0x")
167
- return true if amount == Integer(request.amount) && memo.downcase == memo_clean
172
+ if amount == Integer(request.amount) && memo.downcase == memo_clean
173
+ matched_logs << {kind: :memo, memo: memo}
174
+ end
168
175
  else
169
- next unless topics[0] == TRANSFER_TOPIC
176
+ case topics[0]
177
+ when TRANSFER_WITH_MEMO_TOPIC
178
+ next if topics.length < 4
179
+
180
+ data = log.fetch("data", "0x")
181
+ next if data.length < 66
182
+
183
+ amount = data[2, 64].to_i(16)
184
+ matched_logs << {kind: :memo, memo: topics[3]} if amount == Integer(request.amount)
185
+ when TRANSFER_TOPIC
186
+ data = log.fetch("data", "0x")
187
+ next if data.length < 66
188
+
189
+ amount = data.delete_prefix("0x").to_i(16)
190
+ matched_logs << {kind: :transfer} if amount == Integer(request.amount)
191
+ end
192
+ end
193
+ end
170
194
 
171
- data = log.fetch("data", "0x")
172
- next if data.length < 66
195
+ matched_logs.sort_by { |log| (log[:kind] == :memo) ? 0 : 1 }
196
+ end
173
197
 
174
- amount = data.delete_prefix("0x").to_i(16)
175
- return true if amount == Integer(request.amount)
176
- end
198
+ def assert_challenge_bound_memo(matched_logs, challenge)
199
+ bound = matched_logs.any? do |log|
200
+ log[:kind] == :memo &&
201
+ Attribution.verify_server(log[:memo], challenge.realm) &&
202
+ Attribution.verify_challenge_binding(log[:memo], challenge.id)
177
203
  end
178
204
 
179
- false
205
+ return if bound
206
+
207
+ raise Mpp::VerificationError, "Payment verification failed: memo is not bound to this challenge"
180
208
  end
181
209
 
182
210
  def validate_transaction_payload(signature, request)
@@ -335,7 +363,7 @@ module Mpp
335
363
  max_fee_per_gas: int.call(decoded[2]),
336
364
  gas_limit: int.call(decoded[3]),
337
365
  calls: calls,
338
- access_list: Transaction::EMPTY_LIST,
366
+ access_list: decoded[5] || Transaction::EMPTY_LIST,
339
367
  nonce_key: int.call(decoded[6]),
340
368
  nonce: int.call(decoded[7]),
341
369
  valid_before: int.call(decoded[8]),
@@ -350,7 +378,7 @@ module Mpp
350
378
 
351
379
  # Verify sender signature
352
380
  sender_hash = tx_for_recovery.signature_hash
353
- recovered = Eth::Key.personal_recover(sender_hash, "0x#{sender_sig.unpack1("H*")}")
381
+ recovered = Eth::Signature.recover(sender_hash, "0x#{sender_sig.unpack1("H*")}")
354
382
  recovered_addr = Eth::Util.public_key_to_address(recovered).to_s
355
383
  envelope_addr = "0x#{sender_addr_bytes.unpack1("H*")}"
356
384
 
@@ -364,7 +392,7 @@ module Mpp
364
392
 
365
393
  tx_to_sign = tx_for_recovery.with(fee_token: resolved_fee_token)
366
394
 
367
- # Fee payer signs over fields including sender_signature
395
+ # Fee payer signs the 0x78 payload, which identifies the recovered sender.
368
396
  fee_payer_hash = tx_to_sign.fee_payer_signature_hash
369
397
  fee_payer_sig = fee_payer.sign_hash(fee_payer_hash)
370
398
 
@@ -45,29 +45,50 @@ module Mpp
45
45
  require_eth!
46
46
  require_rlp!
47
47
 
48
- Eth::Util.keccak256([TYPE_ID].pack("C") + RLP.encode(unsigned_rlp_fields))
48
+ Eth::Util.keccak256([TYPE_ID].pack("C") + RLP.encode(signing_rlp_fields))
49
49
  end
50
50
 
51
- # Hash for fee payer to sign — includes sender_signature in the RLP.
51
+ # Hash for fee payer to sign.
52
52
  def fee_payer_signature_hash
53
53
  require_eth!
54
54
  require_rlp!
55
55
 
56
- fields = unsigned_rlp_fields
57
- fields.insert(11, sender_signature)
58
- Eth::Util.keccak256([TYPE_ID].pack("C") + RLP.encode(fields))
56
+ Eth::Util.keccak256([FeePayer::TYPE_ID].pack("C") + RLP.encode(fee_payer_signing_rlp_fields))
59
57
  end
60
58
 
61
59
  private
62
60
 
63
61
  def rlp_fields
62
+ fields = signing_rlp_fields
63
+ fields << signature_envelope(sender_signature) if sender_signature
64
+ fields
65
+ end
66
+
67
+ def signing_rlp_fields
64
68
  fields = unsigned_rlp_fields
65
- fields.insert(11, sender_signature)
66
- fields.insert(12, fee_payer_signature || EMPTY_SIGNATURE)
69
+ fields << key_authorization if key_authorization
67
70
  fields
68
71
  end
69
72
 
70
73
  def unsigned_rlp_fields
74
+ [
75
+ chain_id,
76
+ max_priority_fee_per_gas,
77
+ max_fee_per_gas,
78
+ gas_limit,
79
+ calls.map(&:as_rlp_list),
80
+ access_list || EMPTY_LIST,
81
+ nonce_key,
82
+ nonce,
83
+ encode_optional_uint(valid_before),
84
+ encode_optional_uint(valid_after),
85
+ fee_token ? pack_hex(fee_token) : "".b,
86
+ fee_payer_field,
87
+ tempo_authorization_list || EMPTY_LIST
88
+ ]
89
+ end
90
+
91
+ def fee_payer_signing_rlp_fields
71
92
  fields = [
72
93
  chain_id,
73
94
  max_priority_fee_per_gas,
@@ -80,12 +101,48 @@ module Mpp
80
101
  encode_optional_uint(valid_before),
81
102
  encode_optional_uint(valid_after),
82
103
  fee_token ? pack_hex(fee_token) : "".b,
104
+ pack_hex(sender_address),
83
105
  tempo_authorization_list || EMPTY_LIST
84
106
  ]
85
107
  fields << key_authorization if key_authorization
86
108
  fields
87
109
  end
88
110
 
111
+ def fee_payer_field
112
+ return signature_tuple(fee_payer_signature) if fee_payer_signature && fee_payer_signature != EMPTY_SIGNATURE
113
+ return EMPTY_SIGNATURE if fee_token.nil?
114
+
115
+ "".b
116
+ end
117
+
118
+ def signature_tuple(signature)
119
+ normalized = normalized_signature(signature)
120
+ [
121
+ normalized.getbyte(64).zero? ? "".b : normalized[64],
122
+ trim_leading_zeroes(normalized[0, 32]),
123
+ trim_leading_zeroes(normalized[32, 32])
124
+ ]
125
+ end
126
+
127
+ def signature_envelope(signature)
128
+ normalized_signature(signature)
129
+ end
130
+
131
+ def normalized_signature(signature)
132
+ bytes = signature.b
133
+ raise ArgumentError, "signature must be 65 bytes, got #{bytes.bytesize}" unless bytes.bytesize == 65
134
+
135
+ v = bytes.getbyte(64)
136
+ parity = (v >= 27) ? v - 27 : v
137
+ raise ArgumentError, "signature parity must be 0 or 1, got #{v}" unless [0, 1].include?(parity)
138
+
139
+ bytes[0, 64] + [parity].pack("C")
140
+ end
141
+
142
+ def trim_leading_zeroes(value)
143
+ value.sub(/\A\x00+/n, "")
144
+ end
145
+
89
146
  def pack_hex(value)
90
147
  [value.delete_prefix("0x")].pack("H*")
91
148
  end
data/lib/mpp/version.rb CHANGED
@@ -2,5 +2,5 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  module Mpp
5
- VERSION = "0.1.1"
5
+ VERSION = "0.1.2"
6
6
  end
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: mpp-rb
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.1.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Stripe
8
- autorequire:
9
8
  bindir: bin
10
9
  cert_chain: []
11
- date: 2026-04-23 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: base64
@@ -26,7 +25,6 @@ dependencies:
26
25
  version: '0.3'
27
26
  description: Ruby SDK for the Machine Payments Protocol (MPP) — an HTTP 402 Payment
28
27
  Authentication scheme.
29
- email:
30
28
  executables: []
31
29
  extensions: []
32
30
  extra_rdoc_files: []
@@ -89,7 +87,6 @@ licenses:
89
87
  metadata:
90
88
  rubygems_mfa_required: 'true'
91
89
  source_code_uri: https://github.com/stripe/mpp-rb
92
- post_install_message:
93
90
  rdoc_options: []
94
91
  require_paths:
95
92
  - lib
@@ -104,8 +101,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
104
101
  - !ruby/object:Gem::Version
105
102
  version: '0'
106
103
  requirements: []
107
- rubygems_version: 3.5.22
108
- signing_key:
104
+ rubygems_version: 3.6.9
109
105
  specification_version: 4
110
106
  summary: HTTP 402 Payment Authentication for Ruby
111
107
  test_files: []