7

I am building an application that will hopefully offer a login option via Nami wallet, rather than the typical email/password. I understand how this approach can work for on-chain data, but is it possible (and if so, what are the limitations?) to use wallet authentication to associate users with other site data?

For example, my site will allow users to create "projects" to organize links and relevant data from the website. This data will be stored off-chain in a database. To ensure data privacy, the backend server must be able to verify that the user has access to the correct wallet. Is there a way to generate a site-specific password (i.e. private key) that can be securely stored on the wallet, then sent to the backend for authentication?

One alternative would be to remove centralized storage of private data altogether, and instead store data off-site where only the user can access (such as local file storage). Then this could be loaded using the File System Access API. This is more aligned with the spirit of decentralization, but is not yet widely supported and raises issues for multi-device support.

Of course I could skip the Nami login altogether and stick to the traditional email/password approach for authentication. Then, users would connect to Nami later on when they need to perform an action that involves the wallet. The downside here is the requirement of an email and a longer log-in/sign-up process. Thoughts?

2 Answers 2

14

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:

  1. Connect to wallet ->window.cardano.enable()
  2. Send public address to backend -> window.cardano.getRewardAddress()
  3. Store public address and a signature (description + nonce) in database
  4. 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)
  1. Send the signed signature to backend
  2. Verify that signature was signed by correct wallet (explained below)
  3. 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;
};
9
  • Any idea why I am getting when calling verifySignedMessage: ERROR [ExceptionsHandler] Deserialization failed in Address because: Encountered unknown address header 0b10101101
    – Jingles
    Apr 17, 2022 at 4:34
  • Getting this error on: const addressCose: Serialization.Address = Serialization.Address.from_bytes(headerCBORBytes);
    – Jingles
    Apr 17, 2022 at 6:12
  • @Jingles I'm not sure what's causing that error, but I think I have an idea. After writing my original answer, the authentication processed changed a bit (unless you're using Nami, because their new code doesn't work correctly last time I checked). It's probably an error with the coseSign1Hex parameter being passed into verifySignedMessage. Instead of signing the message with window.cardano.signData, you have to use the signData function on the object returned by window.cardano.enable(). I will update my answer now Apr 17, 2022 at 20:07
  • @Jingles Here is the CIP that caused the change: github.com/cardano-foundation/CIPs/pull/148 Apr 17, 2022 at 20:37
  • 1
    Thanks. The codes in your repo works. I replaced the backend codes. Had issues on verifyAddress.
    – Jingles
    Apr 19, 2022 at 4:32
3

There are a few options for something like this, Nami specifically supports signData which lets the user sign some arbitrary data with their private key. Your backend can then take this signature and public key to authenticate the user behind the wallet.

So if your data is the same, it will produce a unique signature for each wallet that signs it. You can store this signature in the database (which is totally safe since the signature isn't private information), and use it to associate a particular user with a project or whatever other information you want to store in your database.

An alternative (more centralized) approach is to still have some form of username:password authentication, but instead of letting the user fill in their own password, just sign some data with their wallet and use that signature as their password. When a user logs in, you just need to verify that the password (read: signature) is signed by their wallet. Then you can still use all the standard session handling and user association of a regular application, with some added decentralization and security.

There's some example code here that implements message signing with Nami and verification using Emurgo's message_signing library: https://github.com/Berry-Pool/nami-wallet/blob/faf5a7a7993defbb7267a25a5f4cef4e06d39c7d/MessageSigning.md

1
  • 1
    Thank you! This is exactly the type of reply I was looking for. Dec 3, 2021 at 17:03

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge that you have read and understand our privacy policy and code of conduct.

Not the answer you're looking for? Browse other questions tagged or ask your own question.