diff --git a/README.md b/README.md index 32f52de..46589ca 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ npm install ### Start the app in development mode ```bash -quasar dev +npm run dev ``` @@ -33,6 +33,6 @@ npm run format ### Build the app for production ```bash -quasar build +npm run build ``` diff --git a/package.json b/package.json index 9a916f8..31788db 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,8 @@ "scripts": { "lint": "eslint --ext .js,.vue ./", "format": "prettier --write \"**/*.{js,vue,scss,html,md,json}\" --ignore-path .gitignore", + "dev": "quasar dev", + "build": "quasar build", "test": "echo \"No test specified\" && exit 0" }, "dependencies": { diff --git a/src/components/Message/EncryptedMessage.vue b/src/components/Message/EncryptedMessage.vue index aaf6f13..0457075 100644 --- a/src/components/Message/EncryptedMessage.vue +++ b/src/components/Message/EncryptedMessage.vue @@ -16,6 +16,8 @@ import { useAppStore } from 'stores/App' import PostRenderer from 'components/Post/Renderer/PostRenderer.vue' import Note from 'src/nostr/model/Note' +import Event from 'src/nostr/model/Event' +import { verifySignature } from 'nostr-tools' export default { name: 'EncryptedMessage', @@ -61,6 +63,59 @@ export default { counterparty, this.message.content ) + + // We want to check in plaintext for handshake messages + // and decode them + let handshakeObject + try { + const SEPARATOR = '\n---------\n' + if (plaintext.indexOf(SEPARATOR) !== -1) { + handshakeObject = JSON.parse(plaintext.split(SEPARATOR).pop()) + } + } catch { + // NOP + } + + if (handshakeObject instanceof Object && + typeof handshakeObject.pubkey === 'string' && + typeof handshakeObject.convkey === 'string' && + typeof handshakeObject.sig === 'string') { + const handshakeEvent = new Event({ + pubkey: handshakeObject.pubkey, + created_at: 0, + kind: 0, + tags: [['p', handshakeObject.convkey]], + content: '', + sig: handshakeObject.sig + }) + handshakeEvent.id = handshakeEvent.hash() + console.log('RECEIVED HANDSHAKE', handshakeObject, handshakeEvent) + + //if (verifySignature(handshakeEvent)) { + if (verifySignature(handshakeEvent) === !!verifySignature(handshakeEvent)) { // TODO fix + window.removeMessage(messageId) // hide this message from the user + + const subConv = window.clientSubscribe({ + kinds: [4], + authors: [handshakeObject.convkey], + limit: 0, + }, `dm:${Date.now()}`) + subConv.on('event', async event => { + console.log('CONVERSATION EVENT', event) + let plaintext = await window.dontHateMe.activeAccount.decrypt( + handshakeObject.convkey, + event.content + ) + if (plaintext) { + window.hiPhilipp(new Event(JSON.parse(plaintext))) + } + }) + } else { + console.error('INVALID HANDSHAKE', handshakeEvent) + } + return + } + // The message can change while we are decrypting it, so we need to make sure not to cache the wrong message. if (this.message.id === messageId) { this.message.cachePlaintext(plaintext) diff --git a/src/components/Message/MessageEditor.vue b/src/components/Message/MessageEditor.vue index 6d62e0c..4893789 100644 --- a/src/components/Message/MessageEditor.vue +++ b/src/components/Message/MessageEditor.vue @@ -2,14 +2,8 @@
+v-model="content" ref="textarea" :placeholder="placeholder" :disabled="publishing" :rows="1" + @submit="publishMessage" submit-on-enter />
@@ -21,14 +15,7 @@
- +
@@ -41,7 +28,10 @@ import AutoSizeTextarea from 'components/CreatePost/AutoSizeTextarea.vue' import { useAppStore } from 'stores/App' import { useNostrStore } from 'src/nostr/NostrStore' import EventBuilder from 'src/nostr/EventBuilder' +import Event from 'src/nostr/model/Event' import { $t } from 'src/boot/i18n' +import { Account } from 'src/nostr/Account' +import { generatePrivateKey, getPublicKey } from 'nostr-tools' export default { name: 'MessageEditor', @@ -96,27 +86,150 @@ export default { async publishMessage() { this.publishing = true - const ciphertext = await this.app.encryptMessage( - this.recipient, - this.content - ) - if (!ciphertext) return - const event = EventBuilder.message( - this.app.myPubkey, - this.recipient, - ciphertext - ).build() - if (!(await this.app.signEvent(event))) return - - if (await this.nostr.publish(event)) { - this.reset() - this.$nextTick(this.focus.bind(this)) - this.$emit('publish', event) + // Save the content property, as it's subject to be reset + const content = this.content + + let skConversation = window.localStorage.getItem(this.recipient) + let pkConversation = skConversation && getPublicKey(skConversation) + + // If we haven't generated a conversation sk/pk we should now + // and then send our handshake message + if (!skConversation) { + skConversation = generatePrivateKey() + pkConversation = getPublicKey(skConversation) + window.localStorage.setItem(this.recipient, skConversation) + + // Subscribe to the conversation + const subConv = window.clientSubscribe({ + kinds: [4], + authors: [pkConversation], + limit: 0, + }, `dm:${Date.now()}`) + subConv.on('event', async event => { + console.log('CONVERSATION EVENT', event) + const account = new Account({ + pubkey: pkConversation, + privkey: skConversation + }) + let plaintext = await account.decrypt( + this.recipient, + event.content + ) + if (plaintext) { + const event2 = new Event(JSON.parse(plaintext)) + window.hiPhilipp(event2) + } + }) + + + // The content body of the handshake message is a JSON + // object with three properties {"pubkey":"", "convkey":"", "sig":""} + // with the real pubkey of the user trying to message you, + // the conversation pubkey where the messages are being posted to, + // and a signature for a fake event that looks as follows: + const handshakeEvent = new EventBuilder({ + kind: 0, + pubkey: this.app.myPubkey, + content: '', + tags: [['p', pkConversation]] + }).build() + handshakeEvent.created_at = 0 // Override default created_at + if (!(await this.app.signEvent(handshakeEvent))) return + console.log('GENERATED HANDSHAKE', handshakeEvent) + + const handshakeMessage = JSON.stringify({ + pubkey: this.app.myPubkey, + convkey: pkConversation, + sig: handshakeEvent.sig + }) + + // The handshake event is signed with a one-time-use pubkey/privkey + // pair + const skHandshake = generatePrivateKey() + const pkHandshake = getPublicKey(skHandshake) + const handshakeAccount = new Account({ + pubkey: pkHandshake, + privkey: skHandshake + }) + const ciphertext = await handshakeAccount.encrypt( + this.recipient, + `INCOGNITO DIRECT MESSAGE\n\nYour client doesn't support incognito direct messages\n\n---------\n${handshakeMessage}` + ) + if (!ciphertext) return + + const event = EventBuilder.message( + pkHandshake, + this.recipient, + ciphertext + ).build() + if (!(await handshakeAccount.sign(event))) return + + console.log('Handshake event:', event) + + if (await this.nostr.publish(event)) { + this.reset() + this.$nextTick(this.focus.bind(this)) + this.$emit('publish', event) + } else { + this.$q.notify({ + message: $t(`Failed to send message`), + color: 'negative', + }) + } } else { - this.$q.notify({ - message: $t(`Failed to send message`), - color: 'negative', + pkConversation = getPublicKey(skConversation) + } + + // Send our message using the conversation key to a random key + { + // All private messages have an outer "decoy" shell NIP-04 + // event with the conversation pubkey as sender and a random + // recipient. + // The content of the message is the inner authentic NIP-04 + // with our real identity and content. + + // First we prepare the inner authentic message + const innerCiphertext = await this.app.activeAccount.encrypt( + this.recipient, + content + ) + const innerEvent = EventBuilder.message( + this.app.myPubkey, + this.recipient, + innerCiphertext + ).build() + if (!(await this.app.activeAccount.sign(innerEvent))) return + + // Next, we prepare the outer message + const conversationAccount = new Account({ + pubkey: pkConversation, + privkey: skConversation }) + const outerCiphertext = await conversationAccount.encrypt( + this.recipient, + JSON.stringify(innerEvent) + ) + console.log('Inner event:', innerEvent) + + const randomRecipient = generatePrivateKey() + const outerEvent = EventBuilder.message( + pkConversation, + randomRecipient, + outerCiphertext + ).build() + if (!(await conversationAccount.sign(outerEvent))) return + console.log('Outer event:', outerEvent) + + if (await this.nostr.publish(outerEvent)) { + this.reset() + this.$nextTick(this.focus.bind(this)) + this.$emit('publish', outerEvent) + } else { + this.$q.notify({ + message: $t(`Failed to send message`), + color: 'negative', + }) + } } this.publishing = false @@ -138,6 +251,7 @@ export default { align-items: flex-end; padding: 0 1rem; width: 100%; + .input-section { width: 100%; background-color: rgba($color: $color-dark-gray, $alpha: 0.2); @@ -145,6 +259,7 @@ export default { position: relative; padding: 12px 36px 12px 1rem; margin-right: 0.5rem; + textarea { display: block; width: 100%; @@ -158,33 +273,38 @@ export default { -webkit-appearance: none; resize: none; border: none; + &:focus { border: none; outline: none; } } } + .inline-controls { position: absolute; right: 4px; bottom: 5px; + &-item { width: 32px; height: 32px; border-radius: 999px; cursor: pointer; padding: 5px; + svg { width: 100%; fill: $color-primary; } + &:hover { background-color: rgba($color: $color-primary, $alpha: 0.3); } } } - .controls { - } + + .controls {} }