Authentication
If your game needs to remember user-specific information like balances, inventory, or other persistent state, you'll want to have an authentication mechanism to uniquely identify your users. You're free to implement traditional methods like username/password authentication. However, some users might find it more convenient to sign in using their wallet's public key. This guide details how to integrate Ivy's wallet-based authentication into your game.
Design
In Ivy's wallet authentication, the user creates an ed25519 signature of a specific message with their private key. Your game backend is then responsible for verifying this signature. The message has the following structure:
Authenticate user [user public key] to game [game public key] on ivypowered.com, valid from [start unix timestamp] to [end unix timestamp]
In the current frontend, the start timestamp of the validity period is set to 60 seconds before the user's current time, and the end timestamp is set to 24 hours after the user's current time, though these parameters may be subject to change in future. Your game should reject the authentication signature if the current timestamp is not within the interval of validity.
Obtaining the Signature
Your game, which runs inside an <iframe>
, communicates with the Ivy frontend using the web postMessage API.
Here's how to get started:
1. Retrieve Parent Origin
The postMessage
API allows any window to send any message to any other window. To filter for messages sent by Ivy, we'll retrieve the Ivy website's origin, which is passed to our <iframe>
through the parentOrigin
query parameter. Insert this somewhere in your game's client-side JavaScript:
const parentOrigin = new URLSearchParams(document.location.search).get(
"parentOrigin",
);
2. Subscribe to State Updates
Next, you'll want to subscribe to the Ivy state from within your game's client-side JavaScript:
parent.postMessage({ action: "subscribe" }, parentOrigin);
After subscribing, the Ivy frontend will immediately send a message containing the current state to your <iframe>
, and will send a new message with the updated state whenever the authentication state changes.
3. Listen for State Updates
Next, in your game's client-side JavaScript, you'll want to create an event listener to capture state updates from the Ivy frontend:
window.addEventListener("message", function (event) {
// Ensure origin is the parent document
if (event.origin !== parentOrigin) return;
// Log current Ivy state
console.log("Received Ivy state", event.data);
});
The event.data
will be an object describing the current authentication status. It has the following structure:
interface State {
user: string | null; // The user's base58 encoded 32-byte public key
message: string | null; // The authentication message for this game
signature: string | null; // The hex-encoded 64-byte signature for the authentication message
}
You'll receive:
{ user: null, signature: null }
, when the user has loaded the page but has not connected their wallet, you'll receive `.{ user: "(base58 public key)", message: null, signature: null }
, when the user has connected their wallet but not signed an authentication signature for your game{ user: "(base58 public key)", message: "(auth message string)", signature: "(hex signature)" }
, when the user has connected their wallet and signed an authentication message for your game
The authentication message and signature for your game are persisted in local storage, so users won't have to sign in twice within a 24-hour period if they're using the same device.
If an authentication message or signature expire on the frontend, they'll be automatically cleared, and message
and signature
will be set to null
so that your application can request the user to sign a new message.
If you ever want to allow the user to "log out", or clear the authentication message and signature from local storage, you can do so with:
parent.postMessage({ action: "logout" }, parentOrigin);
This will have no effect if the user is not logged in to your game already.
4. Prompt User if Necessary
If user
is null, you'll want to show the "Connect Wallet" dialog. To do this, write:
parent.postMessage({ action: "connect_wallet" }, parentOrigin);
This will have no effect if the user has already connected their wallet.
If user
is a public key but msg
and signature
are null, you'll want to show the "Sign Message" dialog. To do this, write:
parent.postMessage({ action: "sign_message" }, parentOrigin);
The user will be prompted to sign the game authentication message. This will have no effect if the user has not connected a wallet yet.
If you ever need to force a reload of the wallet balance shown to the user on the Ivy frontend, you can do so with:
parent.postMessage({ action: "reload_balance" }, parentOrigin);
Verifying the User
Once you've obtained the user's signature for the authentication message, you can treat the (message, signature)
pair as an authentication token, and append them to all requests to your game's backend. On the backend, you'll want to verify this data before performing sensitive operations. You can do this in several ways:
Using the REST API
The REST API contains an endpoint to verify authentication details:
POST /api/games/{game}/authenticate
Verify that the given message
is valid for the game game
, correctly signed with signature
, and is valid at the current timestamp.
With the request body:
{
"message": "(User authentication message as a string)",
"signature": "(The base58-encoded signature)"
}
Where:
game
: The base58-encoded public key of your gamemessage
: The authentication message that was signedsignature
: The base58-encoded signature of the message
Example response:
{
"status": "ok",
"data": "2eo1opvtmFoTaTpuSNF32rcEAztkVykLiCw1yzpj1CVx"
}
The data
field contains the base58-encoded public key of the authenticated user. If the signature is invalid, the server will return an error:
{
"status": "err",
"data": "Unauthorized: Invalid message format"
}
Using the JS SDK
The JS SDK provides a function to verify a message with a given signature:
import { Auth } from "ivy-sdk";
// Example parameters
const game = new PublicKey("8EE4ggc5MMrXW3HyFVHErMMaGPjwEtnHcZ7Xb84ASXPw");
const msg =
"Authenticate user 2eo1opvtmFoTaTpuSNF32rcEAztkVykLiCw1yzpj1CVx to game 8EE4ggc5MMrXW3HyFVHErMMaGPjwEtnHcZ7Xb84ASXPw on ivypowered.com, valid from 1746776050 to 1746862522";
const signature = Buffer.from(
"2de54326317fcd1ab769752aa234cd6854c46577d2afb2539abea078e36bbb65bb1c4c767f0f051335066fb84e2d46e2526ddbf0eb6c95a4ae4fb26927a14305",
"hex",
);
// Verify message, and extract user that signed it
const user = Auth.verifyMessage(game, msg, signature);
In Python
If your backend's in Python, you can use the following implementation:
import re
import time
import base58
from nacl.signing import VerifyKey
from nacl.exceptions import BadSignatureError
def verify_message(game_address: str, message: str, signature: str) -> str:
"""
Verify an authentication message and return the authenticated user.
Args:
game_address: The base58-encoded game public key
message: The authentication message
signature: The hex-encoded signature (64 bytes represented as 128 hex characters)
Returns:
The base58-encoded user public key
Raises:
ValueError: If the message is invalid or signature verification fails
"""
# Check message length
if len(message) > 256:
raise ValueError("Unauthorized: Message too long")
# Parse the message
pattern = r"^Authenticate user ([1-9A-Za-z]+) to game ([1-9A-Za-z]+) on ivypowered\.com, valid from ([0-9]+) to ([0-9]+)$"
match = re.match(pattern, message)
if not match:
raise ValueError("Unauthorized: Incorrect message format")
user_b58, game_provided_b58, start_str, end_str = match.groups()
# Verify game address
if game_provided_b58 != game_address:
raise ValueError(f"Unauthorized: Expected message to have game {game_address}, but got {game_provided_b58}")
# Verify timestamps
start = int(start_str)
end = int(end_str)
current_time = int(time.time())
if current_time < start or current_time > end:
raise ValueError(f"Unauthorized: Message is only valid within the interval [{start}, {end}], but the current time is {current_time}")
try:
# Convert hex signature to bytes
try:
signature_bytes = bytes.fromhex(signature)
except ValueError:
raise ValueError("Unauthorized: Invalid hex-encoded signature")
# Decode base58 user public key
user_pubkey_bytes = base58.b58decode(user_b58)
# Create verify key from user's public key
verify_key = VerifyKey(user_pubkey_bytes)
# Verify the signature
verify_key.verify(message.encode(), signature_bytes)
# Return the authenticated user
return user_b58
except BadSignatureError:
raise ValueError("Unauthorized: Message signature is invalid")
except Exception as e:
raise ValueError(f"Unauthorized: {str(e)}")
In Rust
If you're using Rust, you can use the following implementation:
use std::time::{SystemTime, UNIX_EPOCH};
use std::fmt;
use std::error::Error;
use base58::{FromBase58, ToBase58};
use ed25519_dalek::{PublicKey, Signature, Verifier};
use regex::Regex;
// Simple error type
#[derive(Debug)]
pub enum AuthError {
Unauthorized(String),
Internal(String),
}
impl fmt::Display for AuthError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
AuthError::Unauthorized(msg) => write!(f, "Unauthorized: {}", msg),
AuthError::Internal(msg) => write!(f, "Internal error: {}", msg),
}
}
}
impl Error for AuthError {}
pub fn verify_message(
game_address: &str,
message: &str,
signature: &str,
) -> Result<String, AuthError> {
// Check message length
if message.len() > 256 {
return Err(AuthError::Unauthorized("Message too long".to_string()));
}
// Parse the message using regex
let re = Regex::new(r"^Authenticate user ([1-9A-Za-z]+) to game ([1-9A-Za-z]+) on ivypowered\.com, valid from ([0-9]+) to ([0-9]+)$")
.map_err(|e| AuthError::Internal(format!("Regex error: {}", e)))?;
let captures = re.captures(message)
.ok_or_else(|| AuthError::Unauthorized("Incorrect message format".to_string()))?;
let user_b58 = captures.get(1).unwrap().as_str();
let game_provided_b58 = captures.get(2).unwrap().as_str();
let start_str = captures.get(3).unwrap().as_str();
let end_str = captures.get(4).unwrap().as_str();
// Verify game address
if game_provided_b58 != game_address {
return Err(AuthError::Unauthorized(
format!("Expected message to have game {}, but got {}", game_address, game_provided_b58)
));
}
// Verify timestamps
let start = start_str.parse::<u64>()
.map_err(|_| AuthError::Unauthorized("Message start time is not a natural number".to_string()))?;
let end = end_str.parse::<u64>()
.map_err(|_| AuthError::Unauthorized("Message end time is not a natural number".to_string()))?;
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|e| AuthError::Internal(format!("System time error: {}", e)))?
.as_secs();
if now < start || now > end {
return Err(AuthError::Unauthorized(
format!("Message is only valid within the interval [{}, {}], but the current time is {}",
start, end, now)
));
}
// Convert hex signature to bytes
let signature_bytes = hex::decode(signature)
.map_err(|_| AuthError::Unauthorized("Invalid hex-encoded signature".to_string()))?;
if signature_bytes.len() != 64 {
return Err(AuthError::Unauthorized("Signature must be 64 bytes".to_string()));
}
// Decode base58 user public key
let user_pubkey_bytes = user_b58.from_base58()
.map_err(|_| AuthError::Unauthorized("Invalid base58-encoded public key".to_string()))?;
// Verify signature
let dalek_public_key = PublicKey::from_bytes(&user_pubkey_bytes)
.map_err(|_| AuthError::Unauthorized("Invalid public key format".to_string()))?;
let dalek_signature = Signature::from_bytes(&signature_bytes)
.map_err(|_| AuthError::Unauthorized("Invalid signature format".to_string()))?;
dalek_public_key.verify(message.as_bytes(), &dalek_signature)
.map_err(|_| AuthError::Unauthorized("Message signature is invalid".to_string()))?;
// Return the authenticated user
Ok(user_b58.to_string())
}