diff --git a/.eslintrc.json b/.eslintrc.json index 32a0311..1c060b4 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -22,6 +22,7 @@ "globals": { "document": false, + "BigInt": false, "navigator": false, "window": false, "crypto": false, diff --git a/event.ts b/event.ts index 0ba5498..c2a7057 100644 --- a/event.ts +++ b/event.ts @@ -13,6 +13,7 @@ export enum Kind { EncryptedDirectMessage = 4, EventDeletion = 5, Reaction = 7, + StatelessRevocation = 13, ChannelCreation = 40, ChannelMetadata = 41, ChannelMessage = 42, diff --git a/index.ts b/index.ts index 0295f02..ffe5b69 100644 --- a/index.ts +++ b/index.ts @@ -12,6 +12,7 @@ export * as nip10 from './nip10' export * as nip19 from './nip19' export * as nip26 from './nip26' export * as nip39 from './nip39' +export * as nip41 from './nip41' export * as nip57 from './nip57' export * as fj from './fakejson' diff --git a/nip41.test.js b/nip41.test.js new file mode 100644 index 0000000..803a8b8 --- /dev/null +++ b/nip41.test.js @@ -0,0 +1,154 @@ +/* eslint-env jest */ + +const secp256k1 = require('@noble/secp256k1') +const { + getPublicKey, + validateEvent, + verifySignature, + generatePrivateKey, + nip41 +} = require('./lib/nostr.cjs') + +test('sanity', () => { + let sk = generatePrivateKey() + + expect(getPublicKey(sk)).toEqual(secp256k1.Point.fromPrivateKey(sk).toHexX()) +}) + +test('key arithmetics', () => { + expect( + secp256k1.utils.mod(secp256k1.CURVE.n + 1n, secp256k1.CURVE.n) + ).toEqual(1n) + + let veryHighPoint = secp256k1.Point.fromPrivateKey( + (secp256k1.CURVE.n - 1n).toString(16).padStart(64, '0') + ) + let pointAt2 = secp256k1.Point.fromPrivateKey( + 2n.toString(16).padStart(64, '0') + ) + let pointAt1 = secp256k1.Point.fromPrivateKey( + 1n.toString(16).padStart(64, '0') + ) + expect(veryHighPoint.add(pointAt2)).toEqual(pointAt1) + + expect( + secp256k1.getPublicKey(1n.toString(16).padStart(64, '0'), true) + ).toEqual(pointAt1.toRawBytes(true)) +}) + +test('testing getting child keys compatibility', () => { + let sk = '2222222222222222222222222222222222222222222222222222222222222222' + let pk = secp256k1.getPublicKey(sk, true) + let hsk = '3333333333333333333333333333333333333333333333333333333333333333' + let hpk = secp256k1.getPublicKey(hsk, true) + + expect(secp256k1.utils.bytesToHex(nip41.getChildPublicKey(pk, hpk))).toEqual( + secp256k1.utils.bytesToHex( + secp256k1.getPublicKey(nip41.getChildPrivateKey(sk, hsk), true) + ) + ) +}) + +test('more testing child key derivation', () => { + ;[ + { + sk: '448aedc74f93b71af69ed7c6860d95f148d796355517779c7631fdb64a085b26', + hsk: '00ee15a0a117e818073b92d7f3360029f6e091035534348f713a23d440bd8f58', + pk: '02e3990b0eb40452a8ffbd9fe99037deb7beeb6ab26020e8c0e8284f3009a56d0c', + hpk: '029e9cb07f3a3b8abcad629920d4a5460aefb6b7c08704b7f1ced8648b007ef65f' + }, + { + sk: '778aedc74f93b71af69ed7c6860d95f148d796355517779c7631fdb64a085b26', + hsk: '99ee15a0a117e818073b92d7f3360029f6e091035534348f713a23d440bd8f58', + pk: '020d09894e321f53a7ac8bc003cb1563a4857d57ea69c39ab7189e2cccedc17d1b', + hpk: '0358fe19e14c78c4a8c0037a2b9d3e3a714717f2a2d8dd54a5e88d283440dcb28a' + }, + { + sk: '2eb5edc74f93b71af69ed7c6860d95f148d796355517779c7631fdb64a085b26', + hsk: '65d515a0a117e818073b92d7f3360029f6e091035534348f713a23d440bd8f58', + pk: '03dd651a07dc6c9a54b596f6492c9623a595cb48e31af04f8c322d4ce81accb2b0', + hpk: '03b8c98d920141a1e168d21e9315cf933a601872ebf57751b30797fb98526c2f4f' + } + ].forEach(({pk, hpk, sk, hsk}) => { + expect( + secp256k1.utils.bytesToHex(secp256k1.getPublicKey(sk, true)) + ).toEqual(pk) + expect( + secp256k1.utils.bytesToHex(secp256k1.getPublicKey(hsk, true)) + ).toEqual(hpk) + + expect( + secp256k1.utils.bytesToHex( + nip41.getChildPublicKey( + secp256k1.utils.hexToBytes(pk), + secp256k1.utils.hexToBytes(hpk) + ) + ) + ).toEqual( + secp256k1.utils.bytesToHex( + secp256k1.getPublicKey(nip41.getChildPrivateKey(sk, hsk), true) + ) + ) + }) +}) + +test('generating a revocation event and validating it', () => { + const mnemonic = + 'air property excess weird rare rival fade intact brave office mirror wait' + + const firstKey = nip41.getPrivateKeyAtIndex(mnemonic, 9) + // expect(firstKey).toEqual( + // '8495ba55f56485d378aa275604a45e76abbcae177e374fa06af5770c3b8e24af' + // ) + const firstPubkey = getPublicKey(firstKey) + // expect(firstPubkey).toEqual( + // '35246813a0dd45e74ce22ecdf052cca8ed47759c8f8d412c281dc2755110956f' + // ) + + // first key is compromised, revoke it + let {parentPrivateKey, event} = nip41.buildRevocationEvent( + mnemonic, + firstPubkey + ) + + const secondKey = nip41.getPrivateKeyAtIndex(mnemonic, 8) + expect(parentPrivateKey).toEqual(secondKey) + expect(secondKey).toEqual( + '1b311655ef73bed3bbebc83d0cb3eef42c6aff45f944e3a0c263eb6fdf98c617' + ) + + expect(event).toHaveProperty('kind', 13) + expect(event.tags).toHaveLength(2) + expect(event.tags[0]).toHaveLength(2) + expect(event.tags[1]).toHaveLength(2) + expect(event.tags[0][0]).toEqual('p') + expect(event.tags[1][0]).toEqual('hidden-key') + + let hiddenKey = secp256k1.utils.hexToBytes(event.tags[1][1]) + + let pubkeyAlt1 = secp256k1.utils + .bytesToHex( + nip41.getChildPublicKey( + secp256k1.utils.hexToBytes('02' + event.pubkey), + hiddenKey + ) + ) + .slice(2) + let pubkeyAlt2 = secp256k1.utils + .bytesToHex( + nip41.getChildPublicKey( + secp256k1.utils.hexToBytes('03' + event.pubkey), + hiddenKey + ) + ) + .slice(2) + + expect([pubkeyAlt1, pubkeyAlt2]).toContain(event.tags[0][1]) + + // receiver of revocation event can validate it + let secondPubkey = getPublicKey(secondKey) + expect(event.pubkey).toEqual(secondPubkey) + expect(validateEvent(event)).toBeTruthy() + expect(verifySignature(event)).toBeTruthy() + expect(nip41.validateRevocation(event)).toBeTruthy() +}) diff --git a/nip41.ts b/nip41.ts new file mode 100644 index 0000000..87754ce --- /dev/null +++ b/nip41.ts @@ -0,0 +1,160 @@ +import * as secp256k1 from '@noble/secp256k1' +import {sha256} from '@noble/hashes/sha256' +import {mnemonicToSeedSync} from '@scure/bip39' +import {HARDENED_OFFSET, HDKey} from '@scure/bip32' + +import {getPublicKey} from './keys' +import {Event, getEventHash, Kind, signEvent, verifySignature} from './event' + +const MaxKeys = 11 + +function getRootFromMnemonic(mnemonic: string): HDKey { + return HDKey.fromMasterSeed(mnemonicToSeedSync(mnemonic)).derive( + `m/44'/1237'/41'` + ) +} + +export function getPrivateKeyAtIndex( + mnemonic: string, + targetIdx: number +): string { + let root = getRootFromMnemonic(mnemonic) + let rootPrivateKey = secp256k1.utils.bytesToHex(root.privateKey as Uint8Array) + let currentPrivateKey = rootPrivateKey + + for (let idx = 1; idx <= targetIdx; idx++) { + let hiddenPrivateKey = secp256k1.utils.bytesToHex( + root.deriveChild(idx + HARDENED_OFFSET).privateKey as Uint8Array + ) + currentPrivateKey = getChildPrivateKey(currentPrivateKey, hiddenPrivateKey) + } + + return currentPrivateKey +} + +export function getPublicKeyAtIndex( + root: HDKey, + targetIdx: number +): Uint8Array { + let rootPublicKey = root.publicKey as Uint8Array + + let currentPublicKey = rootPublicKey + for (let idx = 1; idx <= targetIdx; idx++) { + let hiddenPublicKey = root.deriveChild(idx + HARDENED_OFFSET) + .publicKey as Uint8Array + currentPublicKey = getChildPublicKey(currentPublicKey, hiddenPublicKey) + } + + return currentPublicKey +} + +function getIndexOfPublicKey(root: HDKey, publicKey: string): number { + let rootPublicKey = root.publicKey as Uint8Array + if (secp256k1.utils.bytesToHex(rootPublicKey).slice(2) === publicKey) return 0 + + let currentPublicKey = rootPublicKey + for (let idx = 1; idx <= MaxKeys; idx++) { + let hiddenPublicKey = root.deriveChild(idx + HARDENED_OFFSET) + .publicKey as Uint8Array + let pubkeyAtIndex = getChildPublicKey(currentPublicKey, hiddenPublicKey) + if (secp256k1.utils.bytesToHex(pubkeyAtIndex).slice(2) === publicKey) + return idx + + currentPublicKey = pubkeyAtIndex + } + + throw new Error( + `public key ${publicKey} not in the set of the first ${MaxKeys} public keys` + ) +} + +export function getChildPublicKey( + parentPublicKey: Uint8Array, + hiddenPublicKey: Uint8Array +): Uint8Array { + if (parentPublicKey.length !== 33 || hiddenPublicKey.length !== 33) + throw new Error( + 'getChildPublicKey() requires public keys with the leading differentiator byte.' + ) + + let hash = sha256( + secp256k1.utils.concatBytes(hiddenPublicKey, parentPublicKey) + ) + let hashPoint = secp256k1.Point.fromPrivateKey(hash) + let point = secp256k1.Point.fromHex(hiddenPublicKey).add(hashPoint) + return point.toRawBytes(true) +} + +export function getChildPrivateKey( + parentPrivateKey: string, + hiddenPrivateKey: string +): string { + let parentPublicKey = secp256k1.getPublicKey(parentPrivateKey, true) + let hiddenPublicKey = secp256k1.getPublicKey(hiddenPrivateKey, true) + let hash = sha256( + secp256k1.utils.concatBytes(hiddenPublicKey, parentPublicKey) + ) + let hashScalar = BigInt(`0x${secp256k1.utils.bytesToHex(hash)}`) + let hiddenPrivateKeyScalar = BigInt(`0x${hiddenPrivateKey}`) + let sumScalar = hiddenPrivateKeyScalar + hashScalar + let modulo = secp256k1.utils.mod(sumScalar, secp256k1.CURVE.n) + return modulo.toString(16).padStart(64, '0') +} + +export function buildRevocationEvent( + mnemonic: string, + compromisedKey: string, + content = '' +): { + parentPrivateKey: string + event: Event +} { + let root = getRootFromMnemonic(mnemonic) + let idx = getIndexOfPublicKey(root, compromisedKey) + let hiddenKey = secp256k1.utils.bytesToHex( + root.deriveChild(idx + HARDENED_OFFSET).publicKey as Uint8Array + ) + let parentPrivateKey = getPrivateKeyAtIndex(mnemonic, idx - 1) + let parentPublicKey = getPublicKey(parentPrivateKey) + + let event: Event = { + kind: 13, + tags: [ + ['p', compromisedKey], + ['hidden-key', hiddenKey] + ], + created_at: Math.round(Date.now() / 1000), + content, + pubkey: parentPublicKey + } + + event.sig = signEvent(event, parentPrivateKey) + event.id = getEventHash(event) + + return {parentPrivateKey, event} +} + +export function validateRevocation(event: Event): boolean { + if (event.kind !== Kind.StatelessRevocation) return false + if (!verifySignature(event)) return false + + let invalidKeyTag = event.tags.find(([t, v]) => t === 'p' && v) + if (!invalidKeyTag) return false + let invalidKey = invalidKeyTag[1] + + let hiddenKeyTag = event.tags.find(([t, v]) => t === 'hidden-key' && v) + if (!hiddenKeyTag) return false + let hiddenKey = secp256k1.utils.hexToBytes(hiddenKeyTag[1]) + if (hiddenKey.length !== 33) return false + + let currentKeyAlt1 = secp256k1.utils.hexToBytes('02' + event.pubkey) + let currentKeyAlt2 = secp256k1.utils.hexToBytes('03' + event.pubkey) + let childKeyAlt1 = secp256k1.utils + .bytesToHex(getChildPublicKey(currentKeyAlt1, hiddenKey)) + .slice(2) + let childKeyAlt2 = secp256k1.utils + .bytesToHex(getChildPublicKey(currentKeyAlt2, hiddenKey)) + .slice(2) + + return childKeyAlt1 === invalidKey || childKeyAlt2 === invalidKey +}