Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Private messages #69

Merged
merged 10 commits into from
Apr 13, 2021
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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`.
ralphtheninja marked this conversation as resolved.
Show resolved Hide resolved

### Publishing

#### cabal.publish(message, opts, cb)
Expand Down
43 changes: 43 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')
hackergrrl marked this conversation as resolved.
Show resolved Hide resolved
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},
hackergrrl marked this conversation as resolved.
Show resolved Hide resolved
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,40 @@ Cabal.prototype.publish = function (message, opts, cb) {
})
}

/**
* Publish a message to your feed, encrypted to specific recipient's key.
hackergrrl marked this conversation as resolved.
Show resolved Hide resolved
* @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')],
ralphtheninja marked this conversation as resolved.
Show resolved Hide resolved
text
},
timestamp: timestamp()
}
const ciphertext = box(Buffer.from(JSON.stringify(message)), [recipientKey, feed.key]).toString('base64')
hackergrrl marked this conversation as resolved.
Show resolved Hide resolved
const encryptedMessage = {
type: 'encrypted',
ralphtheninja marked this conversation as resolved.
Show resolved Hide resolved
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 @@
var pb = require('private-box')
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i really liked that you put the crypto stuff in one place, makes it way easier to audit 🖤

cough ping @zozs cough cough

var 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) {
var pkbuf = Buffer.alloc(sodium.crypto_box_PUBLICKEYBYTES)
hackergrrl marked this conversation as resolved.
Show resolved Hide resolved
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) {
var skbuf = Buffer.alloc(sodium.crypto_box_SECRETKEYBYTES)
hackergrrl marked this conversation as resolved.
Show resolved Hide resolved
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
ralphtheninja marked this conversation as resolved.
Show resolved Hide resolved
test.skip('swarm network replication', function (t) {
t.plan(15)

var key
Expand Down
Loading