11 KiB
NIP-111
Nostr-Specific Private Key Generation from Deterministic Wallet Signatures (Sign-In-With-X)
draft
optional
author:0xc0de4c0ffee
author:sshmatrix
Abstract
This specification provides an optional method for Nostr Clients, NIP-07 providers and Wallet providers to generate deterministic private keys from chain-agnostic CAIP-122 Signatures (Sign-In-With-X
specification). The keypairs generated using this specification are Nostr-specific and do not expose the original signing keypair. The new private keys are derived using SHA-256 HMAC Key Derivation Function (HKDF) with NIP-02 or NIP-05 names, CAIP-02 Blockchain ID & CAIP-10 Account ID Specification identifiers, and deterministic signatures from connected wallets as inputs.
Introduction
NIP-111 at its core is an account abstraction specification in which a cryptographic signature calculated by one signing algorithm and its native keypair (e.g. Bitcoin-native Schnorr algorithm) can be used to derive a deterministic cryptographic keypair for another signing algorithm (e.g. Ethereum-native ECDSA algorithm) using an appropriate singular (non-invertible) key derivation function. This specification particularly describes the case where the former and latter algorithms are Schnorr and ECDSA respectively, and the one-way adaptor from ECDSA to Schnorr keypair is HMAC-based Key Derivation Function (HKDF).
NIP-111 specification originated from the desire to allow Nostr to function with widely popular Ethereum wallets such as Metamask and leverage the strong network effects of Ethereum ecosystem. The problem however lay in the fact that Nostr Protocol uses Bitcoin-native Schnorr algorithm for signing messages/data while Ethereum (and its wallets such as Metamask etc) uses ECDSA algorithm. The difference in two signing algorithms and respective signing keypairs is the exact technical incompatibility that this specification originally succeeded in resolving by enabling Sign-In With Ethereum (SIWE) on Nostr. The underlying schema however is fully capable of functioning as a chain-agnostic workflow and this improved draft reflects that property by using CAIP (Chain-Agnostic Improvement Proposals) implementations.
Terminology
a) Username
username
is either of the following:
petname
is a NIP-02 compatible name,petname@example.com
is a NIP-05 identifier,example.com
is NIP-05 identifier_@example.com
,sub.example.com
is NIP-05 identifier_@sub.example.com
,
such that
let username = 'petname' || 'petname@example.com' || 'example.com' || 'sub.example.com'
b) Password
password
is an optional string
value used to salt the key derivation function (HKDF),
let password = "horse staple battery"
c) Chain-agnostic Identifiers
Chain-agnostic CAIP-02: Blockchain ID Specification and CAIP-10: Account ID Specification schemes are used to generate blockchain and address identifiers caip02
and caip10
respectively,
let caip02 =
`eip155:<evm_chain_id>` ||
`cosmos:<hub_id_name>` ||
`bip122:<16 bytes genesis/fork hash>`;
let caip10 = `${caip02}:<checksum_address>`;
d) Info
info
is CAIP-10 and NIP-02/NIP-05 identifier string formatted as:
let info = `${caip10}:${username}`;
e) Message
Deterministic message
to be signed by the wallet provider,
let message = `Log into Nostr client as '${username}'\n\nIMPORTANT: Please verify the integrity and authenticity of connected Nostr client before signing this message\n\nSIGNED BY: ${caip10}`
f) Signature
RFC-6979 compatible (ECDSA) deterministic signature
calculated by the wallet provider using native keypair,
let signature = wallet.signMessage(message);
g) Salt
salt
is SHA-256 hash of the info
, optional password and last 32 bytes of signature string formatted as:
let salt = await sha256(`${info}:${password?password:""}:${signature.slice(68)}`);
where, signature.slice(68)
are the last 32 bytes of the deterministic ECDSA-derived Ethereum signature.
h) Key Derivation Function (KDF)
HMAC-Based KDF hkdf(sha256, inputKey, salt, info, dkLen = 42)
is used to derive the 42 bytes long hashkey with inputs,
-
inputKey
is SHA-256 hash of signature bytes,let inputKey = await sha256(hexToBytes(signature.slice(2)));
-
info
is same as defined before, i.e.let info = `${caip10}:${username}`;
-
salt
is same as defined before, i.e.let salt = await sha256(`${info}:${password?password:""}:${signature.slice(68)}`);
-
dkLen
(Derived Key Length) is set to42
,let dkLen = 42;
FIPS 186-4 B.4.1 requires hashkey length to be
>= n + 8
, wheren = 32
is the bytelength of the finalsecp256k1
private key, such that42 >= 32 + 8
. -
hashToPrivateKey()
function is FIPS 186-4 B.4.1 implementation to convert HKDF-derived hashkey to validsecp256k1
keypair. This function is implemented in JavaScript library@noble/secp256k1
ashashToPrivateKey()
.let hashKey = hkdf(sha256, inputKey, salt, info, dkLen = 42); let privKey = secp256k1.utils.hashToPrivateKey(hashKey); let pubKey = secp256k1.schnorr.getPublicKey(privKey);
Architecture
The resulting architecture of NIP-111 can be visually interpreted as follows:
Implementation Requirements
- Connected Ethereum wallet Signer MUST be EIP-191 and RFC-6979 compatible.
- The
message
MUST be string formatted as
`Log into Nostr client as '${username}'\n\nIMPORTANT: Please verify the integrity and authenticity of connected Nostr client before signing this message\n\nSIGNED BY: ${caip10}`
- HKDF
inputKey
MUST be generated as the SHA-256 hash of 65 bytes long signature. - HKDF
salt
MUST be generated as SHA-256 hash of string
${info}:${password?password:""}:${signature.slice(68)}
- HKDF Derived Key Length (
dkLen
) MUST be 42. - HKDF
info
MUST be string formatted as
${caip10}:${username}
JS Example
import * as secp256k1 from '@noble/secp256k1'
import {hkdf} from '@noble/hashes/hkdf'
import {sha256} from '@noble/hashes/sha256'
import {queryProfile} from './nip05'
import {getPublicKey} from './keys'
import {ProfilePointer} from './nip19'
// const wallet = connected ethereum wallet with ethers.js
let username = "me@example.com"
let chainId = wallet.getChainId(); // get ChainID from connected wallet
let address = wallet.getAddress(); // get Address from wallet
let caip10 = `eip155:${chainId}:${address}`;
let message = `Log into Nostr client as '${username}'\n\nIMPORTANT: Please verify the integrity and authenticity of connected Nostr client before signing this message\n\nSIGNED BY: ${caip10}`
let signature = wallet.signMessage(message); // request Signature from wallet
let password = "horse staple battery"
/**
*
* @param username NIP-02/NIP-05 identifier
* @param caip10 CAIP identifier for the blockchain account
* @param sig Deterministic signature from X-wallet provider
* @param password Optional password
* @returns Deterministic private key as hex string
*/
export async function privateKeyFromX(
username: string,
caip10: string,
sig: string,
password: string | undefined
): Promise < string > {
if (sig.length < 64)
throw new Error("Signature too short");
let inputKey = await sha256(secp256k1.utils.hexToBytes(sig.toLowerCase().startsWith("0x") ? sig.slice(2) : sig))
let info = `${caip10}:${username}`
let salt = await sha256(`${info}:${password?password:""}:${sig.slice(-64)}`)
let hashKey = await hkdf(sha256, inputKey, salt, info, 42)
return secp256k1.utils.bytesToHex(secp256k1.utils.hashToPrivateKey(hashKey))
}
/**
*
* @param username NIP-02/NIP-05 identifier
* @param caip10 CAIP identifier for the blockchain account
* @param sig Deterministic signature from X-wallet provider
* @param password Optional password
* @returns
*/
export async function signInWithX(
username: string,
caip10: string,
sig: string,
password: string | undefined
): Promise < {
petname: string,
profile: ProfilePointer | null,
privkey: string
} > {
let profile = null
let petname = username
if (username.includes(".")) {
try {
profile = await queryProfile(username)
} catch (e) {
console.log(e)
throw new Error("Nostr Profile Not Found")
}
if(profile == null){
throw new Error("Nostr Profile Not Found")
}
petname = (username.split("@").length == 2) ? username.split("@")[0] : username.split(".")[0]
}
let privkey = await privateKeyFromX(username, caip10, sig, password)
let pubkey = getPublicKey(privkey)
if (profile?.pubkey && pubkey !== profile.pubkey) {
throw new Error("Invalid Signature/Password")
}
return {
petname,
profile,
privkey
}
}
Implementations
- Nostr Tools : Sign-In-With-X (Pull Request #132)
- Nostr Client: Dostr
Security Considerations
- Users SHOULD always verify the integrity and authenticity of the Nostr client before signing the message.
- Users SHOULD ensure that they only input their Nostr
username
andpassword
in trusted and secure clients.
References:
- RFC-6979: Deterministic Usage of the DSA and ECDSA
- RFC-5869: HKDF (HMAC-based Extract-and-Expand Key Derivation Function)
- CAIP-02: Blockchain ID Specification
- CAIP-10: Account ID Specification
- CAIP-122: Sign-in-With-X)
- Digital Signature Standard (DSS), FIPS 186-4 B.4.1
- BIP-340: Schnorr Signature Standard
- ERC-191: Signed Data Standard
- EIP-155: Simple Replay Attack Protection
- NIP-02: Contact List and Petnames
- NIP-05: Mapping Nostr Keys to DNS-based Internet Identifiers
- ECDSA Signature Standard
- @noble/hashes
- @noble/secp256k1