Skip to content

Commit

Permalink
Merge pull request #69 from cabal-club/private-messages
Browse files Browse the repository at this point in the history
Private messages
  • Loading branch information
noffle authored Apr 13, 2021
2 parents 1f09c8c + c9445a0 commit 6b07050
Show file tree
Hide file tree
Showing 7 changed files with 423 additions and 1 deletion.
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,34 @@ responsible for the state change.
This event happens when a moderation update was skipped with `skip`, the log
record responsible for the state change.

### Private Messages

#### cabal.publishPrivateMessage(text, recipientKey, cb)

Write the private message string `text` to be encrypted so that only
`recipientKey` (the public key of its recipient) can read it.

A `timestamp` field is set automatically with the current local system time.

#### cabal.privateMessages.list(cb)

Returns a list of strings of all users' public keys that have sent a PM to you,
or who you have sent a PM to.

#### var rs = cabal.privateMessages.read(channel, opts)

Returns a readable stream of messages (most recent first) from a channel.

Pass `opts.limit` to set a maximum number of messages to read.

#### cabal.privateMessages.events.on('message', fn)

Calls `fn` with every new private message that arrives.

#### cabal.privateMessages.events.on(publicKey, fn)

Calls `fn` with every new message that arrives to or from `publicKey`.

### Publishing

#### cabal.publish(message, opts, cb)
Expand Down
44 changes: 44 additions & 0 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ var events = require('events')
var inherits = require('inherits')
var level = require('level-mem')
var thunky = require('thunky')
var { box } = require('./lib/crypto')
var timestamp = require('monotonic-timestamp')
var sublevel = require('subleveldown')
var crypto = require('hypercore-crypto')
Expand All @@ -13,6 +14,7 @@ var createTopicsView = require('./views/topics')
var createUsersView = require('./views/users')
var createModerationView = require('./views/moderation')
var createArchivingView = require('./views/channel-archiving')
var createPrivateMessagesView = require('./views/private-messages')
var swarm = require('./swarm')

var DATABASE_VERSION = 1
Expand All @@ -24,6 +26,7 @@ var USERS = 'u'
var MODERATION_AUTH = 'mx'
var MODERATION_INFO = 'my'
var ARCHIVES = 'a'
var PRIVATE_MESSAGES = 'p'

module.exports = Cabal
module.exports.databaseVersion = DATABASE_VERSION
Expand Down Expand Up @@ -101,6 +104,12 @@ function Cabal (storage, key, opts) {
this.kcore.use('archives', createArchivingView(
this,
sublevel(this.db, ARCHIVES, { valueEncoding: json })))
this.feed((feed) => {
self.kcore.use('privateMessages', createPrivateMessagesView(
{ public: feed.key, private: feed.secretKey },
sublevel(self.db, PRIVATE_MESSAGES, { valueEncoding: json })))
this.privateMessages = this.kcore.api.privateMessages
})

this.messages = this.kcore.api.messages
this.channels = this.kcore.api.channels
Expand Down Expand Up @@ -155,6 +164,41 @@ Cabal.prototype.publish = function (message, opts, cb) {
})
}

/**
* Publish a message to your feed, encrypted to a specific recipient's key.
* @param {String} text - The textual message to publish.
* @param {String|Buffer[32]) recipientKey - A recipient's public key to encrypt the message to.
* @param {function} cb - When the message has been successfully written.
*/
Cabal.prototype.publishPrivateMessage = function (text, recipientKey, cb) {
if (!cb) cb = noop
if (typeof text !== 'string') return process.nextTick(cb, new Error('text must be a string'))
if (!isHypercoreKey(recipientKey)) return process.nextTick(cb, new Error('recipientKey must be a 32-byte hypercore key'))

if (typeof recipientKey === 'string') recipientKey = Buffer.from(recipientKey, 'hex')

this.feed(function (feed) {
const message = {
type: 'private/text',
content: {
recipients: [recipientKey.toString('hex')],
text
},
timestamp: timestamp()
}
// Note: we encrypt the message to the recipient, but also to ourselves (so that we can read our part of the convo!)
const ciphertext = box(Buffer.from(JSON.stringify(message)), [recipientKey, feed.key]).toString('base64')
const encryptedMessage = {
type: 'encrypted',
content: ciphertext
}

feed.append(encryptedMessage, function (err) {
cb(err, err ? null : encryptedMessage)
})
})
}

Cabal.prototype.publishNick = function (nick, cb) {
// TODO: sanity checks on reasonable names
if (!cb) cb = noop
Expand Down
27 changes: 27 additions & 0 deletions lib/crypto.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
const pb = require('private-box')
const sodium = require('sodium-universal')

module.exports = {
box: box,
unbox: unbox
}

// Data buffer, array of hypercore public key buffers
// Returns ciphertext buffer
function box (data, recipients) {
if (!Array.isArray(recipients)) recipients = [recipients]
recipients = recipients.map(function (key) {
const pkbuf = Buffer.alloc(sodium.crypto_box_PUBLICKEYBYTES)
sodium.crypto_sign_ed25519_pk_to_curve25519(pkbuf, key)
return pkbuf
})
return pb.encrypt(data, recipients)
}

// Encrypted data buffer, hypercore secret key
// Returns decrypted buffer, or undefined if not addressed to the key
function unbox (cdata, key) {
const skbuf = Buffer.alloc(sodium.crypto_box_SECRETKEYBYTES)
sodium.crypto_sign_ed25519_sk_to_curve25519(skbuf, key)
return pb.decrypt(cdata, skbuf)
}
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,11 +46,13 @@
"materialized-group-auth": "^2.1.0",
"monotonic-timestamp": "0.0.9",
"once": "^1.4.0",
"private-box": "^0.3.1",
"pump": "^3.0.0",
"query-string": "^6.8.2",
"randombytes": "^2.0.6",
"read-only-stream": "^2.0.0",
"readable-stream": "^3.4.0",
"sodium-universal": "^2.0.0",
"strftime": "^0.10.0",
"subleveldown": "^4.1.0",
"through2": "^3.0.1",
Expand Down
147 changes: 147 additions & 0 deletions test/private.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
// Tests for private messages.

const Cabal = require('..')
const test = require('tape')
const ram = require('random-access-memory')
const crypto = require('hypercore-crypto')
const pump = require('pump')
const {unbox} = require('../lib/crypto')

test('write a private message & check it\'s not plaintext', function (t) {
t.plan(5)

const keypair = crypto.keyPair()

const cabal = Cabal(ram)
cabal.ready(function () {
cabal.publishPrivateMessage('greetings', keypair.publicKey, function (err, cipherMsg) {
t.error(err)
t.same(cipherMsg.type, 'encrypted', 'type is "encrypted"')
t.ok(typeof cipherMsg.content, 'content is a string')
t.notSame(cipherMsg.content.toString(), 'greetings')

const anotherKeypair = crypto.keyPair()
const failtext = unbox(Buffer.from(cipherMsg.content, 'base64'), anotherKeypair.secretKey)
t.same(typeof failtext, 'undefined', 'could not decrypt')
})
})
})

test('write a private message & manually decrypt', function (t) {
t.plan(11)

const keypair = crypto.keyPair()

const cabal = Cabal(ram)
cabal.ready(function () {
cabal.publishPrivateMessage('hello', keypair.publicKey, function (err, cipherMsg) {
t.error(err)
t.same(cipherMsg.type, 'encrypted', 'type is "encrypted"')

// decrypt with recipient key
const plaintext = unbox(Buffer.from(cipherMsg.content, 'base64'), keypair.secretKey).toString()
try {
const message = JSON.parse(plaintext)
t.same(message.type, 'private/text', 'type is ok')
t.same(typeof message.content, 'object', 'content is set')
t.same(message.content.text, 'hello', 'text is ok')
t.same(message.content.recipients, [keypair.publicKey.toString('hex')], 'recipients field ok')
} catch (err) {
t.error(err)
}

// decrypt with sender key
cabal.feed(function (feed) {
const res = unbox(Buffer.from(cipherMsg.content, 'base64'), feed.secretKey)
t.ok(res, 'decrypted ok')
const plaintext = res.toString()
try {
const message = JSON.parse(plaintext)
t.same(message.type, 'private/text', 'type is ok')
t.same(typeof message.content, 'object', 'content is set')
t.same(message.content.text, 'hello', 'text is ok')
t.same(message.content.recipients, [keypair.publicKey.toString('hex')], 'recipients field ok')
} catch (err) {
t.error(err)
}
})
})
})
})

test('write a private message and read it on the other device', function (t) {
t.plan(13)

var sharedKey

function create (id, cb) {
var cabal = Cabal(ram, sharedKey ? sharedKey : null)
cabal.ready(function () {
if (!sharedKey) sharedKey = cabal.key
cabal.getLocalKey(function (err, key) {
if (err) return cb(err)
cabal._key = key
cb(null, cabal)
})
})
}

var count = 0
function checkIfDone () {
count++
if (count === 2) {
t.end()
}
}
create(1, function (err, c1) {
t.error(err)
create(2, function (err, c2) {
t.error(err)
create(3, function (err, c3) {
t.error(err)
c1.publishPrivateMessage('beeps & boops', c2._key, (err) => {
t.error(err)

c1.ready(() => {
c1.privateMessages.read(c2._key).once('data', msg => {
t.equals(msg.key, c1._key)
t.equals(msg.value.content.text, 'beeps & boops')

sync(c1, c2, function (err) {
t.error(err, 'sync ok')

c2.privateMessages.read(c1._key).once('data', msg => {
t.equals(msg.key, c1._key)
t.equals(msg.value.content.text, 'beeps & boops')

c2.privateMessages.list((err, keys) => {
t.error(err)
t.deepEquals(keys, [c1._key])

sync(c1, c3, function (err) {
t.error(err, 'sync ok')

c3.privateMessages.read(c1._key)
.once('data', msg => {
t.fail('should not be able to decrypt this')
})
.once('end', () => {
t.pass('no decryptable messages ok')
checkIfDone()
})
})
})
})
})
})
})
})
})
})
})
})

function sync (a, b, cb) {
var r = a.replicate(true, {live:false})
pump(r, b.replicate(false, {live:false}), r, cb)
}
3 changes: 2 additions & 1 deletion test/test.js
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,8 @@ test('local replication', function (t) {
})
})

test('swarm network replication', function (t) {
// FIXME: does not terminate itself properly
test.skip('swarm network replication', function (t) {
t.plan(15)

var key
Expand Down
Loading

0 comments on commit 6b07050

Please sign in to comment.