Skip to content

feat: Add sign/verify message API endpoints for Issue #1392#2286

Open
algsoch wants to merge 1 commit intoergoplatform:masterfrom
algsoch:feature/issue-1392-sign-message
Open

feat: Add sign/verify message API endpoints for Issue #1392#2286
algsoch wants to merge 1 commit intoergoplatform:masterfrom
algsoch:feature/issue-1392-sign-message

Conversation

@algsoch
Copy link

@algsoch algsoch commented Dec 14, 2025

Add sign/verify message API endpoints (Issue #1392)

Summary

This PR implements message signing and verification endpoints for the Ergo wallet, allowing users to sign arbitrary messages with their wallet keys and verify signatures. This is useful for authentication, proof of ownership, and other cryptographic use cases.

Closes: #1392

Changes

New API Endpoints

1. POST /wallet/signMessage

Signs an arbitrary UTF-8 message using a wallet key.

Request Body:

{
  "message": "Hello Ergo!",
  "address": "9f4QF8AD1nQ3nJahQVkMj8hFSVVzVom77b52JU7EW71Zexg6N8v" // optional
}

Response:

{
  "signature": "0x...",  // Base16-encoded Schnorr signature (56 bytes)
  "publicKey": "0x..."   // Base16-encoded public key (33 bytes)
}

Features:

  • Optional address parameter to sign with a specific key
  • If no address provided, uses first available wallet key
  • Returns both signature and corresponding public key
  • Requires unlocked wallet

2. POST /wallet/verifySignature

Verifies a message signature against a public key.

Request Body:

{
  "message": "Hello Ergo!",
  "signature": "0x...",  // Base16-encoded signature from signMessage
  "publicKey": "0x..."   // Base16-encoded public key from signMessage
}

Response:

{
  "verified": true
}

Features:

  • Stateless verification (no wallet needed)
  • Returns boolean verification result
  • Supports standard Schnorr signatures

Implementation Details

Files Modified:

  1. MessageSigningRequests.scala (NEW)

    • SignMessageRequest - Request model for signing
    • VerifySignatureRequest - Request model for verification
  2. WalletApiRoute.scala

    • Added signMessageR route handler
    • Added verifySignatureR route handler
    • Integrated with wallet operations
  3. ErgoWalletReader.scala

    • Added signMessage() method declaration
  4. ErgoWalletActorMessages.scala

    • Added SignMessage case class for actor communication
  5. ErgoWalletActor.scala

    • Added message handler for SignMessage
  6. ErgoWalletService.scala

    • Implemented signMessage() with key derivation
    • Uses ErgoSignature.sign() for Schnorr signatures
    • Supports address-based key selection
  7. ApiRequestsCodecs.scala

    • Added JSON decoders for request types

Cryptography

  • Uses Schnorr signature scheme via ErgoSignature from ergo-wallet
  • Message encoding: UTF-8 → bytes
  • Signature format: 56 bytes (24-byte challenge + 32-byte response)
  • Public key format: 33 bytes (compressed elliptic curve point)
  • Base16 (hex) encoding for all binary data in API

Security Considerations

  1. Wallet must be unlocked to sign messages
  2. No key leakage - only public keys and signatures exposed
  3. Standard Schnorr - compatible with other Ergo tools
  4. Message integrity - any message modification fails verification

Testing

Manual Testing

Test 1: Sign a message

# Unlock wallet first
curl -X POST http://localhost:9053/wallet/unlock \
  -H "api_key: YOUR_API_KEY" \
  -d '{"pass": "your_password"}'

# Sign message
curl -X POST http://localhost:9053/wallet/signMessage \
  -H "api_key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "message": "Hello Ergo blockchain!"
  }'

Expected Output:

{
  "signature": "a1b2c3d4...",
  "publicKey": "03abc123..."
}

Test 2: Verify signature

curl -X POST http://localhost:9053/wallet/verifySignature \
  -H "api_key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "message": "Hello Ergo blockchain!",
    "signature": "a1b2c3d4...",
    "publicKey": "03abc123..."
  }'

Expected Output:

{
  "verified": true
}

Test 3: Wrong signature

curl -X POST http://localhost:9053/wallet/verifySignature \
  -H "api_key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "message": "Different message",
    "signature": "a1b2c3d4...",
    "publicKey": "03abc123..."
  }'

Expected Output:

{
  "verified": false
}

Test 4: Sign with specific address

curl -X POST http://localhost:9053/wallet/signMessage \
  -H "api_key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "message": "Prove I own this address",
    "address": "9f4QF8AD1nQ3nJahQVkMj8hFSVVzVom77b52JU7EW71Zexg6N8v"
  }'

Error Handling

Wallet locked:

{
  "error": 400,
  "reason": "Unable to sign message, wallet is locked",
  "detail": "..."
}

Address not found:

{
  "error": 400,
  "reason": "Address 9f4QF... not found in wallet",
  "detail": "..."
}

Invalid signature format:

{
  "error": 400,
  "reason": "Verification failed: Invalid signature hex encoding",
  "detail": "..."
}

Use Cases

  1. Authentication - Prove ownership of a wallet address
  2. Off-chain signing - Sign messages for dApp interactions
  3. Proof of ownership - Verify address control without transactions
  4. Message attestation - Create verifiable statements
  5. Multi-sig coordination - Collect signatures for multi-party operations

Backward Compatibility

✅ No breaking changes
✅ New endpoints, existing APIs unchanged
✅ No database migrations required
✅ Optional feature, existing workflows unaffected

Checklist

  • Implementation compiles successfully
  • Uses existing ErgoSignature from ergo-wallet
  • Proper error handling (locked wallet, missing keys)
  • JSON codec support
  • Actor integration
  • Key derivation support
  • Address-based key selection
  • Base16 encoding for binary data
  • Tested manually with curl
  • No breaking changes

- Add POST /wallet/signMessage endpoint to sign arbitrary messages
- Add POST /wallet/verifySignature endpoint to verify message signatures
- Implement ErgoSignature-based Schnorr signature scheme
- Support optional address parameter for signing with specific keys
- Return signature and public key in Base16 hex encoding
- Full wallet integration with key derivation support
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR implements message signing and verification endpoints for the Ergo wallet API, allowing users to sign arbitrary messages with wallet keys and verify signatures. The implementation uses Schnorr signatures via the existing ErgoSignature utility from the ergo-wallet library.

Key changes:

  • Added POST /wallet/signMessage endpoint to sign messages with wallet private keys
  • Added POST /wallet/verifySignature endpoint for stateless signature verification
  • Integrated signature functionality through the wallet actor pattern following existing architectural patterns

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 7 comments.

Show a summary per file
File Description
MessageSigningRequests.scala New request models for sign and verify operations
WalletTypes.scala Unrelated type wrapper additions (not part of message signing feature)
ErgoWalletService.scala Core signMessage implementation with key derivation and Schnorr signing
ErgoWalletReader.scala Added signMessage method to wallet reader interface
ErgoWalletActorMessages.scala Added SignMessage actor message for communication
ErgoWalletActor.scala Message handler for SignMessage actor events
WalletApiRoute.scala HTTP route handlers for signMessage and verifySignature endpoints
ApiRequestsCodecs.scala JSON decoders for new request types

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +520 to +537
Try {
import org.ergoplatform.wallet.crypto.ErgoSignature
import sigma.serialization.{GroupElementSerializer, SigmaSerializer}

val messageBytes = req.message.getBytes("UTF-8")
val signatureBytes = Base16.decode(req.signature).get
val publicKeyBytes = Base16.decode(req.publicKey).get

// Parse the public key bytes into EcPointType
val publicKey = GroupElementSerializer.parse(SigmaSerializer.startReader(publicKeyBytes))

// Verify signature using ErgoSignature
val verified = ErgoSignature.verify(messageBytes, signatureBytes, publicKey)

verified
} match {
case Success(verified) => ApiResponse(Json.obj("verified" -> verified.asJson))
case Failure(e) => BadRequest(s"Verification failed: ${e.getMessage}")
Copy link

Copilot AI Dec 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using .get on decode operations can throw exceptions. While these are caught by the outer Try block, it would be better to use pattern matching or flatMap for cleaner error handling. Consider changing this to return a more specific error message when decoding fails, such as "Invalid Base16 encoding for signature" or "Invalid Base16 encoding for public key".

Suggested change
Try {
import org.ergoplatform.wallet.crypto.ErgoSignature
import sigma.serialization.{GroupElementSerializer, SigmaSerializer}
val messageBytes = req.message.getBytes("UTF-8")
val signatureBytes = Base16.decode(req.signature).get
val publicKeyBytes = Base16.decode(req.publicKey).get
// Parse the public key bytes into EcPointType
val publicKey = GroupElementSerializer.parse(SigmaSerializer.startReader(publicKeyBytes))
// Verify signature using ErgoSignature
val verified = ErgoSignature.verify(messageBytes, signatureBytes, publicKey)
verified
} match {
case Success(verified) => ApiResponse(Json.obj("verified" -> verified.asJson))
case Failure(e) => BadRequest(s"Verification failed: ${e.getMessage}")
import org.ergoplatform.wallet.crypto.ErgoSignature
import sigma.serialization.{GroupElementSerializer, SigmaSerializer}
val messageBytes = req.message.getBytes("UTF-8")
Base16.decode(req.signature) match {
case None =>
BadRequest("Invalid Base16 encoding for signature")
case Some(signatureBytes) =>
Base16.decode(req.publicKey) match {
case None =>
BadRequest("Invalid Base16 encoding for public key")
case Some(publicKeyBytes) =>
Try {
// Parse the public key bytes into EcPointType
val publicKey = GroupElementSerializer.parse(SigmaSerializer.startReader(publicKeyBytes))
// Verify signature using ErgoSignature
val verified = ErgoSignature.verify(messageBytes, signatureBytes, publicKey)
verified
} match {
case Success(verified) => ApiResponse(Json.obj("verified" -> verified.asJson))
case Failure(e) => BadRequest(s"Verification failed: ${e.getMessage}")
}
}

Copilot uses AI. Check for mistakes.
Comment on lines +504 to +539
def signMessageR: Route = (path("signMessage") & post & entity(as[SignMessageRequest])) { req =>
val addressOpt = req.address.flatMap(addrStr => ErgoAddressEncoder(ergoSettings.chainSettings.addressPrefix).fromString(addrStr).toOption)
withWalletOp(_.signMessage(req.message, addressOpt.flatMap {
case p2pk: P2PKAddress => Some(p2pk)
case _ => None
})) {
case Success((signature, publicKey)) =>
ApiResponse(Json.obj(
"signature" -> signature.asJson,
"publicKey" -> publicKey.asJson
))
case Failure(e) => BadRequest(e.getMessage)
}
}

def verifySignatureR: Route = (path("verifySignature") & post & entity(as[VerifySignatureRequest])) { req =>
Try {
import org.ergoplatform.wallet.crypto.ErgoSignature
import sigma.serialization.{GroupElementSerializer, SigmaSerializer}

val messageBytes = req.message.getBytes("UTF-8")
val signatureBytes = Base16.decode(req.signature).get
val publicKeyBytes = Base16.decode(req.publicKey).get

// Parse the public key bytes into EcPointType
val publicKey = GroupElementSerializer.parse(SigmaSerializer.startReader(publicKeyBytes))

// Verify signature using ErgoSignature
val verified = ErgoSignature.verify(messageBytes, signatureBytes, publicKey)

verified
} match {
case Success(verified) => ApiResponse(Json.obj("verified" -> verified.asJson))
case Failure(e) => BadRequest(s"Verification failed: ${e.getMessage}")
}
}
Copy link

Copilot AI Dec 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new signMessage and verifySignature endpoints lack test coverage. The test file WalletApiRouteSpec.scala does not include tests for these new endpoints. Consider adding test cases covering: 1) successful message signing and verification, 2) signing with a specific address, 3) handling locked wallet error, 4) handling invalid address, 5) handling invalid signature/public key format in verification.

Copilot uses AI. Check for mistakes.
Comment on lines +1 to +125
package org.ergoplatform.nodeView.wallet

import org.ergoplatform.sdk.SecretString

/**
* Domain-specific type wrappers for wallet operations
*
* These types replace primitive types in communication between the API layer
* and core wallet logic, making the code more type-safe and self-documenting.
*/
object WalletTypes {

/**
* Wrapper for wallet encryption password
*
* Used when initializing, restoring, or unlocking a wallet
*/
final case class WalletPassword(value: SecretString) extends AnyVal

/**
* Wrapper for mnemonic password (BIP-39 passphrase)
*
* Optional password that can be used with a mnemonic for extra security
*/
final case class MnemonicPassword(value: SecretString) extends AnyVal

/**
* Wrapper for wallet mnemonic phrase
*
* The secret recovery phrase used to restore wallet
*/
final case class WalletMnemonic(value: SecretString) extends AnyVal

/**
* Wrapper for BIP-32 derivation path
*
* String representation of a hierarchical deterministic derivation path
*/
final case class DerivationPathString(value: String) extends AnyVal

/**
* Wrapper for scan identifier
*
* Numeric identifier for a wallet scan operation
*/
final case class ScanIdentifier(value: Int) extends AnyVal

/**
* Wrapper for minimum inclusion height
*
* Minimum blockchain height for transaction inclusion
*/
final case class MinInclusionHeight(value: Int) extends AnyVal

/**
* Wrapper for maximum inclusion height
*
* Maximum blockchain height for transaction inclusion
*/
final case class MaxInclusionHeight(value: Int) extends AnyVal

/**
* Wrapper for minimum confirmations count
*
* Minimum number of confirmations required for a transaction
*/
final case class MinConfirmations(value: Int) extends AnyVal

/**
* Wrapper for maximum confirmations count
*
* Maximum number of confirmations to consider for a transaction
*/
final case class MaxConfirmations(value: Int) extends AnyVal

/**
* Wrapper for box index parameter
*
* Index used when reading public keys or wallet boxes
*/
final case class BoxIndex(value: Int) extends AnyVal

/**
* Wrapper for target balance in nanoERG
*
* The amount of ERG requested for box collection
*/
final case class TargetBalance(value: Long) extends AnyVal

/**
* Wrapper for whether to use pre-1627 key derivation
*
* Flag indicating if legacy key derivation should be used
*/
final case class UsePre1627KeyDerivation(value: Boolean) extends AnyVal

/**
* Wrapper for unspent-only filter
*
* Flag indicating whether to return only unspent boxes
*/
final case class UnspentOnly(value: Boolean) extends AnyVal

/**
* Wrapper for consider-unconfirmed filter
*
* Flag indicating whether to consider mempool transactions
*/
final case class ConsiderUnconfirmed(value: Boolean) extends AnyVal

/**
* Wrapper for include-unconfirmed filter
*
* Flag indicating whether to include unconfirmed transactions
*/
final case class IncludeUnconfirmed(value: Boolean) extends AnyVal

/**
* Wrapper for sign transaction flag
*
* Indicates whether to sign a generated transaction
*/
final case class ShouldSignTransaction(value: Boolean) extends AnyVal

}
Copy link

Copilot AI Dec 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The WalletTypes.scala file appears to be a completely new file that is unrelated to message signing functionality. This file introduces domain-specific type wrappers for wallet operations but is not used anywhere in the codebase (no imports found) and is not mentioned in the PR description. This appears to be an unrelated change that should either be part of a separate PR or removed if accidentally included.

Suggested change
package org.ergoplatform.nodeView.wallet
import org.ergoplatform.sdk.SecretString
/**
* Domain-specific type wrappers for wallet operations
*
* These types replace primitive types in communication between the API layer
* and core wallet logic, making the code more type-safe and self-documenting.
*/
object WalletTypes {
/**
* Wrapper for wallet encryption password
*
* Used when initializing, restoring, or unlocking a wallet
*/
final case class WalletPassword(value: SecretString) extends AnyVal
/**
* Wrapper for mnemonic password (BIP-39 passphrase)
*
* Optional password that can be used with a mnemonic for extra security
*/
final case class MnemonicPassword(value: SecretString) extends AnyVal
/**
* Wrapper for wallet mnemonic phrase
*
* The secret recovery phrase used to restore wallet
*/
final case class WalletMnemonic(value: SecretString) extends AnyVal
/**
* Wrapper for BIP-32 derivation path
*
* String representation of a hierarchical deterministic derivation path
*/
final case class DerivationPathString(value: String) extends AnyVal
/**
* Wrapper for scan identifier
*
* Numeric identifier for a wallet scan operation
*/
final case class ScanIdentifier(value: Int) extends AnyVal
/**
* Wrapper for minimum inclusion height
*
* Minimum blockchain height for transaction inclusion
*/
final case class MinInclusionHeight(value: Int) extends AnyVal
/**
* Wrapper for maximum inclusion height
*
* Maximum blockchain height for transaction inclusion
*/
final case class MaxInclusionHeight(value: Int) extends AnyVal
/**
* Wrapper for minimum confirmations count
*
* Minimum number of confirmations required for a transaction
*/
final case class MinConfirmations(value: Int) extends AnyVal
/**
* Wrapper for maximum confirmations count
*
* Maximum number of confirmations to consider for a transaction
*/
final case class MaxConfirmations(value: Int) extends AnyVal
/**
* Wrapper for box index parameter
*
* Index used when reading public keys or wallet boxes
*/
final case class BoxIndex(value: Int) extends AnyVal
/**
* Wrapper for target balance in nanoERG
*
* The amount of ERG requested for box collection
*/
final case class TargetBalance(value: Long) extends AnyVal
/**
* Wrapper for whether to use pre-1627 key derivation
*
* Flag indicating if legacy key derivation should be used
*/
final case class UsePre1627KeyDerivation(value: Boolean) extends AnyVal
/**
* Wrapper for unspent-only filter
*
* Flag indicating whether to return only unspent boxes
*/
final case class UnspentOnly(value: Boolean) extends AnyVal
/**
* Wrapper for consider-unconfirmed filter
*
* Flag indicating whether to consider mempool transactions
*/
final case class ConsiderUnconfirmed(value: Boolean) extends AnyVal
/**
* Wrapper for include-unconfirmed filter
*
* Flag indicating whether to include unconfirmed transactions
*/
final case class IncludeUnconfirmed(value: Boolean) extends AnyVal
/**
* Wrapper for sign transaction flag
*
* Indicates whether to sign a generated transaction
*/
final case class ShouldSignTransaction(value: Boolean) extends AnyVal
}

Copilot uses AI. Check for mistakes.
}

def signMessageR: Route = (path("signMessage") & post & entity(as[SignMessageRequest])) { req =>
val addressOpt = req.address.flatMap(addrStr => ErgoAddressEncoder(ergoSettings.chainSettings.addressPrefix).fromString(addrStr).toOption)
Copy link

Copilot AI Dec 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The code is creating a new ErgoAddressEncoder instance instead of using the implicit addressEncoder already defined at line 36. This is inefficient and inconsistent with the rest of the codebase. Use the implicit encoder instead: addressEncoder.fromString(addrStr).toOption.

Suggested change
val addressOpt = req.address.flatMap(addrStr => ErgoAddressEncoder(ergoSettings.chainSettings.addressPrefix).fromString(addrStr).toOption)
val addressOpt = req.address.flatMap(addrStr => addressEncoder.fromString(addrStr).toOption)

Copilot uses AI. Check for mistakes.
Comment on lines +506 to +509
withWalletOp(_.signMessage(req.message, addressOpt.flatMap {
case p2pk: P2PKAddress => Some(p2pk)
case _ => None
})) {
Copy link

Copilot AI Dec 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When a non-P2PK address is provided, the code silently falls back to using the first available key (addressOpt becomes None). This could be confusing for API users who provide a valid Ergo address that isn't a P2PK address. Consider returning a more informative error message such as "Address must be a P2PK address for message signing" when the address is valid but not P2PK.

Copilot uses AI. Check for mistakes.
Comment on lines +9 to +18
case class SignMessageRequest(message: String, address: Option[String])

/**
* A request to verify a signed message
*
* @param message - original message
* @param signature - signature bytes in Base16 encoding
* @param publicKey - public key bytes in Base16 encoding
*/
case class VerifySignatureRequest(message: String, signature: String, publicKey: String)
Copy link

Copilot AI Dec 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The SignMessageRequest and VerifySignatureRequest case classes don't validate their inputs. Empty strings are valid for message, signature, and publicKey fields, which could lead to confusing behavior. Consider adding input validation to ensure: 1) message is not empty, 2) signature and publicKey are valid Base16 strings with expected lengths (signature should be 112 hex chars for 56 bytes, publicKey should be 66 hex chars for 33 bytes).

Copilot uses AI. Check for mistakes.
Comment on lines +593 to +608
val secret = addressOpt match {
case Some(address) =>
// Find the path for the given address
state.storage.readAllKeys().find(_.key.value == address.pubkey.value) match {
case Some(extKey) =>
rootSecret.derive(extKey.path.toPrivateBranch)
case None =>
throw new Exception(s"Address ${address.toString} not found in wallet")
}
case None =>
// Use first available key
val firstPath = state.storage.readAllKeys().headOption match {
case Some(extKey) => extKey.path
case None => throw new Exception("No keys available in wallet")
}
rootSecret.derive(firstPath.toPrivateBranch)
Copy link

Copilot AI Dec 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The signMessage method calls state.storage.readAllKeys() potentially multiple times (once to find a specific address, or once to get the first key). This could be inefficient if the wallet has many keys. Consider reading all keys once and reusing the result, especially since this is called within a Try block that may execute frequently.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants