Module: Solana::AuthVerifier
- Defined in:
- lib/solana/auth_verifier.rb
Overview
Verifies a Solana wallet signature against an externally-stored nonce.
Pure module — no Rails / no session coupling. Host apps adapt their
session storage and call Solana::AuthVerifier.verify!.
IMPORTANT — caller is responsible for replay prevention. The host
MUST invalidate stored_nonce immediately after this method returns
(success OR failure). The canonical Rails-session adapter pattern is:
stored_nonce = session.delete(:solana_nonce)
nonce_at = session.delete(:solana_nonce_at)
Solana::AuthVerifier.verify!(
message: ..., signature_b58: ..., pubkey_b58: ...,
expected_host: request.host,
stored_nonce: stored_nonce, nonce_at: nonce_at
)
The session.delete(...) BEFORE the verify! call is what prevents
replay — once consumed, the nonce can never satisfy verify! again.
See turf-monster app/controllers/concerns/solana/session_auth.rb
for the production adapter.
Defined Under Namespace
Classes: VerificationError
Constant Summary collapse
- NONCE_MAX_AGE =
Default max nonce age in seconds (5 minutes).
300- ED25519_PUBKEY_BYTES =
32- ED25519_SIGNATURE_BYTES =
64
Class Method Summary collapse
-
.constant_time_eq?(a, b) ⇒ Boolean
Constant-time string equality, sourced from OpenSSL's fixed_length_secure_compare (available since Ruby 2.5+).
-
.verify!(message:, signature_b58:, pubkey_b58:, expected_host:, stored_nonce:, nonce_at: nil, max_age: NONCE_MAX_AGE) ⇒ Object
Verifies that
signature_b58is a valid Ed25519 signature overmessagemade bypubkey_b58, AND that the message is bound toexpected_host(its opening token), AND that theNonce: ...field matchesstored_nonce, AND that the nonce is not stale.
Class Method Details
.constant_time_eq?(a, b) ⇒ Boolean
Constant-time string equality, sourced from OpenSSL's fixed_length_secure_compare (available since Ruby 2.5+). Returns false (not raise) if lengths differ. Used for nonce comparison so attackers can't time-leak match progress.
97 98 99 100 101 102 |
# File 'lib/solana/auth_verifier.rb', line 97 def self.constant_time_eq?(a, b) a = a.to_s b = b.to_s return false unless a.bytesize == b.bytesize OpenSSL.fixed_length_secure_compare(a, b) end |
.verify!(message:, signature_b58:, pubkey_b58:, expected_host:, stored_nonce:, nonce_at: nil, max_age: NONCE_MAX_AGE) ⇒ Object
Verifies that signature_b58 is a valid Ed25519 signature over
message made by pubkey_b58, AND that the message is bound to
expected_host (its opening token), AND that the Nonce: ... field
matches stored_nonce, AND that the nonce is not stale.
Returns the verified public key (base58 string) on success. Raises Solana::AuthVerifier::VerificationError on any failure.
51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 |
# File 'lib/solana/auth_verifier.rb', line 51 def self.verify!(message:, signature_b58:, pubkey_b58:, expected_host:, stored_nonce:, nonce_at: nil, max_age: NONCE_MAX_AGE) raise VerificationError, "No nonce provided" if stored_nonce.nil? || stored_nonce.empty? raise VerificationError, "No expected_host provided" if expected_host.nil? || expected_host.to_s.empty? if nonce_at && (Time.now.to_i - nonce_at.to_i) > max_age raise VerificationError, "Nonce expired" end sig_bytes = Solana::Keypair.decode_base58(signature_b58) pub_bytes = Solana::Keypair.decode_base58(pubkey_b58) # Length-check BEFORE handing to Ed25519::VerifyKey to surface a clean # error (instead of letting the library raise ArgumentError, which the # rescue below would convert into a misleading "Signature verification # failed" message). unless pub_bytes.bytesize == ED25519_PUBKEY_BYTES raise VerificationError, "Public key must be #{ED25519_PUBKEY_BYTES} bytes, got #{pub_bytes.bytesize}" end unless sig_bytes.bytesize == ED25519_SIGNATURE_BYTES raise VerificationError, "Signature must be #{ED25519_SIGNATURE_BYTES} bytes, got #{sig_bytes.bytesize}" end verify_key = Ed25519::VerifyKey.new(pub_bytes) verify_key.verify(sig_bytes, ) claimed_nonce = .match(/Nonce: (\w+)/)&.captures&.first unless claimed_nonce && constant_time_eq?(claimed_nonce, stored_nonce) raise VerificationError, "Invalid nonce" end # OPSEC-018: bind the signature to the host. The signed message must name # the host as its opening token (SIWS-style: "<host> wants to sign in…"). # Without this, a signature the user produced for any other dApp — over a # message that happens to carry the same nonce — would satisfy verify!. unless .start_with?("#{expected_host} ") raise VerificationError, "Message is not bound to host #{expected_host}" end pubkey_b58 rescue Ed25519::VerifyError => e raise VerificationError, "Signature verification failed: #{e.}" end |