diff --git a/api/v1_create_reward_code.go b/api/v1_create_reward_code.go index 1c5b509c..60572f99 100644 --- a/api/v1_create_reward_code.go +++ b/api/v1_create_reward_code.go @@ -64,6 +64,25 @@ func generateCode() (string, error) { return string(result), nil } +// buildOffchainMessage wraps a message in Solana's SIMD-0048 off-chain +// message envelope. Hardware wallets (notably Ledger) refuse to sign +// arbitrary bytes, so wallets like Phantom wrap the message in this +// envelope before sending it to the device — meaning the signature is +// over the wrapped bytes rather than the raw message. +func buildOffchainMessage(message string, format byte) []byte { + msgBytes := []byte(message) + msgLen := uint16(len(msgBytes)) + + // Signing domain (16 bytes) + version (1) + format (1) + length (2) + message + buf := make([]byte, 0, 20+len(msgBytes)) + buf = append(buf, []byte("\xffsolana offchain")...) + buf = append(buf, 0x00) + buf = append(buf, format) + buf = append(buf, byte(msgLen), byte(msgLen>>8)) + buf = append(buf, msgBytes...) + return buf +} + func verifySignature(signatureBase58 string, message string, authorizedPubKey string) (bool, error) { // Decode the signature from base58 signatureBytes, err := base58.Decode(signatureBase58) @@ -77,10 +96,20 @@ func verifySignature(signatureBase58 string, message string, authorizedPubKey st return false, err } - // Verify the signature - messageBytes := []byte(message) - valid := ed25519.Verify(expectedPubKey[:], messageBytes, signatureBytes) - return valid, nil + // Hot wallets sign the raw bytes directly. + if ed25519.Verify(expectedPubKey[:], []byte(message), signatureBytes) { + return true, nil + } + + // Hardware wallets (e.g. Ledger via Phantom) sign the SIMD-0048 wrapped + // message instead. Try all three message formats since wallets vary. + for _, format := range []byte{0, 1, 2} { + if ed25519.Verify(expectedPubKey[:], buildOffchainMessage(message, format), signatureBytes) { + return true, nil + } + } + + return false, nil } func verifySignatureAgainstKeys(signatureBase58 string, message string, authorizedKeys []string) (string, error) { diff --git a/api/v1_create_reward_code_test.go b/api/v1_create_reward_code_test.go index a11bb09a..947bf441 100644 --- a/api/v1_create_reward_code_test.go +++ b/api/v1_create_reward_code_test.go @@ -307,6 +307,26 @@ func TestVerifySignature(t *testing.T) { assert.Error(t, err, "Should return error for invalid base58") }) + t.Run("Accepts SIMD-0048 off-chain wrapped signature (Ledger path)", func(t *testing.T) { + testPubKey, testPrivateKey, err := ed25519.GenerateKey(rand.Reader) + assert.NoError(t, err) + + solanaPubKey := solana.PublicKeyFromBytes(testPubKey) + testPubKeyBase58 := solanaPubKey.String() + + timestamp := time.Now().UnixMilli() + timestampStr := fmt.Sprintf("%d", timestamp) + + for _, format := range []byte{0, 1, 2} { + wrapped := buildOffchainMessage(timestampStr, format) + signature := base58.Encode(ed25519.Sign(testPrivateKey, wrapped)) + + valid, err := verifySignature(signature, timestampStr, testPubKeyBase58) + assert.NoError(t, err) + assert.True(t, valid, "Should accept off-chain wrapped signature for format %d", format) + } + }) + t.Run("Returns false for wrong message", func(t *testing.T) { testPubKey, testPrivateKey, err := ed25519.GenerateKey(rand.Reader) assert.NoError(t, err)