solana-studio 0.4.1 → 0.4.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 +4 -4
- data/CHANGELOG.md +14 -0
- data/lib/solana/auth_verifier.rb +17 -3
- data/lib/solana/transaction.rb +29 -16
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: eddaba3ed1477d676bec3f207d9d1fd41614853259b3fb1b6d8606708f461eef
|
|
4
|
+
data.tar.gz: 3517d316ee585700c03b70c868bd5b3e1225e18f3dd03992e69d3e36d81a3e1e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8fee45a8591c3c3a8b193aa3a3ab0b719aafedd79ae746e3c3d679c15f83113a3dcc52b1bac34216d4da3502ca3f53dfcc1f12fbc2f159e6df3ca1385d35287a
|
|
7
|
+
data.tar.gz: a959122852472eb7a5c1172cfe7f6a719ef44ad3f985ed12d548127c798d976964916dba92a47627a43579e2116a0cdbfb5ed4095ccc25bf299db4434fc6c70a
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
4
4
|
|
|
5
|
+
## v0.4.2 (2026-05-19)
|
|
6
|
+
|
|
7
|
+
Tier-3 fixes from the turf-monster pre-prod opsec audit (OPSEC-017/018/043).
|
|
8
|
+
|
|
9
|
+
### Changed (breaking)
|
|
10
|
+
- **`Solana::AuthVerifier.verify!` now requires an `expected_host:` keyword argument (OPSEC-018).** The verifier previously matched only the nonce, so a signature a user produced for any other dApp — over a message carrying the same nonce — would satisfy a host app's login. `verify!` now asserts the signed message names `expected_host` as its opening token (SIWS-style `"<host> wants to sign in…"`). Callers must pass `expected_host:` (e.g. `request.host`).
|
|
11
|
+
|
|
12
|
+
### Fixed (security)
|
|
13
|
+
- **`Solana::Transaction#serialize` / `#serialize_partial` now verify signer count (OPSEC-017).** `serialize` raises unless `@signers.length` equals the number of `is_signer` accounts; `serialize_partial` raises unless local + additional signers cover every required slot. Previously a missing required signer produced a malformed payload, or a zero-filled signature slot in a still-broadcastable half-signed TX.
|
|
14
|
+
- **`Solana::Transaction#serialize_partial` no longer stores signer state in an instance variable (OPSEC-043).** Additional signers are kept in a local, so a `Transaction` shared across threads can't leak signer state between partial-sign flows.
|
|
15
|
+
|
|
16
|
+
### Tests
|
|
17
|
+
- New `test/auth_verifier_test.rb`; added signer-count + no-instance-state cases to `test/transaction_test.rb`.
|
|
18
|
+
|
|
5
19
|
## v0.4.1 (2026-05-17)
|
|
6
20
|
|
|
7
21
|
Pre-public-release security hardening per `SECURITY-AUDIT-2026-05-17.md`.
|
data/lib/solana/auth_verifier.rb
CHANGED
|
@@ -14,6 +14,7 @@ module Solana
|
|
|
14
14
|
# nonce_at = session.delete(:solana_nonce_at)
|
|
15
15
|
# Solana::AuthVerifier.verify!(
|
|
16
16
|
# message: ..., signature_b58: ..., pubkey_b58: ...,
|
|
17
|
+
# expected_host: request.host,
|
|
17
18
|
# stored_nonce: stored_nonce, nonce_at: nonce_at
|
|
18
19
|
# )
|
|
19
20
|
#
|
|
@@ -31,8 +32,9 @@ module Solana
|
|
|
31
32
|
ED25519_SIGNATURE_BYTES = 64
|
|
32
33
|
|
|
33
34
|
# Verifies that `signature_b58` is a valid Ed25519 signature over
|
|
34
|
-
# `message` made by `pubkey_b58`, AND that the
|
|
35
|
-
#
|
|
35
|
+
# `message` made by `pubkey_b58`, AND that the message is bound to
|
|
36
|
+
# `expected_host` (its opening token), AND that the `Nonce: ...` field
|
|
37
|
+
# matches `stored_nonce`, AND that the nonce is not stale.
|
|
36
38
|
#
|
|
37
39
|
# Returns the verified public key (base58 string) on success.
|
|
38
40
|
# Raises Solana::AuthVerifier::VerificationError on any failure.
|
|
@@ -40,11 +42,15 @@ module Solana
|
|
|
40
42
|
# @param message [String] the signed message (must contain `Nonce: <value>`)
|
|
41
43
|
# @param signature_b58 [String] base58-encoded Ed25519 signature
|
|
42
44
|
# @param pubkey_b58 [String] base58-encoded public key
|
|
45
|
+
# @param expected_host [String] host the signed message must name as its
|
|
46
|
+
# opening token — rejects signatures the user made for any other domain
|
|
47
|
+
# (OPSEC-018)
|
|
43
48
|
# @param stored_nonce [String, nil] the nonce the host issued + remembers
|
|
44
49
|
# @param nonce_at [Integer, nil] Unix timestamp when the nonce was issued
|
|
45
50
|
# @param max_age [Integer] seconds before a nonce expires (default 300)
|
|
46
|
-
def self.verify!(message:, signature_b58:, pubkey_b58:, stored_nonce:, nonce_at: nil, max_age: NONCE_MAX_AGE)
|
|
51
|
+
def self.verify!(message:, signature_b58:, pubkey_b58:, expected_host:, stored_nonce:, nonce_at: nil, max_age: NONCE_MAX_AGE)
|
|
47
52
|
raise VerificationError, "No nonce provided" if stored_nonce.nil? || stored_nonce.empty?
|
|
53
|
+
raise VerificationError, "No expected_host provided" if expected_host.nil? || expected_host.to_s.empty?
|
|
48
54
|
|
|
49
55
|
if nonce_at && (Time.now.to_i - nonce_at.to_i) > max_age
|
|
50
56
|
raise VerificationError, "Nonce expired"
|
|
@@ -72,6 +78,14 @@ module Solana
|
|
|
72
78
|
raise VerificationError, "Invalid nonce"
|
|
73
79
|
end
|
|
74
80
|
|
|
81
|
+
# OPSEC-018: bind the signature to the host. The signed message must name
|
|
82
|
+
# the host as its opening token (SIWS-style: "<host> wants to sign in…").
|
|
83
|
+
# Without this, a signature the user produced for any other dApp — over a
|
|
84
|
+
# message that happens to carry the same nonce — would satisfy verify!.
|
|
85
|
+
unless message.start_with?("#{expected_host} ")
|
|
86
|
+
raise VerificationError, "Message is not bound to host #{expected_host}"
|
|
87
|
+
end
|
|
88
|
+
|
|
75
89
|
pubkey_b58
|
|
76
90
|
rescue Ed25519::VerifyError => e
|
|
77
91
|
raise VerificationError, "Signature verification failed: #{e.message}"
|
data/lib/solana/transaction.rb
CHANGED
|
@@ -82,6 +82,14 @@ module Solana
|
|
|
82
82
|
num_readonly_signed = count_readonly_signed(account_keys)
|
|
83
83
|
num_readonly_unsigned = count_readonly_unsigned(account_keys)
|
|
84
84
|
|
|
85
|
+
# OPSEC-017: the message header declares num_required_signatures, but we
|
|
86
|
+
# only write @signers.length signatures. A mismatch produces a malformed
|
|
87
|
+
# payload — fail loudly here instead of emitting a silently-broken TX.
|
|
88
|
+
if @signers.length != num_required_signatures
|
|
89
|
+
raise "Signer count mismatch: #{@signers.length} signer(s) provided, " \
|
|
90
|
+
"#{num_required_signatures} required by the account list"
|
|
91
|
+
end
|
|
92
|
+
|
|
85
93
|
# Build message
|
|
86
94
|
message = build_message(account_keys, num_required_signatures, num_readonly_signed, num_readonly_unsigned)
|
|
87
95
|
|
|
@@ -105,18 +113,27 @@ module Solana
|
|
|
105
113
|
raise "No signers" if @signers.empty?
|
|
106
114
|
raise "No instructions" if @instructions.empty?
|
|
107
115
|
|
|
108
|
-
#
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
@_additional_signers << pk
|
|
113
|
-
end
|
|
116
|
+
# OPSEC-043: keep additional signers in a local — never an instance ivar.
|
|
117
|
+
# A Transaction shared across threads/requests must not leak signer state
|
|
118
|
+
# between partial-sign flows.
|
|
119
|
+
normalized_additional = additional_signers.map { |pk| normalize_pubkey(pk) }
|
|
114
120
|
|
|
115
|
-
account_keys = collect_account_keys
|
|
121
|
+
account_keys = collect_account_keys(normalized_additional)
|
|
116
122
|
num_required_signatures = count_required_signatures(account_keys)
|
|
117
123
|
num_readonly_signed = count_readonly_signed(account_keys)
|
|
118
124
|
num_readonly_unsigned = count_readonly_unsigned(account_keys)
|
|
119
125
|
|
|
126
|
+
# OPSEC-017: every required signature slot must be covered by a local
|
|
127
|
+
# signer (signed now) or an additional signer (signs client-side later).
|
|
128
|
+
# Otherwise a slot is silently zero-filled and the half-signed TX is
|
|
129
|
+
# still broadcastable.
|
|
130
|
+
provided = @signers.length + normalized_additional.length
|
|
131
|
+
if provided != num_required_signatures
|
|
132
|
+
raise "Signer count mismatch: #{provided} provided " \
|
|
133
|
+
"(#{@signers.length} local + #{normalized_additional.length} additional), " \
|
|
134
|
+
"#{num_required_signatures} required by the account list"
|
|
135
|
+
end
|
|
136
|
+
|
|
120
137
|
message = build_message(account_keys, num_required_signatures, num_readonly_signed, num_readonly_unsigned)
|
|
121
138
|
|
|
122
139
|
# Build ordered signature slots matching the account key order
|
|
@@ -124,12 +141,10 @@ module Solana
|
|
|
124
141
|
@signers.each { |s| signer_map[s.public_key_bytes] = s.sign(message) }
|
|
125
142
|
|
|
126
143
|
signatures = account_keys.select { |_, meta| meta[:is_signer] }.map do |pk, _|
|
|
127
|
-
signer_map[pk] || ("\x00" * 64).b # zero placeholder for
|
|
144
|
+
signer_map[pk] || ("\x00" * 64).b # zero placeholder for an additional (client-side) signer
|
|
128
145
|
end
|
|
129
146
|
|
|
130
147
|
compact_u16(signatures.length) + signatures.join.b + message
|
|
131
|
-
ensure
|
|
132
|
-
@_additional_signers = nil
|
|
133
148
|
end
|
|
134
149
|
|
|
135
150
|
def serialize_partial_base64(additional_signers: [])
|
|
@@ -151,7 +166,7 @@ module Solana
|
|
|
151
166
|
end
|
|
152
167
|
end
|
|
153
168
|
|
|
154
|
-
def collect_account_keys
|
|
169
|
+
def collect_account_keys(additional_signers = [])
|
|
155
170
|
keys = {}
|
|
156
171
|
|
|
157
172
|
# Fee payer (first signer) is always first
|
|
@@ -166,11 +181,9 @@ module Solana
|
|
|
166
181
|
end
|
|
167
182
|
|
|
168
183
|
# Additional signers (for partial signing — not in @signers but must be marked as signer)
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
keys[pk][:is_signer] = true
|
|
173
|
-
end
|
|
184
|
+
additional_signers.each do |pk|
|
|
185
|
+
keys[pk] ||= { is_signer: true, is_writable: false }
|
|
186
|
+
keys[pk][:is_signer] = true
|
|
174
187
|
end
|
|
175
188
|
|
|
176
189
|
# Instruction accounts
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: solana-studio
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.4.
|
|
4
|
+
version: 0.4.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Alex McRitchie
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-05-
|
|
11
|
+
date: 2026-05-20 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: ed25519
|