For anyone looking for a more exact solution, below is an explanation for how I implemented this in JavaScript.
To understand the authentication flow, I started by reading this article. While the article is for Metamask and Ethereum, the same flow can be applied with Nami (or Eternl, etc) and Cardano. It goes:
- Connect to wallet ->
window.cardano.enable()
- Send public address to backend ->
window.cardano.getRewardAddress()
- Store public address and a signature (description + nonce) in database
- Return hex string of signature to frontend, and have your wallet extension sign it. This requires the user to enter their staking password. If there is a way to get around this, I'd love to know.
- For most wallets -> call
signData(publicAddress, signature)
on the object returned in step 1.
- For Nami (last time I checked the newer method didn't work right) ->
window.cardano.signData(publicAddress, signature)
- Send the signed signature to backend
- Verify that signature was signed by correct wallet (explained below)
- Return http cookie with JWT, signed by backend secret key (won't detail how this is done, as there are many non-crypto tutorials of this online)
The most difficult part is verifying the signed nonce. Using this article from Thom's answer, we can see that verification relies on two packages: cardano-serialization-lib-nodejs and emurgo_message_signing.
The first can be added to the backend server through npm or yarn. The second is more difficult, as it is written in Rust. To create a javascript-friendly version, you must first install Rust and wasm-pack. Then, you can clone emurgo_message_signing, start a terminal in that project's "rust" directory, and enter wasm-pack build --target nodejs
. The "rust" directory can then be moved into your project's backend directory and imported like a normal package: const MS = require('./message-signing/rust/pkg/emurgo_message_signing')
.
If you are using TypeScript and ES6, you may run into additional problems because tsc doesn't copy the additional WebAssembly files used by emurgo_message_signing. I got around this by creating a post-install command in my backend's package.json file that copies them over.
Using Thom's example and the example file in emurgo_message_signing, you can get a sense for how to create and verify the signature. This is how I implemented it:
import * as Serialization from '@emurgo/cardano-serialization-lib-nodejs';
import * as MessageSigning from './message_signing/rust/pkg/emurgo_message_signing';
import { randomBytes } from 'crypto';
// Generate a random string of the specified length, consisting of the specified characters
function randomString(
length: number = 64,
chars: string = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
): string {
// Check for valid parameters
if (length <= 0 || length > 2048) throw new Error('Length must be bewteen 1 and 2048.');
const charsLength = chars.length;
if (charsLength < 10 || chars.length > 256) throw new Error('Chars must be bewteen 10 and 256.');
// Generate random bytes
const bytes = randomBytes(length);
// Create result array
let result = new Array(length);
// Fill result array with bytes, modified to consist of the specified characters
let cursor = 0;
for (let i = 0; i < length; i++) {
cursor += bytes[i];
result[i] = chars[cursor % charsLength];
}
// Return result as string
return result.join('');
}
// Generate signable nonce, which includes human-readable description
// Returns hex string
export const generateNonce = async (
description: string = 'Please sign this message so we can verify your wallet:',
length: number = 64,
) => {
if (length <= 0 || length > 2048) throw new Error('Length must be bewteen 1 and 2048');
// Generate nonce (payload)
const payload = randomString(length);
// Return description + nonce
return Buffer.from(`${description} ${payload}`).toString('hex');
}
/**
* Determines if a wallet address signed a message (payload)
* @param address Serialized wallet address
* @param payload Serialized payload (i.e. message with nonce)
* @param coseSign1Hex Hex string of signed payload (signed by user's wallet)
* @returns True if payload was signed by wallet address
*/
export const verifySignedMessage = (address: string, payload: string, coseSign1Hex: string) => {
const coseSign1 = MessageSigning.COSESign1.from_bytes(Buffer.from(coseSign1Hex, 'hex'));
const payloadCose: Uint8Array | undefined = coseSign1.payload();
if (!payloadCose || !verifyPayload(payload, payloadCose)) {
throw new Error('Payload does not match');
}
const protectedHeaders: MessageSigning.HeaderMap = coseSign1
.headers()
.protected()
.deserialized_headers();
const headerCBORBytes: Uint8Array | undefined = protectedHeaders.header(MessageSigning.Label.new_text('address'))?.as_bytes();
if (!headerCBORBytes) {
throw new Error('Failed to convert header to bytes');
}
const keyId: Uint8Array | undefined = protectedHeaders.key_id();
if (!keyId) {
throw new Error('Failed to get keyId from header');
}
const addressCose: Serialization.Address = Serialization.Address.from_bytes(headerCBORBytes);
const publicKeyCose = Serialization.PublicKey.from_bytes(keyId);
if (!verifyAddress(address, addressCose, publicKeyCose))
throw new Error('Could not verify because of address mismatch');
const signature = Serialization.Ed25519Signature.from_bytes(coseSign1.signature());
const data = coseSign1.signed_data().to_bytes();
return publicKeyCose.verify(data, signature);
};
const verifyPayload = (payload: string, payloadCose: Uint8Array) => {
return Buffer.from(payloadCose).compare(Buffer.from(payload, 'hex')) === 0;
};
const verifyAddress = (address: string, addressCose: Serialization.Address, publicKeyCose: Serialization.PublicKey) => {
const checkAddress = Serialization.Address.from_bytes(Buffer.from(address, 'hex'));
if (addressCose.to_bech32() !== checkAddress.to_bech32()) return false;
// check if RewardAddress
try {
//reconstruct address
const stakeKeyHash = publicKeyCose.hash();
const reconstructedAddress = Serialization.RewardAddress.new(
checkAddress.network_id(),
Serialization.StakeCredential.from_keyhash(stakeKeyHash)
);
if (
checkAddress.to_bech32() !== reconstructedAddress.to_address().to_bech32()
)
return false;
return true;
} catch (error) {
console.error('Caught error verifying address', error)
}
return false;
};