diff --git a/README.md b/README.md index e45394b..202e8b6 100644 --- a/README.md +++ b/README.md @@ -202,6 +202,41 @@ sub.on('event', (event) => { }) ``` +### Performing and checking for delegation + +```js +import {nip26, getPublicKey, generatePrivateKey} from 'nostr-tools' + +// delegator +let sk1 = generatePrivateKey() +let pk1 = getPublicKey(sk1) + +// delegatee +let sk2 = generatePrivateKey() +let pk2 = getPublicKey(sk2) + +// generate delegation +let delegation = nip26.createDelegation(sk1, { + pubkey: pk2, + kind: 1, + since: Math.round(Date.now() / 1000), + until: Math.round(Date.now() / 1000) + 60 * 60 * 24 * 30 /* 30 days */ +}) + +// the delegatee uses the delegation when building an event +let event = { + pubkey: pk2, + kind: 1, + created_at: Math.round(Date.now() / 1000), + content: 'hello from a delegated key', + tags: [['delegation', delegation.from, delegation.cond, delegation.sig]] +} + +// finally any receiver of this event can check for the presence of a valid delegation tag +let delegator = nip26.getDelegator(event) +assert(delegator === pk1) // will be null if there is no delegation tag or if it is invalid +``` + Please consult the tests or [the source code](https://github.com/fiatjaf/nostr-tools) for more information that isn't available here. ### Using from the browser (if you don't want to use a bundler) diff --git a/index.ts b/index.ts index aef3274..fe0f850 100644 --- a/index.ts +++ b/index.ts @@ -7,3 +7,13 @@ export * as nip04 from './nip04' export * as nip05 from './nip05' export * as nip06 from './nip06' export * as nip19 from './nip19' +export * as nip26 from './nip26' + +// monkey patch secp256k1 +import * as secp256k1 from '@noble/secp256k1' +import {hmac} from '@noble/hashes/hmac' +import {sha256} from '@noble/hashes/sha256' +secp256k1.utils.hmacSha256Sync = (key, ...msgs) => + hmac(sha256, key, secp256k1.utils.concatBytes(...msgs)) +secp256k1.utils.sha256Sync = (...msgs) => + sha256(secp256k1.utils.concatBytes(...msgs)) diff --git a/nip26.test.js b/nip26.test.js new file mode 100644 index 0000000..8b50aaf --- /dev/null +++ b/nip26.test.js @@ -0,0 +1,105 @@ +/* eslint-env jest */ + +const {nip26, getPublicKey, generatePrivateKey} = require('./lib/nostr.cjs') + +test('parse good delegation from NIP', async () => { + expect( + nip26.getDelegator({ + id: 'a080fd288b60ac2225ff2e2d815291bd730911e583e177302cc949a15dc2b2dc', + pubkey: + '62903b1ff41559daf9ee98ef1ae67cc52f301bb5ce26d14baba3052f649c3f49', + created_at: 1660896109, + kind: 1, + tags: [ + [ + 'delegation', + '86f0689bd48dcd19c67a19d994f938ee34f251d8c39976290955ff585f2db42e', + 'kind=1&created_at>1640995200', + 'c33c88ba78ec3c760e49db591ac5f7b129e3887c8af7729795e85a0588007e5ac89b46549232d8f918eefd73e726cb450135314bfda419c030d0b6affe401ec1' + ] + ], + content: 'Hello world', + sig: 'cd4a3cd20dc61dcbc98324de561a07fd23b3d9702115920c0814b5fb822cc5b7c5bcdaf3fa326d24ed50c5b9c8214d66c75bae34e3a84c25e4d122afccb66eb6' + }) + ).toEqual('86f0689bd48dcd19c67a19d994f938ee34f251d8c39976290955ff585f2db42e') +}) + +test('parse bad delegations', async () => { + expect( + nip26.getDelegator({ + id: 'a080fd288b60ac2225ff2e2d815291bd730911e583e177302cc949a15dc2b2dc', + pubkey: + '62903b1ff41559daf9ee98ef1ae67cc52f301bb5ce26d14baba3052f649c3f49', + created_at: 1660896109, + kind: 1, + tags: [ + [ + 'delegation', + '86f0689bd48dcd19c67a19d994f938ee34f251d8c39976290955ff585f2db42f', + 'kind=1&created_at>1640995200', + 'c33c88ba78ec3c760e49db591ac5f7b129e3887c8af7729795e85a0588007e5ac89b46549232d8f918eefd73e726cb450135314bfda419c030d0b6affe401ec1' + ] + ], + content: 'Hello world', + sig: 'cd4a3cd20dc61dcbc98324de561a07fd23b3d9702115920c0814b5fb822cc5b7c5bcdaf3fa326d24ed50c5b9c8214d66c75bae34e3a84c25e4d122afccb66eb6' + }) + ).toEqual(null) + + expect( + nip26.getDelegator({ + id: 'a080fd288b60ac2225ff2e2d815291bd730911e583e177302cc949a15dc2b2dc', + pubkey: + '62903b1ff41559daf9ee98ef1ae67cc52f301bb5ce26d14baba3052f649c3f49', + created_at: 1660896109, + kind: 1, + tags: [ + [ + 'delegation', + '86f0689bd48dcd19c67a19d994f938ee34f251d8c39976290955ff585f2db42e', + 'kind=1&created_at>1740995200', + 'c33c88ba78ec3c760e49db591ac5f7b129e3887c8af7729795e85a0588007e5ac89b46549232d8f918eefd73e726cb450135314bfda419c030d0b6affe401ec1' + ] + ], + content: 'Hello world', + sig: 'cd4a3cd20dc61dcbc98324de561a07fd23b3d9702115920c0814b5fb822cc5b7c5bcdaf3fa326d24ed50c5b9c8214d66c75bae34e3a84c25e4d122afccb66eb6' + }) + ).toEqual(null) + + expect( + nip26.getDelegator({ + id: 'a080fd288b60ac2225ff2e2d815291bd730911e583e177302cc949a15dc2b2dc', + pubkey: + '62903b1ff41559daf9ee98ef1ae67c152f301bb5ce26d14baba3052f649c3f49', + created_at: 1660896109, + kind: 1, + tags: [ + [ + 'delegation', + '86f0689bd48dcd19c67a19d994f938ee34f251d8c39976290955ff585f2db42e', + 'kind=1&created_at>1640995200', + 'c33c88ba78ec3c760e49db591ac5f7b129e3887c8af7729795e85a0588007e5ac89b46549232d8f918eefd73e726cb450135314bfda419c030d0b6affe401ec1' + ] + ], + content: 'Hello world', + sig: 'cd4a3cd20dc61dcbc98324de561a07fd23b3d9702115920c0814b5fb822cc5b7c5bcdaf3fa326d24ed50c5b9c8214d66c75bae34e3a84c25e4d122afccb66eb6' + }) + ).toEqual(null) +}) + +test('create and verify delegation', async () => { + let sk1 = generatePrivateKey() + let pk1 = getPublicKey(sk1) + let sk2 = generatePrivateKey() + let pk2 = getPublicKey(sk2) + let delegation = nip26.createDelegation(sk1, {pubkey: pk2, kind: 1}) + expect(delegation).toHaveProperty('from', pk1) + expect(delegation).toHaveProperty('to', pk2) + expect(delegation).toHaveProperty('cond', 'kind=1') + + let event = { + kind: 1, + tags: [['delegation', delegation.from, delegation.cond, delegation.sig]], + pubkey: pk2 + } + expect(nip26.getDelegator(event)).toEqual(pk1) +}) diff --git a/nip26.ts b/nip26.ts new file mode 100644 index 0000000..150135a --- /dev/null +++ b/nip26.ts @@ -0,0 +1,90 @@ +import * as secp256k1 from '@noble/secp256k1' +import {sha256} from '@noble/hashes/sha256' + +import {Event} from './event' +import {utf8Encoder} from './utils' +import {getPublicKey} from './keys' + +export type Parameters = { + pubkey: string // the key to whom the delegation will be given + kind: number | undefined + until: number | undefined // delegation will only be valid until this date + since: number | undefined // delegation will be valid from this date on +} + +export type Delegation = { + from: string // the pubkey who signed the delegation + to: string // the pubkey that is allowed to use the delegation + cond: string // the string of conditions as they should be included in the event tag + sig: string +} + +export function createDelegation( + privateKey: string, + parameters: Parameters +): Delegation { + let conditions = [] + if ((parameters.kind || -1) >= 0) conditions.push(`kind=${parameters.kind}`) + if (parameters.until) conditions.push(`created_at<${parameters.until}`) + if (parameters.since) conditions.push(`created_at>${parameters.since}`) + let cond = conditions.join('&') + + if (cond === '') + throw new Error('refusing to create a delegation without any conditions') + + let sighash = sha256( + utf8Encoder.encode(`nostr:delegation:${parameters.pubkey}:${cond}`) + ) + + let sig = secp256k1.utils.bytesToHex( + secp256k1.schnorr.signSync(sighash, privateKey) + ) + + return { + from: getPublicKey(privateKey), + to: parameters.pubkey, + cond, + sig + } +} + +export function getDelegator(event: Event): string | null { + // find delegation tag + let tag = event.tags.find(tag => tag[0] === 'delegation' && tag.length >= 4) + if (!tag) return null + + let pubkey = tag[1] + let cond = tag[2] + let sig = tag[3] + + // check conditions + let conditions = cond.split('&') + for (let i = 0; i < conditions.length; i++) { + let [key, operator, value] = conditions[i].split(/\b/) + + // the supported conditions are just 'kind' and 'created_at' for now + if (key === 'kind' && operator === '=' && event.kind === parseInt(value)) + continue + else if ( + key === 'created_at' && + operator === '<' && + event.created_at < parseInt(value) + ) + continue + else if ( + key === 'created_at' && + operator === '>' && + event.created_at > parseInt(value) + ) + continue + else return null // invalid condition + } + + // check signature + let sighash = sha256( + utf8Encoder.encode(`nostr:delegation:${event.pubkey}:${cond}`) + ) + if (!secp256k1.schnorr.verifySync(sig, sighash, pubkey)) return null + + return pubkey +}