mpp-rb 0.1.0
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 +7 -0
- data/LICENSE +21 -0
- data/README.md +133 -0
- data/lib/mpp/body_digest.rb +37 -0
- data/lib/mpp/challenge.rb +115 -0
- data/lib/mpp/challenge_echo.rb +19 -0
- data/lib/mpp/challenge_id.rb +54 -0
- data/lib/mpp/client/transport.rb +137 -0
- data/lib/mpp/client.rb +9 -0
- data/lib/mpp/credential.rb +20 -0
- data/lib/mpp/errors.rb +190 -0
- data/lib/mpp/expires.rb +60 -0
- data/lib/mpp/extensions/mcp/capabilities.rb +23 -0
- data/lib/mpp/extensions/mcp/constants.rb +17 -0
- data/lib/mpp/extensions/mcp/decorator.rb +44 -0
- data/lib/mpp/extensions/mcp/errors.rb +110 -0
- data/lib/mpp/extensions/mcp/types.rb +205 -0
- data/lib/mpp/extensions/mcp/verify.rb +152 -0
- data/lib/mpp/extensions/mcp.rb +16 -0
- data/lib/mpp/json.rb +32 -0
- data/lib/mpp/methods/stripe/charge_intent.rb +90 -0
- data/lib/mpp/methods/stripe/client_method.rb +42 -0
- data/lib/mpp/methods/stripe/defaults.rb +14 -0
- data/lib/mpp/methods/stripe/stripe_method.rb +63 -0
- data/lib/mpp/methods/stripe.rb +14 -0
- data/lib/mpp/methods/tempo/account.rb +52 -0
- data/lib/mpp/methods/tempo/attribution.rb +112 -0
- data/lib/mpp/methods/tempo/client_method.rb +259 -0
- data/lib/mpp/methods/tempo/defaults.rb +77 -0
- data/lib/mpp/methods/tempo/fee_payer_envelope.rb +74 -0
- data/lib/mpp/methods/tempo/intents.rb +377 -0
- data/lib/mpp/methods/tempo/keychain.rb +31 -0
- data/lib/mpp/methods/tempo/proof.rb +127 -0
- data/lib/mpp/methods/tempo/rpc.rb +60 -0
- data/lib/mpp/methods/tempo/schemas.rb +96 -0
- data/lib/mpp/methods/tempo/transaction.rb +144 -0
- data/lib/mpp/methods/tempo.rb +22 -0
- data/lib/mpp/parsing.rb +252 -0
- data/lib/mpp/receipt.rb +31 -0
- data/lib/mpp/secure_compare.rb +25 -0
- data/lib/mpp/server/decorator.rb +32 -0
- data/lib/mpp/server/defaults.rb +45 -0
- data/lib/mpp/server/intent.rb +40 -0
- data/lib/mpp/server/method.rb +27 -0
- data/lib/mpp/server/middleware.rb +51 -0
- data/lib/mpp/server/mpp_handler.rb +97 -0
- data/lib/mpp/server/verify.rb +129 -0
- data/lib/mpp/server.rb +15 -0
- data/lib/mpp/store.rb +49 -0
- data/lib/mpp/units.rb +57 -0
- data/lib/mpp/version.rb +6 -0
- data/lib/mpp-rb.rb +3 -0
- data/lib/mpp.rb +68 -0
- metadata +111 -0
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module Mpp
|
|
7
|
+
module Extensions
|
|
8
|
+
module MCP
|
|
9
|
+
extend T::Sig
|
|
10
|
+
|
|
11
|
+
DEFAULT_CHALLENGE_TTL = T.let(5 * 60, Integer) # 5 minutes in seconds
|
|
12
|
+
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
# Verify a payment credential or generate a new challenge.
|
|
16
|
+
# Returns MCPChallenge or [MCPCredential, MCPReceipt].
|
|
17
|
+
sig { params(meta: T.untyped, intent: T.untyped, request: T.untyped, realm: String, secret_key: String, method: T.nilable(String), expires_in: Integer, description: T.nilable(String)).returns(T.untyped) }
|
|
18
|
+
def verify_or_challenge(meta:, intent:, request:, realm:, secret_key:,
|
|
19
|
+
method: nil, expires_in: DEFAULT_CHALLENGE_TTL, description: nil)
|
|
20
|
+
method_name = method || "tempo"
|
|
21
|
+
meta ||= {}
|
|
22
|
+
|
|
23
|
+
new_challenge = Kernel.lambda {
|
|
24
|
+
create_challenge(
|
|
25
|
+
method: method_name,
|
|
26
|
+
intent_name: intent.name,
|
|
27
|
+
request: request,
|
|
28
|
+
realm: realm,
|
|
29
|
+
secret_key: secret_key,
|
|
30
|
+
expires_in: expires_in,
|
|
31
|
+
description: description
|
|
32
|
+
)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
credential_data = meta[META_CREDENTIAL]
|
|
36
|
+
return new_challenge.call unless credential_data
|
|
37
|
+
|
|
38
|
+
begin
|
|
39
|
+
mcp_credential = MCPCredential.from_dict(credential_data)
|
|
40
|
+
rescue KeyError, TypeError, NoMethodError => e
|
|
41
|
+
Kernel.raise MalformedCredentialError.new(detail: "Invalid credential structure: #{e}")
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Stateless challenge verification
|
|
45
|
+
echoed = mcp_credential.challenge
|
|
46
|
+
expected_id = Mpp.generate_challenge_id(
|
|
47
|
+
secret_key: secret_key,
|
|
48
|
+
realm: echoed.realm,
|
|
49
|
+
method: echoed.method,
|
|
50
|
+
intent: echoed.intent,
|
|
51
|
+
request: echoed.request,
|
|
52
|
+
expires: echoed.expires,
|
|
53
|
+
digest: echoed.digest,
|
|
54
|
+
opaque: echoed.opaque
|
|
55
|
+
)
|
|
56
|
+
return new_challenge.call unless Mpp.secure_compare(echoed.id, expected_id)
|
|
57
|
+
|
|
58
|
+
# Assert echoed fields match server's values
|
|
59
|
+
unless echoed.realm == realm && echoed.method == method_name && echoed.intent == intent.name
|
|
60
|
+
return new_challenge.call
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Assert echoed request matches server's current request
|
|
64
|
+
return new_challenge.call unless echoed.request == request
|
|
65
|
+
|
|
66
|
+
# Reject expired challenges as defense-in-depth
|
|
67
|
+
if echoed.expires
|
|
68
|
+
begin
|
|
69
|
+
expires_dt = Time.iso8601(echoed.expires.gsub("Z", "+00:00"))
|
|
70
|
+
return new_challenge.call if expires_dt < Time.now.utc
|
|
71
|
+
rescue ArgumentError
|
|
72
|
+
# continue to stricter check
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Verify echoed request parameters
|
|
77
|
+
echoed_request = echoed.request.is_a?(Hash) ? echoed.request : {}
|
|
78
|
+
request.each do |key, value|
|
|
79
|
+
next if key == "expires"
|
|
80
|
+
|
|
81
|
+
return new_challenge.call unless echoed_request[key] == value
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Enforce challenge expiry - fail closed
|
|
85
|
+
return new_challenge.call unless echoed.expires
|
|
86
|
+
|
|
87
|
+
begin
|
|
88
|
+
expires_dt = Time.iso8601(echoed.expires.gsub("Z", "+00:00"))
|
|
89
|
+
rescue ArgumentError
|
|
90
|
+
return new_challenge.call
|
|
91
|
+
end
|
|
92
|
+
return new_challenge.call if expires_dt < Time.now.utc
|
|
93
|
+
|
|
94
|
+
core_credential = mcp_credential.to_core
|
|
95
|
+
|
|
96
|
+
begin
|
|
97
|
+
core_receipt = intent.verify(core_credential, request)
|
|
98
|
+
rescue Mpp::VerificationError => e
|
|
99
|
+
Kernel.raise PaymentVerificationError.new(
|
|
100
|
+
challenges: [new_challenge.call],
|
|
101
|
+
reason: "verification-failed",
|
|
102
|
+
detail: e.message
|
|
103
|
+
)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
mcp_receipt = MCPReceipt.from_core(
|
|
107
|
+
core_receipt,
|
|
108
|
+
challenge_id: mcp_credential.challenge.id,
|
|
109
|
+
method: mcp_credential.challenge.method,
|
|
110
|
+
settlement: extract_settlement(request)
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
[mcp_credential, mcp_receipt]
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
sig { params(method: T.untyped, intent_name: T.untyped, request: T.untyped, realm: T.untyped, secret_key: T.untyped, expires_in: BasicObject, description: T.untyped).returns(Mpp::Extensions::MCP::MCPChallenge) }
|
|
117
|
+
def create_challenge(method:, intent_name:, request:, realm:, secret_key:,
|
|
118
|
+
expires_in: DEFAULT_CHALLENGE_TTL, description: nil)
|
|
119
|
+
expires_time = Time.now.utc + expires_in
|
|
120
|
+
expires = expires_time.iso8601
|
|
121
|
+
expires = expires.sub(/\+00:00$/, "Z")
|
|
122
|
+
|
|
123
|
+
challenge_id = Mpp.generate_challenge_id(
|
|
124
|
+
secret_key: secret_key,
|
|
125
|
+
realm: realm,
|
|
126
|
+
method: method,
|
|
127
|
+
intent: intent_name,
|
|
128
|
+
request: request,
|
|
129
|
+
expires: expires
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
MCPChallenge.new(
|
|
133
|
+
id: challenge_id,
|
|
134
|
+
realm: realm,
|
|
135
|
+
method: method,
|
|
136
|
+
intent: intent_name,
|
|
137
|
+
request: request,
|
|
138
|
+
expires: expires,
|
|
139
|
+
description: description
|
|
140
|
+
)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
sig { params(request: T.untyped).returns(T.nilable(T::Hash[T.untyped, T.untyped])) }
|
|
144
|
+
def extract_settlement(request)
|
|
145
|
+
settlement = {}
|
|
146
|
+
settlement["amount"] = request["amount"] if request.key?("amount")
|
|
147
|
+
settlement["currency"] = request["currency"] if request.key?("currency")
|
|
148
|
+
settlement.empty? ? nil : settlement
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require_relative "mcp/constants"
|
|
5
|
+
require_relative "mcp/types"
|
|
6
|
+
require_relative "mcp/verify"
|
|
7
|
+
require_relative "mcp/errors"
|
|
8
|
+
require_relative "mcp/decorator"
|
|
9
|
+
require_relative "mcp/capabilities"
|
|
10
|
+
|
|
11
|
+
module Mpp
|
|
12
|
+
module Extensions
|
|
13
|
+
module MCP
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
data/lib/mpp/json.rb
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Mpp
|
|
7
|
+
module Json
|
|
8
|
+
extend T::Sig
|
|
9
|
+
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
# Encode object as compact JSON with recursively sorted keys.
|
|
13
|
+
# Matches Python's json.dumps(separators=(",", ":"), sort_keys=True).
|
|
14
|
+
sig { params(obj: T.untyped).returns(String) }
|
|
15
|
+
def compact_encode(obj)
|
|
16
|
+
::JSON.generate(deep_sort_keys(obj), space: "", object_nl: "", array_nl: "")
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Recursively sort hash keys for deterministic serialization.
|
|
20
|
+
sig { params(obj: T.anything).returns(T.untyped) }
|
|
21
|
+
def deep_sort_keys(obj)
|
|
22
|
+
case obj
|
|
23
|
+
when Hash
|
|
24
|
+
obj.sort_by { |k, _| k.to_s }.to_h.transform_values { |v| deep_sort_keys(v) }
|
|
25
|
+
when Array
|
|
26
|
+
obj.map { |v| deep_sort_keys(v) }
|
|
27
|
+
else
|
|
28
|
+
obj
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# typed: false
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module Mpp
|
|
7
|
+
module Methods
|
|
8
|
+
module Stripe
|
|
9
|
+
# Server-side charge intent that verifies payment via Stripe PaymentIntents.
|
|
10
|
+
# Requires the `stripe` gem.
|
|
11
|
+
class ChargeIntent
|
|
12
|
+
attr_reader :name
|
|
13
|
+
|
|
14
|
+
def initialize(secret_key:, api_base: Defaults::STRIPE_API_BASE)
|
|
15
|
+
@name = "charge"
|
|
16
|
+
@secret_key = secret_key
|
|
17
|
+
@api_base = api_base
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def verify(credential, request)
|
|
21
|
+
# Check challenge expiry
|
|
22
|
+
challenge_expires = credential.challenge.expires
|
|
23
|
+
if challenge_expires
|
|
24
|
+
expires = Time.iso8601(challenge_expires.gsub("Z", "+00:00"))
|
|
25
|
+
raise Mpp::VerificationError, "Request has expired" if expires < Time.now.utc
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
payload_data = credential.payload
|
|
29
|
+
unless payload_data.is_a?(Hash) && payload_data.key?("spt")
|
|
30
|
+
raise Mpp::VerificationError, "Invalid credential payload: missing spt"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
spt = payload_data["spt"]
|
|
34
|
+
external_id = payload_data["externalId"]
|
|
35
|
+
|
|
36
|
+
# Build PaymentIntent params
|
|
37
|
+
params = {
|
|
38
|
+
amount: Integer(request["amount"]),
|
|
39
|
+
currency: request["currency"],
|
|
40
|
+
shared_payment_granted_token: spt,
|
|
41
|
+
confirm: true,
|
|
42
|
+
automatic_payment_methods: {
|
|
43
|
+
enabled: true,
|
|
44
|
+
allow_redirects: "never"
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
# Include metadata from methodDetails if present
|
|
49
|
+
method_details = request["methodDetails"]
|
|
50
|
+
if method_details.is_a?(Hash) && method_details["metadata"].is_a?(Hash)
|
|
51
|
+
params[:metadata] = method_details["metadata"].transform_values(&:to_s)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Create PaymentIntent via Stripe SDK
|
|
55
|
+
begin
|
|
56
|
+
Kernel.require "stripe"
|
|
57
|
+
rescue LoadError
|
|
58
|
+
raise "stripe gem is required for Stripe charge verification. Install with: gem install stripe"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
begin
|
|
62
|
+
client = ::Stripe::StripeClient.new(@secret_key)
|
|
63
|
+
result = client.v1.payment_intents.create(params)
|
|
64
|
+
rescue => e
|
|
65
|
+
raise Mpp::VerificationError, e.message
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# https://docs.stripe.com/error-low-level#idempotency
|
|
69
|
+
if result.respond_to?(:last_response) &&
|
|
70
|
+
result.last_response&.headers&.[]("idempotent-replayed") == "true"
|
|
71
|
+
raise Mpp::VerificationError, "Payment has already been processed."
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
pi_id = result.id
|
|
75
|
+
status = result.status
|
|
76
|
+
|
|
77
|
+
if status == "requires_action"
|
|
78
|
+
raise Mpp::PaymentActionRequiredError.new(reason: "PaymentIntent #{pi_id} requires action")
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
unless status == "succeeded"
|
|
82
|
+
raise Mpp::VerificationError, "PaymentIntent #{pi_id} has status: #{status}"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
Mpp::Receipt.success(pi_id, method: "stripe", external_id: external_id)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# typed: false
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Mpp
|
|
5
|
+
module Methods
|
|
6
|
+
module Stripe
|
|
7
|
+
# Client-side Stripe method for creating SPT-based credentials.
|
|
8
|
+
class ClientMethod
|
|
9
|
+
attr_reader :name
|
|
10
|
+
|
|
11
|
+
def initialize(create_spt:, payment_method: nil, external_id: nil)
|
|
12
|
+
@name = "stripe"
|
|
13
|
+
@create_spt = create_spt
|
|
14
|
+
@payment_method = payment_method
|
|
15
|
+
@external_id = external_id
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Create a credential to satisfy the given challenge.
|
|
19
|
+
def create_credential(challenge)
|
|
20
|
+
request = challenge.request
|
|
21
|
+
method_details = request["methodDetails"]
|
|
22
|
+
method_details = {} unless method_details.is_a?(Hash)
|
|
23
|
+
|
|
24
|
+
spt_id = @create_spt.call(
|
|
25
|
+
amount: request["amount"],
|
|
26
|
+
currency: request["currency"],
|
|
27
|
+
network_id: method_details["networkId"],
|
|
28
|
+
payment_method: @payment_method
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
payload = {"spt" => spt_id}
|
|
32
|
+
payload["externalId"] = @external_id if @external_id
|
|
33
|
+
|
|
34
|
+
Mpp::Credential.new(
|
|
35
|
+
challenge: challenge.to_echo,
|
|
36
|
+
payload: payload
|
|
37
|
+
)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# typed: false
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require_relative "defaults"
|
|
5
|
+
|
|
6
|
+
module Mpp
|
|
7
|
+
module Methods
|
|
8
|
+
module Stripe
|
|
9
|
+
# Stripe payment method implementation.
|
|
10
|
+
# Handles SPT-based payments through Stripe's Business Network.
|
|
11
|
+
class StripeMethod
|
|
12
|
+
attr_reader :name, :currency, :recipient, :decimals
|
|
13
|
+
attr_accessor :intents
|
|
14
|
+
|
|
15
|
+
def initialize(secret_key:, network_id:, payment_methods: nil,
|
|
16
|
+
metadata: nil, currency: Defaults::DEFAULT_CURRENCY,
|
|
17
|
+
decimals: Defaults::DEFAULT_DECIMALS)
|
|
18
|
+
@name = "stripe"
|
|
19
|
+
@secret_key = secret_key
|
|
20
|
+
@network_id = network_id
|
|
21
|
+
@payment_methods = payment_methods
|
|
22
|
+
@metadata = metadata
|
|
23
|
+
@currency = currency
|
|
24
|
+
@recipient = network_id
|
|
25
|
+
@decimals = decimals
|
|
26
|
+
@intents = {}
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Transform request - injects Stripe-specific methodDetails.
|
|
30
|
+
def transform_request(request, _credential)
|
|
31
|
+
method_details = request.fetch("methodDetails", {})
|
|
32
|
+
method_details = {} unless method_details.is_a?(Hash)
|
|
33
|
+
|
|
34
|
+
method_details["networkId"] = @network_id
|
|
35
|
+
method_details["paymentMethods"] = @payment_methods if @payment_methods
|
|
36
|
+
method_details["metadata"] = @metadata if @metadata
|
|
37
|
+
|
|
38
|
+
request.merge("methodDetails" => method_details)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Factory function to create a configured StripeMethod with ChargeIntent.
|
|
43
|
+
def self.stripe(secret_key:, network_id:, payment_methods: nil,
|
|
44
|
+
metadata: nil, currency: Defaults::DEFAULT_CURRENCY,
|
|
45
|
+
decimals: Defaults::DEFAULT_DECIMALS,
|
|
46
|
+
api_base: Defaults::STRIPE_API_BASE)
|
|
47
|
+
charge_intent = ChargeIntent.new(secret_key: secret_key, api_base: api_base)
|
|
48
|
+
|
|
49
|
+
method = StripeMethod.new(
|
|
50
|
+
secret_key: secret_key,
|
|
51
|
+
network_id: network_id,
|
|
52
|
+
payment_methods: payment_methods,
|
|
53
|
+
metadata: metadata,
|
|
54
|
+
currency: currency,
|
|
55
|
+
decimals: decimals
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
method.intents = {"charge" => charge_intent}
|
|
59
|
+
method
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Mpp
|
|
5
|
+
module Methods
|
|
6
|
+
module Stripe
|
|
7
|
+
autoload :Defaults, "mpp/methods/stripe/defaults"
|
|
8
|
+
# Eagerly require stripe_method so the Stripe.stripe factory method is available
|
|
9
|
+
require_relative "stripe/stripe_method"
|
|
10
|
+
autoload :ChargeIntent, "mpp/methods/stripe/charge_intent"
|
|
11
|
+
autoload :ClientMethod, "mpp/methods/stripe/client_method"
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# typed: false
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Mpp
|
|
5
|
+
module Methods
|
|
6
|
+
module Tempo
|
|
7
|
+
# Wrapper around the eth gem for signing.
|
|
8
|
+
# Requires the `eth` gem to be installed.
|
|
9
|
+
class Account
|
|
10
|
+
attr_reader :key
|
|
11
|
+
|
|
12
|
+
def initialize(key)
|
|
13
|
+
@key = key
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Load from hex private key (0x-prefixed).
|
|
17
|
+
def self.from_key(private_key)
|
|
18
|
+
require "eth"
|
|
19
|
+
new(Eth::Key.new(priv: private_key.delete_prefix("0x")))
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Load from environment variable.
|
|
23
|
+
def self.from_env(var = "TEMPO_PRIVATE_KEY")
|
|
24
|
+
key = ENV.fetch(var, nil)
|
|
25
|
+
raise ArgumentError, "$#{var} not set" unless key && !key.empty?
|
|
26
|
+
|
|
27
|
+
from_key(key)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Get the account's Ethereum address (checksummed).
|
|
31
|
+
def address
|
|
32
|
+
@key.address.to_s
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Get the private key as hex string.
|
|
36
|
+
def private_key
|
|
37
|
+
"0x#{@key.private_hex}"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Sign a 32-byte hash, return 65-byte signature (r || s || v).
|
|
41
|
+
def sign_hash(msg_hash)
|
|
42
|
+
raise ArgumentError, "msg_hash must be 32 bytes, got #{msg_hash.bytesize}" unless msg_hash.bytesize == 32
|
|
43
|
+
|
|
44
|
+
sig = @key.sign(msg_hash)
|
|
45
|
+
# eth gem returns hex signature, parse r, s, v
|
|
46
|
+
sig_hex = sig.delete_prefix("0x")
|
|
47
|
+
[sig_hex].pack("H*")
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# typed: false
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "openssl"
|
|
5
|
+
require "securerandom"
|
|
6
|
+
|
|
7
|
+
module Mpp
|
|
8
|
+
module Methods
|
|
9
|
+
module Tempo
|
|
10
|
+
module Attribution
|
|
11
|
+
VERSION = 0x01
|
|
12
|
+
ANONYMOUS = "\x00" * 10
|
|
13
|
+
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
# Compute keccak256 hash. Uses OpenSSL if available, otherwise pure Ruby.
|
|
17
|
+
def keccak256(data)
|
|
18
|
+
# Try eth gem's keccak first
|
|
19
|
+
Kernel.require "eth"
|
|
20
|
+
Eth::Util.keccak256(data)
|
|
21
|
+
rescue LoadError
|
|
22
|
+
# Fallback: use OpenSSL's SHA3-256 (not exactly keccak, but close)
|
|
23
|
+
# For production, the eth gem should be installed
|
|
24
|
+
OpenSSL::Digest.new("SHA3-256").digest(data)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Compute TAG = keccak256("mpp")[0:4]
|
|
28
|
+
def tag
|
|
29
|
+
@tag ||= keccak256("mpp".b)[0, 4]
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def fingerprint(value)
|
|
33
|
+
keccak256(value.encode(Encoding::UTF_8))[0, 10]
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Encode an MPP attribution memo (32 bytes).
|
|
37
|
+
#
|
|
38
|
+
# Byte Layout:
|
|
39
|
+
# 0..3: TAG = keccak256("mpp")[0:4]
|
|
40
|
+
# 4: version (0x01)
|
|
41
|
+
# 5..14: serverId fingerprint
|
|
42
|
+
# 15..24: clientId fingerprint or zeros
|
|
43
|
+
# 25..31: random nonce
|
|
44
|
+
def encode(server_id:, client_id: nil, challenge_id: nil)
|
|
45
|
+
buf = "\x00".b * 32
|
|
46
|
+
buf[0, 4] = tag
|
|
47
|
+
buf[4] = [VERSION].pack("C")
|
|
48
|
+
buf[5, 10] = fingerprint(server_id)
|
|
49
|
+
buf[15, 10] = client_id ? fingerprint(client_id) : ANONYMOUS.b
|
|
50
|
+
buf[25, 7] = if challenge_id
|
|
51
|
+
keccak256(challenge_id.encode(Encoding::UTF_8))[0, 7]
|
|
52
|
+
else
|
|
53
|
+
SecureRandom.random_bytes(7)
|
|
54
|
+
end
|
|
55
|
+
"0x#{buf.unpack1("H*")}"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Check if a memo is an MPP attribution memo.
|
|
59
|
+
def mpp_memo?(memo)
|
|
60
|
+
return false unless memo.length == 66
|
|
61
|
+
|
|
62
|
+
begin
|
|
63
|
+
memo_tag = [memo[2, 8]].pack("H*")
|
|
64
|
+
memo_version = memo[10, 2].to_i(16)
|
|
65
|
+
rescue ArgumentError
|
|
66
|
+
return false
|
|
67
|
+
end
|
|
68
|
+
memo_tag == tag && memo_version == VERSION
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Verify server fingerprint in memo.
|
|
72
|
+
def verify_server(memo, server_id)
|
|
73
|
+
return false unless mpp_memo?(memo)
|
|
74
|
+
|
|
75
|
+
begin
|
|
76
|
+
memo_server = [memo[12, 20]].pack("H*")
|
|
77
|
+
rescue ArgumentError
|
|
78
|
+
return false
|
|
79
|
+
end
|
|
80
|
+
memo_server == fingerprint(server_id)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Decoded memo structure.
|
|
84
|
+
DecodedMemo = Data.define(:version, :server_fingerprint, :client_fingerprint, :nonce)
|
|
85
|
+
|
|
86
|
+
# Decode an MPP attribution memo.
|
|
87
|
+
def decode(memo)
|
|
88
|
+
return nil unless mpp_memo?(memo)
|
|
89
|
+
|
|
90
|
+
begin
|
|
91
|
+
version = memo[10, 2].to_i(16)
|
|
92
|
+
server_fingerprint = "0x#{memo[12, 20]}"
|
|
93
|
+
client_hex = memo[32, 20]
|
|
94
|
+
nonce = "0x#{memo[52..]}"
|
|
95
|
+
|
|
96
|
+
client_bytes = [client_hex].pack("H*")
|
|
97
|
+
client_fingerprint = (client_bytes == ANONYMOUS.b) ? nil : "0x#{client_hex}"
|
|
98
|
+
rescue ArgumentError
|
|
99
|
+
return nil
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
DecodedMemo.new(
|
|
103
|
+
version: version,
|
|
104
|
+
server_fingerprint: server_fingerprint,
|
|
105
|
+
client_fingerprint: client_fingerprint,
|
|
106
|
+
nonce: nonce
|
|
107
|
+
)
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|