defy.tools

defy.tools

defy.tools

Tools for developing Encrypted Nostr Clients. Depends on @scure & @noble packages.

Overview

  • Each message is encrypted with a unique key, signed with another unique key, and reveals no identifying information about the author or the intended recipient.
  • Keys are exchanged via a Notification event requiring the recipients private key to read the enclosed key.
  • Event content owner is verified through event BIP32 key derivation.

Installation

 npm install defy.tools # or yarn add defy.tools

Demo Client

For a demonstration of a secure messaging client using this library see https://gitlab.com/d5i/defy.demo .

Usage Examples

Creating a keyStore

Using web storage:

const keyStore = KeyStoreStorage.create(KeyStoreWebStorage);

// without a password the key will be saved in sessionStorage
// encrypted with a randomly generated key this will be
// available for 15 minutes.
await keyStore.save();

// with a password, the key will be saved in localStorage
// encrypted with a hash of the password
await keyStore.save("A Demo Password");

Using file storage:

// uses file a file located at $HOME/.defy/defy-backup to store the key
const keyStore = KeyStoreStorage.create(KeyStoreFileStorage);
await keyStore.save("A Demo Password");

Reloading a Saved KeyStore

// reload from session
const keyStoreA = await KeyStoreStorage.fromStorage(KeyStoreWebStorage);

// reload from local
const keyStoreB = await KeyStoreStorage.fromStorage(KeyStoreWebStorage, "A Demo Password");

// reload from file
const keyStoreC = await KeyStoreStorage.fromStorage(KeyStoreFileStorage, "A Demo Password");

Using a RelayPool

Use RelayPool after setting it with the setDefyContext() method.

const keyStore = await KeyStoreStorage.fromStorage(KeyStoreFileStorage);
const relayPool = new RelayPool(); // or any class that extends RelayPool
relayPool.addRelay('wss://my.relay.url', true, true, false);

// relay methods require a context. for now we are assuming
// there is only one context per process.
setDefyContext({ keyStore, relayPool });

keyStore.load().subscribe(keyStore => {
// do stuff with keyStore.channels / keyStore.contacts
})

All methods in the model with names like load, query, publish, listen and delete (and others) require a relayPool object set in the context.

Using a RelayPool as a Web Worker

Same as above, but the last parameter to addRelay is true and we have copied the defy.tools.relay-worker.js file to our public root directory.

const keyStore = await KeyStoreStorage.fromStorage(KeyStoreFileStorage);
const relayPool = new RelayPool(); // or any class that extends RelayPool
relayPool.addRelay('wss://my.relay.url', true, true, true);

setDefyContext({ keyStore, relayPool });

keyStore.load().subscribe(result => {
const { channels, contacts } = result;
// do stuff
})
Web Worker - Copy During Build Example

To use RelayPool as a Web Worker, make sure the defy.tools.relay-worker.js script is copied to your web project public root path. Here is a simple custom vite plugin example that can do that automatically. Then you can add the copied file to your .gitignore .

  plugins: [
...
{
name: 'copy-relay-worker',
buildStart: async () => {
const relayWorkerJs = 'defy.tools.relay-worker.js';
const srcJs = `${__dirname}/node_modules/defy.tools/dist/${relayWorkerJs}`;
const srcMap = `${__dirname}/node_modules/defy.tools/dist/${relayWorkerJs}.map`;
fs.copyFileSync(srcJs, `${__dirname}/public/${relayWorkerJs}`);
if (fs.existsSync(srcMap)) {
fs.copyFileSync(srcMap, `${__dirname}/public/${relayWorkerJs}.map`);
}
}
},
],

Creating New Private Channels

Due to the nature of sequential indexes, we should not create new documents on the root index until we know it is loaded with the existing documents. We will create a documents meta object to help make this faster later. But still we would need to wait for that too.

Example assumes RelayPool in context and the keyStore load() has been called and the documentsIndex is in the 'ready' state.

const { keyStore } = useDefyContext();
const info: PrivateChannelPayload = {
kind: NostrKinds.ChannelMetadata,
pubkey: keyStore.ownerPubKey,
name: 'New Channel',
created_at: getNowSeconds(),
contacts: {},
shares:{}
};
// assume we set the relayPool in the context earlier
keyStore.load().pipe(
mergeMap(() => PrivateChannel.create(info, true)),
).subscribe(channel => {
// do stuff with the channel
});

Creating a Text Note on a Private Channel

Example assumes RelayPool in context

const { keyStore } = useDefyContext();

// determine your the channel:
const channel = keyStore.channels[0];

channel.saveNote('Hello World').subscribe()

Creating a Text Reply or Reaction to Private Channel Note

Example assumes RelayPool in context

const note = channel.posts[0]; // find the note to reply / react to
// a reply
note.reply('Hi', 1).subscribe();
// a reaction
note.reply('👍', 7).subscribe();

Listen for Notes on a Channel

Example assumes RelayPool in context

const { keyStore } = useDefyContext();

const channel = keyStore.channels[0];
const [sub$, endSub] = channel.listen();

sub$.subscribe(note => {
// do stuff with the note
// consider also using other rxjs operators to buffer
// and/or otherwise control the flow of messages
});
// when finished, clean up with the endSub() to end both
// the rxjs subscription and the close the relay request
endSub();

Load the last 50 Notes on the Channel

Example assumes RelayPool in context

const { keyStore } = useDefyContext();

const channel = keyStore.channels[0];
const [sub$, endSub] = channel.dkxPost.query<Note>({
keepAlive: false,
limit: 50
});
sub$.pipe(
reduce((notes, note) => {
notes.push(note);
return notes;
}, [] as Note[]),
).subscribe(notes => {
// do stuff with notes
});

Initiate a DM

Example assumes RelayPool in context and the keyStore load() has been called and the documentsIndex is in the 'ready' state. v1.3.2 supports group conversations with an array of pubkeys.

const { keyStore } = useDefyContext();
const davesPubKey = '828adc3061e4f0c3f1984dce96003eb89d2ab279e703e01de806c9b2ba33ff73';
ContactChannel.create([davesPubKey]).pipe(
mergeMap(msgChannel => {
// until Dave responds, messages are sent to
// him via his Notifications index
return msgChannel.sendMessage("Hi Dave!");
})
).subscribe();

Listen for new contacts

Example assumes RelayPool in context and the keyStore load() has been called and the documentsIndex is in the 'ready' state.

const { keyStore } = useDefyContext();
const [sub$, endSub] = keyStore.notifyIndex.listen<Notification>();
sub$.pipe(
// make sure there is a channelId in the notification
filter(notif => Boolean(notif.content.tags?.[0]?.[0] === 'e')),
mergeMap(notif => {
const contact = keyStore.contacts.find(x => x.channelId === notif.content.tags![0][1]);
if(contact){
return of(contact);
} else {
return ContactChannel.create(notif, true)
}
})
).subscribe(contact => {
// a saved contact that we can send and receive messages on
console.log(contact);
});

Read DM Messages from existing contacts

Example assumes RelayPool in context and the keyStore load() has been called and the documentsIndex is in the 'ready' state.

const { keyStore } = useDefyContext();

// look for a one on one conversation
const davesPubKey = '828adc3061e4f0c3f1984dce96003eb89d2ab279e703e01de806c9b2ba33ff73';
let contact = keyStore.contacts.find(x => x.content.contacts[davesPubKey]
&& Object.keys(x.indexes).length == 1);
// or look by channelId// or look by channelId
const channelId = ContactChannel.getChannelId([
davesPubKey, keyStore.ownerPubKey
])
contact = keyStore.contacts.find(x => x.channelId === channelId)

const [sub$, endSub] = contact.listen();
sub$.pipe(
bufferTime(1000),
tap(messages => {
console.log(messages);
})
).subscribe();

Version 1.5.2 Changes

  • added ability to call PrivateChannel.listen with keepAlive = false so the observable can get completed

License

MIT (c) 2023, 2024 David Krause https://defy.social, see LICENSE file.