This article was originally published on dev.to on July 20, 2024.
Abstract
This article outlines the process of signing and verifying a signature on Starknet. It begins by introducing Account Abstraction and how it modifies signature verification compared to traditional blockchains like Ethereum. It then provides comprehensive code examples in TypeScript and Go for signing a message and verifying a signature using two methods available on Starknet: using the user’s public key and using the user’s account address.
A live signature playground is available at https://signatures.felts.xyz
All the code examples given in this article are available in the associated GitHub repository. I want to thank Thiago for his help on the code snippets.
Account Abstraction
In Ethereum, individual user accounts, known as Externally Owned Accounts (EOAs), are controlled by a pair of private and public keys. Transactions require a signature from the private key to modify the account state. While secure, this system has significant drawbacks, such as irreversible asset loss if the private key is lost or stolen, limited wallet functionality, and a lack of user-friendly key or account recovery options.
Starknet addresses these limitations through Account Abstraction (AA), which manages accounts through smart contracts instead of private keys. This approach allows smart contracts to validate their transactions, enabling features like gas fees covered by smart contracts, multiple signers for a single account, and various cryptographic signatures. AA enhances security and user experience by enabling developers to design custom security models, such as different keys for routine and high-value transactions and biometric authentication for enhanced security. It also simplifies key recovery and management with methods like social recovery and hardware-based transaction signing. Additionally, AA supports key rotation, session keys for web3 applications, and diverse signature and validation schemes, allowing for tailored security measures. By addressing the inherent limitations of Ethereum’s EOA model, Starknet’s AA provides a more flexible, secure, and user-friendly approach to account management, significantly improving blockchain interactions.
Signature
With an understanding of Account Abstraction, we can now explore how it changes signature verification. First, it is essential to understand the composition of a signature. The STARK curve is an elliptic curve, and its signatures are ECDSA signatures, which consist of two values: r
and s
. The signature is generated by signing a message with the private key and can be verified using the public key. For more information about ECDSA signatures, refer to the Wikipedia page.
Signing a Message
In Starknet, messages to be signed typically follow the EIP-712 format. This message format includes four mandatory fields: types
, primaryType
, domain
, and message
. The types
field maps type names to their corresponding type definitions. The primaryType
field specifies the primary type of the message. The domain
field contains key-value pairs that specify the chain context. The message
field includes key-value pairs that describe the message. We usually represent the message as a JSON object:
{
types: {
StarkNetDomain: [
{ name: "name", type: "felt" },
{ name: "chainId", type: "felt" },
{ name: "version", type: "felt" },
],
Message: [{ name: "message", type: "felt" }],
},
primaryType: "Message",
domain: {
name: "MyDapp",
chainId: "SN_MAIN",
version: "0.0.1",
},
message: {
message: "hello world!",
},
}
To sign a message, you need the private key. For an in-depth understanding of the signature process, refer to the ECDSA signature algorithm. Below is the code to sign a message.
TypeScript:
import { ec, encode, TypedData, Signer, typedData, WeierstrassSignatureType } from 'starknet';
//--------------------------------------------------------------------------
// Account
//--------------------------------------------------------------------------
const privateKey = '0x1234567890987654321';
const starknetPublicKey = ec.starkCurve.getStarkKey(privateKey);
const fullPublicKey = encode.addHexPrefix(
encode.buf2hex(ec.starkCurve.getPublicKey(privateKey, false))
);
const pubX = starknetPublicKey
const pubY = encode.addHexPrefix(fullPublicKey.slice(68))
//--------------------------------------------------------------------------
// Message
//--------------------------------------------------------------------------
const messageStructure: TypedData = {
types: {
StarkNetDomain: [
{ name: "name", type: "felt" },
{ name: "chainId", type: "felt" },
{ name: "version", type: "felt" },
],
Message: [{ name: "message", type: "felt" }],
},
primaryType: "Message",
domain: {
name: "MyDapp",
chainId: "SN_MAIN",
version: "0.0.1",
},
message: {
message: "hello world!",
},
};
const messageHash = typedData.getMessageHash(messageStructure, BigInt(starknetPublicKey))
//--------------------------------------------------------------------------
// Signature
//--------------------------------------------------------------------------
const signer = new Signer(privateKey)
let signature: WeierstrassSignatureType;
try {
signature = (await signer.signMessage(messageStructure, starknetPublicKey)) as WeierstrassSignatureType
} catch (error) {
console.error("Error signing the message:", error);
}
// signature has properties r and s
Go:
package main
import (
"fmt"
"math/big"
"strconv"
"github.com/NethermindEth/starknet.go/curve"
"github.com/NethermindEth/starknet.go/typed"
"github.com/NethermindEth/starknet.go/utils"
)
// NOTE: at the time of writing, starknet.go forces us to create a custom
// message type as well as a method to format the message encoding since
// there is no built-in generic way to encode messages.
type MessageType struct {
Message string
}
// FmtDefinitionEncoding is a method that formats the encoding of the message
func (m MessageType) FmtDefinitionEncoding(field string) (fmtEnc []*big.Int) {
if field == "message" {
if v, err := strconv.Atoi(m.Message); err == nil {
fmtEnc = append(fmtEnc, big.NewInt(int64(v)))
} else {
fmtEnc = append(fmtEnc, utils.UTF8StrToBig(m.Message))
}
}
return fmtEnc
}
func main() {
//--------------------------------------------------------------------------
// Account
//--------------------------------------------------------------------------
privateKey, _ := new(big.Int).SetString("1234567890987654321", 16)
pubX, pubY, err := curve.Curve.PrivateToPoint(privateKey)
if err != nil {
fmt.Printf("Error: %s\n", err)
return
}
if !curve.Curve.IsOnCurve(pubX, pubY) {
fmt.Printf("Point is not on curve\n")
return
}
starknetPublicKey := pubX
// IMPORTANT: this is not a standard way to retrieve the full public key, it
// is just for demonstration purposes as starknet.go does not provide a way
// to retrieve the full public key at the time of writing.
// Rule of thumb: never write your own cryptography code!
fullPublicKey := new(big.Int).SetBytes(append(append(
[]byte{0x04}, // 0x04 is the prefix for uncompressed public keys
pubX.Bytes()...), pubY.Bytes()...), // concatenate x and y coordinates
)
//--------------------------------------------------------------------------
// Message
//--------------------------------------------------------------------------
types := map[string]typed.TypeDef{
"StarkNetDomain": {
Definitions: []typed.Definition{
{Name: "name", Type: "felt"},
{Name: "chainId", Type: "felt"},
{Name: "version", Type: "felt"},
},
},
"Message": {
Definitions: []typed.Definition{
{Name: "message", Type: "felt"},
},
},
}
primaryType := "Message"
domain := typed.Domain{
Name: "MyDapp",
ChainId: "SN_MAIN",
Version: "0.0.1",
}
message := MessageType{
Message: "hello world!",
}
td, err := typed.NewTypedData(types, primaryType, domain)
if err != nil {
fmt.Println("Error creating TypedData:", err)
return
}
hash, err := td.GetMessageHash(starknetPublicKey, message, curve.Curve)
if err != nil {
fmt.Println("Error getting message hash:", err)
return
}
//--------------------------------------------------------------------------
// Signature
//--------------------------------------------------------------------------
r, s, err := curve.Curve.Sign(hash, privateKey)
if err != nil {
fmt.Println("Error signing message:", err)
return
}
}
If you are developing a dApp, you won’t have access to the user’s private key. Instead, you can use the starknet.js library to sign the message. The code will interact with the browser wallet (typically ArgentX or Braavos) to sign the message. You can find a live demo at https://signatures.felts.xyz. Here is the simplified code to sign a message in TypeScript using the browser wallet (full code available in the GitHub repository):
import { connect } from "get-starknet";
const starknet = await connect(); // Connect to the browser wallet
const messageStructure: TypedData = {
types: {
StarkNetDomain: [
{ name: "name", type: "felt" },
{ name: "chainId", type: "felt" },
{ name: "version", type: "felt" },
],
Message: [{ name: "message", type: "felt" }],
},
primaryType: "Message",
domain: {
name: "MyDapp",
chainId: "SN_MAIN",
version: "0.0.1",
},
message: {
message: "hello world!",
},
};
// skipDeploy allows not-deployed accounts to sign messages
const signature = await starknet.account.signMessage(messageStructure, { skipDeploy: true });
Once the message is signed, the signature is obtained in the form of r
, s
, and v
. The v
value is the recovery id, which can be used to recover the public key from the signature (see Wikipedia for more information). However, this recovery process cannot be fully trusted for verifying a signature unless the signer’s public key is known beforehand. The r
and s
values are the signature values used to verify the signature.
IMPORTANT: Depending on the browser wallet, the signature might only return r
and s
values. The v
value is not always provided.
Verifying a Signature
To verify a signature, the public key is required from a cryptographic perspective. However, due to Account Abstraction in Starknet, access to the public key is not always available. Currently, the public key cannot be retrieved through the browser wallet. Therefore, two methods are distinguished for verifying a signature: using the user’s public key (if available) or using the user’s address (i.e., account smart contract address).
Using the User’s Public Key
If the user’s public key is available, the signature can be verified using the public key. Here is the code to verify a signature.
TypeScript:
// following the previous code
const isValid = ec.starkCurve.verify(signature, messageHash, fullPublicKey)
Go:
// following the previous code
isValid := curve.Curve.Verify(hash, r, s, starknetPublicKey, pubY)
Using the User’s Address
NOTE: This method works only if the user’s account smart contract has been deployed (activated) on the Starknet network. This deployment is typically done through the browser wallet when the user creates an account and requires some gas fees. The skipDeploy
parameter is specified in the JavaScript code when signing with the browser wallet. The example code provided earlier will not work with signatures different from the browser wallet since a sample private key was used to sign the message.
IMPORTANT: Avoid using your own private key when experimenting with the code. Always sign transactions with the browser wallet.
If the user’s public key is not available, the signature can be verified using the user’s account smart contract. By the standard SRC-6, the user account smart contract has a function fn is_valid_signature(hash: felt252, signature: Array<felt252>) -> felt252;
that takes the hash of the message and the signature (in the form of an array of 2 felt252 values: r
and s
) and returns the string VALID
if the signature is valid, or fails otherwise. Here is the code to verify a signature using the user’s account address in TypeScript and Go.
TypeScript (simplified for readability):
import { Account, RpcProvider } from "starknet";
const provider = new RpcProvider({ nodeUrl: "https://your-rpc-provider-url" });
// '0x123' is a placeholder for the user's private key since we don't have access to it
const account = new Account(provider, address, '0x123')
try {
// messageStructure and signature are obtained from the previous code when signing the message with the browser wallet
const isValid = account.verifyMessage(messageStructure, signature)
console.log("Signature is valid:", isValid)
} catch (error) {
console.error("Error verifying the signature:", error);
}
Go (simplified for readability):
import (
"context"
"encoding/hex"
"fmt"
"math/big"
"github.com/NethermindEth/juno/core/felt"
"github.com/NethermindEth/starknet.go/curve"
"github.com/NethermindEth/starknet.go/rpc"
"github.com/NethermindEth/starknet.go/utils"
)
...
provider, err := rpc.NewProvider("https://your-rpc-provider-url")
if err != nil {
// handle error
}
// we import the account address, r, and s values from the frontend (typescript)
accountAddress, _ := new(big.Int).SetString("0xabc123", 16)
r, _ := new(big.Int).SetString("0xabc123", 16)
s, _ := new(big.Int).SetString("0xabc123", 16)
// we need to get the message hash, but, this time, we use the account address instead of the public key. `message` is the same as the in the previous Go code
hash, err := td.GetMessageHash(accountAddress, message, curve.Curve)
if err != nil {
// handle error
}
callData := []*felt.Felt{
utils.BigIntToFelt(hash),
(&felt.Felt{}).SetUint64(2), // size of the array [r, s]
utils.BigIntToFelt(r),
utils.BigIntToFelt(s),
}
tx := rpc.FunctionCall{
ContractAddress: utils.BigIntToFelt(accountAddress),
EntryPointSelector: utils.GetSelectorFromNameFelt(
"is_valid_signature",
),
Calldata: callData,
}
result, err := provider.Call(context.Background(), tx, rpc.BlockID{Tag: "latest"})
if err != nil {
// handle error
}
isValid, err := hex.DecodeString(result[0].Text(16))
if err != nil {
// handle error
}
fmt.Println("Signature is valid:", string(isValid) == "VALID")
Usage
Signatures can be used in various applications, with user authentication in web3 dApps being a primary use case. To achieve this, use the structure provided above for signature verification using the user’s account address. Here is the complete workflow:
- The user signs a message with the browser wallet.
- Send the user address, message, and signature (r, s) to the backend.
- The backend verifies the signature using the user’s account smart contract.
Make sure that the message structure is the same on the frontend and backend to ensure the signature is verified correctly.
Conclusion
I hope that this article provided you with a comprehensive understanding of the signatures on Starknet and helped you implement it in your applications. If you have any questions or feedback, feel free to comment or reach out to me on Twitter or GitHub. Thank you for reading!
Sources: