-
Notifications
You must be signed in to change notification settings - Fork 602
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
NIP-111: Nostr-specific Private Keys from Deterministic Wallet Signatures (Sign-in-With-X) #268
Open
0xc0de4c0ffee
wants to merge
16
commits into
nostr-protocol:master
Choose a base branch
from
dostr-eth:ethkeygen
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
16 commits
Select commit
Hold shift + click to select a range
93f96f0
WIP:Draft
901135a
Add reference
0xc0de4c0ffee 7d80aec
Merge branch 'nostr-protocol:master' into ethkeygen
0xc0de4c0ffee 299c8b9
update:draft
0xc0de4c0ffee b2a2711
minor edits to NIP-XX
sshmatrix afa93ff
minor stuff
sshmatrix cbcc8de
some corrections
sshmatrix 4b5ca2e
some corrections
sshmatrix ad4f6d8
abstract correction
sshmatrix 16f134c
update identifiers to caip10
0xc0de4c0ffee f4d9db5
housekeeping
sshmatrix a343d17
NIP-XX major update
sshmatrix 225d5b0
NIP-XX minor typo fixes
sshmatrix 3c57cd5
update to NIP-111; finalised message to sign
sshmatrix e17cbbe
typos fix
sshmatrix c7a943d
Update 111.md
sshmatrix File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,242 @@ | ||
# 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](https://github.com/bitcoin/bips/blob/master/bip-0340.mediawiki)) can be used to derive a deterministic cryptographic keypair for another signing algorithm (e.g. [Ethereum-native ECDSA algorithm](https://eips.ethereum.org/EIPS/eip-191)) 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](https://datatracker.ietf.org/doc/html/rfc586)). | ||
|
||
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](https://login.xyz) (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](https://github.com/ChainAgnostic/CAIPs) (Chain-Agnostic Improvement Proposals) implementations. | ||
|
||
## Terminology | ||
|
||
### a) Username | ||
`username` is either of the following: | ||
|
||
- `petname` is a [NIP-02](https://github.com/nostr-protocol/nips/blob/master/02.md) compatible name, | ||
- `petname@example.com` is a [NIP-05](https://github.com/nostr-protocol/nips/blob/master/05.md) identifier, | ||
- `example.com` is NIP-05 identifier `_@example.com`, | ||
- `sub.example.com` is NIP-05 identifier `_@sub.example.com`, | ||
|
||
such that | ||
|
||
```js | ||
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), | ||
```js | ||
let password = "horse staple battery" | ||
``` | ||
|
||
## c) Chain-agnostic Identifiers | ||
Chain-agnostic [CAIP-02: Blockchain ID Specification](https://github.com/ChainAgnostic/CAIPs/blob/master/CAIPs/caip-2.md) and [CAIP-10: Account ID Specification](https://github.com/ChainAgnostic/CAIPs/blob/master/CAIPs/caip-10.md) schemes are used to generate blockchain and address identifiers `caip02` and `caip10` respectively, | ||
```js | ||
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: | ||
```js | ||
let info = `${caip10}:${username}`; | ||
``` | ||
|
||
### e) Message | ||
Deterministic `message` to be signed by the wallet provider, | ||
```js | ||
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](https://datatracker.ietf.org/doc/html/rfc6979) compatible (ECDSA) deterministic `signature` calculated by the wallet provider using native keypair, | ||
```js | ||
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: | ||
```js | ||
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, | ||
```js | ||
let inputKey = await sha256(hexToBytes(signature.slice(2))); | ||
``` | ||
|
||
- `info` is same as defined before, i.e. | ||
```js | ||
let info = `${caip10}:${username}`; | ||
``` | ||
|
||
- `salt` is same as defined before, i.e. | ||
```js | ||
let salt = await sha256(`${info}:${password?password:""}:${signature.slice(68)}`); | ||
``` | ||
|
||
- `dkLen` (Derived Key Length) is set to `42`, | ||
```js | ||
let dkLen = 42; | ||
``` | ||
[FIPS 186-4 B.4.1](https://csrc.nist.gov/publications/detail/fips/186/4/final) requires hashkey length to be `>= n + 8`, where `n = 32` is the **bytelength** of the final `secp256k1` private key, such that `42 >= 32 + 8`. | ||
|
||
- `hashToPrivateKey()` function is FIPS 186-4 B.4.1 implementation to convert HKDF-derived hashkey to valid `secp256k1` keypair. This function is implemented in JavaScript library `@noble/secp256k1` as `hashToPrivateKey()`. | ||
|
||
```js | ||
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: | ||
|
||
![](https://raw.githubusercontent.com/dostr-eth/resources/main/graphics/nip-111.png) | ||
|
||
## 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 | ||
```js | ||
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 | ||
1) Nostr Tools : [Sign-In-With-X](https://github.com/dostr-eth/nostr-tools/tree/siwx) ([Pull Request #132](https://github.com/nbd-wtf/nostr-tools/pull/132)) | ||
2) Nostr Client: [Dostr](https://github.com/dostr-eth/dostr-client) | ||
|
||
|
||
## 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` and `password` in trusted and secure clients. | ||
|
||
## References: | ||
|
||
- [RFC-6979: Deterministic Usage of the DSA and ECDSA](https://datatracker.ietf.org/doc/html/rfc6979) | ||
- [RFC-5869: HKDF (HMAC-based Extract-and-Expand Key Derivation Function)](https://datatracker.ietf.org/doc/html/rfc5869) | ||
- [CAIP-02: Blockchain ID Specification](https://github.com/ChainAgnostic/CAIPs/blob/master/CAIPs/caip-2.md) | ||
- [CAIP-10: Account ID Specification](https://github.com/ChainAgnostic/CAIPs/blob/master/CAIPs/caip-10.md) | ||
- [CAIP-122: Sign-in-With-X)](https://github.com/ChainAgnostic/CAIPs/pull/122) | ||
- [Digital Signature Standard (DSS), FIPS 186-4 B.4.1](https://csrc.nist.gov/publications/detail/fips/186/4/final) | ||
- [BIP-340: Schnorr Signature Standard](https://github.com/bitcoin/bips/blob/master/bip-0340.mediawiki) | ||
- [ERC-191: Signed Data Standard](https://eips.ethereum.org/EIPS/eip-191) | ||
- [EIP-155: Simple Replay Attack Protection](https://eips.ethereum.org/EIPS/eip-155) | ||
- [NIP-02: Contact List and Petnames](https://github.com/nostr-protocol/nips/blob/master/02.md) | ||
- [NIP-05: Mapping Nostr Keys to DNS-based Internet Identifiers](https://github.com/nostr-protocol/nips/blob/master/05.md) | ||
- [ECDSA Signature Standard](https://citeseerx.ist.psu.edu/viewdoc/summary?doi=10.1.1.38.8014) | ||
- [@noble/hashes](https://github.com/paulmillr/noble-hashes) | ||
- [@noble/secp256k1](https://github.com/paulmillr/noble-secp256k1) |
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm a little confused, is this "inspired by" Sign-In With Ethereum/CAIP-122 or is it meant to be conformant to one or both of those two specs? If the latter was intended, the message should probably conform the CAIP-122/EIP-4361 ABNF, which could include the entire
message
(with\n
s removed) in thestatement
field, use thesalt
value fornonce
, andinfo
as first entry in theresources
array (not really sure what to do withinputKey
, as I'm unclear on its exact function on a cursory read-through). If it's not too late to go in that direction, I think it might have some benefits, such as being displayed to metamask users in the familiar, locked-down "Sign-In With Ethereum" modal rather than as a generic "personal_sign" modal... which only displays when presented with a personal_signmessage
matching the ABNF :DThere was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
And apologies for not reading this sooner! Exciting work, in any case, supportive of the general direction and thankful to see CAIP-10 being used as the "export format" for addresses of signers 💪
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks @bumblefudge! We intended to conform to CAIP-122 as much as possible but we had to strip all the variable quantities from the
message
format to retain deterministic nature of key derivation. In some sense, this implementation is not really "Sign-In" but more of "Ephemeral KeyGen and then Sign-In", and hence the necessary deviation from the standard. Our implementation requires that the verifiable signature is static.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Well, the problem with an ABNF-based syntax is that you can't deviate and still get interop with other SIWX libraries or take advantage of SIWX support built into Metamask! Can I suggest hard-coding conformant dummy values into the spec and template rather than removing the key/value pairs that you don't need, so that the message can still conform to the ABNF and get displayed to the users as a SIWX message? I would note that ephemeral keygen is already baked into the SIWX standard, and is being used for that exact usecase by most implementers (the generated ephemeral public key is usually included as a value in the
Resources
array, although this isn't really mentioned explicitly in the specification itself!)There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@bumblefudge 🙏
Should we remove CAIP122 ref as we can't pass full strict ABNF? or req all as new CAIP?
We actually started as basic signature request before upgrading to full CAIP122/ERC4361 format but had to remove all extradata for deterministic keys as they are too strict to fit in all.. So it's now back to generic "personal_sign" modal for chain agnostic "sign-in-with-x" in Nostr context, internally it's using deterministic signature from wallet to generate "app specific deterministic keys" across all Nostr clients.
We could fill in all ABNF required formats with deterministic/fixed values but we can't pass
URI
validation in wallets, and "${service} wants you to sign in with <chain> account: \n<addr>\n...must:have
" text message for ALL web2+3 D/Apps is too strict for our deterministic keygen, web3+dapps are not supposed to have single URLs/apps as entry point.."Important: .....\n\n" is supposed to be "Warning! ..." statement, it's not key: value. All other extradata is wrapped in
SIGNED BY : ${CAIP10}
, & our msg is simple 3 blocks<title>\n\n<statement>\n\n<key:value>...
Can you add some ref codes/links for that?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
URI
is definitely a bigger issue and it hadn't come to my mind before. Whiletimestamp
andnonce
can be replaced with placeholders,URI
is used by Metamask for its community-audited safe dApp list. This is problematic for dApps or services without a unique entry point. CAIP122 is too strict for all use-cases and pretty much a death sentence for the deterministic use-case.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Couldn't the uri be:
https://github.com/nostr-protocol/nips/blob/master/111.md
? It doesn't need to be on the
domain
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That's not a bad idea honestly but it will make users highly suspicious and wary of a service with 1/1/1970 in timestamp and a GitHub link in URI. I personally won't sign and subscribe to such a service at first glance. I believe there is room for a new CAIP detailing a separate signature format standard for deterministic use-cases.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When we connect MM at
https://app.dostr.eth.limo
it'll check and add "WARNING" if connected domain isn't matchingdomain
URL. It's good feature for web2 & web3 apps with single point of entry but for our use case we're adding extra user info & pw with static signature request as basic security.It all works as plaintext "sign in with xyz on Nostr" signature request so we're missing all SWIx interface/features.
window.ethereum
to sign eth tx/permits then use deterministic keys inwindow.nostr
to send that over Nostr relays for off-chain features like stealth payments, DeFi, NFT markets, alt-mempool for AA, xyz services, bundlers/bots, games...There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
personally I think if we don't need timestamp, adding a placeholder for it is not a good idea.