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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 39e8967f873b252f8204b4ff0be0a011b813f3744b2ee9c308b1e73740e9ca2a
4
- data.tar.gz: 769171e5fd595cba388702be0e4dd6b1f9f46545451624393543ef329b2cc402
3
+ metadata.gz: eddaba3ed1477d676bec3f207d9d1fd41614853259b3fb1b6d8606708f461eef
4
+ data.tar.gz: 3517d316ee585700c03b70c868bd5b3e1225e18f3dd03992e69d3e36d81a3e1e
5
5
  SHA512:
6
- metadata.gz: '00851cd448aecb996b636f91ab2bf19192a0d64966223a06633b52584c38116ff32f3a163433a50a4299d15a72196426d14e65cb5aee599a0b71a98728f340ab'
7
- data.tar.gz: 7fcbc86d1194c63055bf14f7fd3c23f1d117c872e295a9e20557fbf8528f20b88be991ac4daae0d276ac10b790037b98c52315170d8323d1082b2b072ce96136
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`.
@@ -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 `Nonce: ...` field in
35
- # the message matches `stored_nonce`, AND that the nonce is not stale.
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}"
@@ -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
- # Mark additional signers so they appear in the account keys
109
- additional_signers.each do |pubkey_bytes|
110
- pk = normalize_pubkey(pubkey_bytes)
111
- @_additional_signers ||= []
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 unsigned slots
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
- if @_additional_signers
170
- @_additional_signers.each do |pk|
171
- keys[pk] ||= { is_signer: true, is_writable: false }
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.1
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-18 00:00:00.000000000 Z
11
+ date: 2026-05-20 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: ed25519