From 62e7e74bd278473cc4358700b7f2b5c0a78ac681 Mon Sep 17 00:00:00 2001 From: cel Date: Thu, 25 May 2017 16:06:28 -1000 Subject: Encrypt blobs in private messages --- lib/app.js | 56 ++++++++++++++++++++++++++++++++++++++++------ lib/render.js | 11 +++++++-- lib/serve.js | 71 ++++++++++++++++++++++++++++++++++++++--------------------- 3 files changed, 104 insertions(+), 34 deletions(-) (limited to 'lib') diff --git a/lib/app.js b/lib/app.js index 4219151..3a41086 100644 --- a/lib/app.js +++ b/lib/app.js @@ -11,6 +11,10 @@ var Contacts = require('ssb-contact') var About = require('./about') var Serve = require('./serve') var Render = require('./render') +var BoxStream = require('pull-box-stream') +var crypto = require('crypto') + +var zeros = new Buffer(24); zeros.fill(0) module.exports = App @@ -168,16 +172,54 @@ App.prototype.publish = function (content, cb) { tryPublish(2) } -App.prototype.addBlob = function (cb) { +App.prototype.addBlobRaw = function (cb) { var done = multicb({pluck: 1, spread: true}) - var hashCb = done() - var addCb = done() - done(function (err, hash, add) { - cb(err, hash) + var sink = pull( + hasher(done()), + u.pullLength(done()), + this.sbot.blobs.add(done()) + ) + done(function (err, hash, size, _) { + if (err) return cb(err) + cb(null, {link: hash, size: size}) }) + return sink +} + +App.prototype.addBlob = function (isPrivate, cb) { + if (!isPrivate) return this.addBlobRaw(cb) + else return this.addBlobPrivate(cb) +} + +App.prototype.addBlobPrivate = function (cb) { + var bufs = [] + var self = this + // use the hash of the cleartext as the key to encrypt the blob + var hash = crypto.createHash('sha256') + return pull.drain(function (buf) { + bufs.push(buf) + hash.update(buf) + }, function (err) { + if (err) return cb(err) + var secret = hash.digest() + pull( + pull.values(bufs), + BoxStream.createBoxStream(secret, zeros), + self.addBlobRaw(function (err, link) { + if (err) return cb(err) + link.key = secret.toString('base64') + cb(null, link) + }) + ) + }) +} + +App.prototype.getBlob = function (id, key) { + if (!key) return this.sbot.blobs.get(id) + if (typeof key === 'string') key = new Buffer(key, 'base64') return pull( - hasher(hashCb), - this.sbot.blobs.add(addCb) + this.sbot.blobs.get(id), + BoxStream.createUnboxStream(key, zeros) ) } diff --git a/lib/render.js b/lib/render.js index dd6d55e..3b4a142 100644 --- a/lib/render.js +++ b/lib/render.js @@ -129,6 +129,7 @@ Render.prototype.markdown = function (text, mentions) { Render.prototype.imageUrl = function (ref) { var m = /^blobstore:(.*)/.exec(ref) if (m) ref = m[1] + ref = ref.replace(/#/, '%23') return this.opts.img_base + ref } @@ -161,8 +162,14 @@ Render.prototype.toUrl = function (href) { if (!u.isRef(href)) return false return this.opts.base + href case '&': - if (!u.isRef(href)) return false - return this.opts.blob_base + href + var parts = href.split('#') + var hash = parts.shift() + var key = parts.shift() + var fragment = parts.join('#') + if (!u.isRef(hash)) return false + return this.opts.blob_base + hash + + (key ? encodeURIComponent('#' + key) : '') + + (fragment ? '#' + fragment : '') case '#': return this.opts.base + 'channel/' + encodeURIComponent(href.substr(1)) case '/': return this.opts.base + href.substr(1) diff --git a/lib/serve.js b/lib/serve.js index 8d3eeba..1ce18c6 100644 --- a/lib/serve.js +++ b/lib/serve.js @@ -24,7 +24,7 @@ module.exports = Serve var emojiDir = path.join(require.resolve('emoji-named-characters'), '../pngs') -var urlIdRegex = /^(?:\/+(([%&@]|%25)(?:[A-Za-z0-9\/+]|%2[Ff]|%2[Bb]){43}(?:=|%3D)\.(?:sha256|ed25519))(?:\.([^?]*))?|(\/.*?))(?:\?(.*))?$/ +var urlIdRegex = /^(?:\/+(([%&@]|%25)(?:[A-Za-z0-9\/+]|%2[Ff]|%2[Bb]){43}(?:=|%3D)\.(?:sha256|ed25519))([^?]*)?|(\/.*?))(?:\?(.*))?$/ function ctype(name) { switch (name && /[^.\/]*$/.exec(name)[0] || 'html') { @@ -75,19 +75,18 @@ Serve.prototype.go = function () { gotData(err, data) }) busboy.on('file', function (fieldname, file, filename, encoding, mimetype) { - var done = multicb({pluck: 1, spread: true}) var cb = filesCb() pull( toPull(file), - u.pullLength(done()), - self.app.addBlob(done()) + self.app.addBlob(!!data.private, function (err, link) { + if (err) return cb(err) + if (link.size === 0 && !filename) return cb() + link.name = filename + link.type = mimetype + data[fieldname] = link + cb() + }) ) - done(function (err, size, id) { - if (err) return cb(err) - if (size === 0 && !filename) return cb() - data[fieldname] = {link: id, name: filename, type: mimetype, size: size} - cb() - }) }) busboy.on('field', function (fieldname, val, fieldnameTruncated, valTruncated, encoding, mimetype) { if (!(fieldname in data)) data[fieldname] = val @@ -197,7 +196,7 @@ Serve.prototype.handle = function () { case '%25': m[2] = '%'; m[1] = decodeURIComponent(m[1]) case '%': return this.id(m[1], m[3]) case '@': return this.userFeed(m[1], m[3]) - case '&': return this.blob(m[1]) + case '&': return this.blob(m[1], m[3]) default: return this.path(m[4]) } } @@ -809,7 +808,7 @@ function threadHeads(msgs, rootId) { } -Serve.prototype.id = function (id, ext) { +Serve.prototype.id = function (id, path) { var self = this if (self.query.raw != null) return self.rawId(id) @@ -845,16 +844,14 @@ Serve.prototype.id = function (id, ext) { channel: channel, }), self.wrapPage(id), - self.respondSink(200, { - 'Content-Type': ctype(ext), - }) + self.respondSink(200) ) }) ) }) } -Serve.prototype.userFeed = function (id, ext) { +Serve.prototype.userFeed = function (id, path) { var self = this var q = self.query var opts = { @@ -874,9 +871,7 @@ Serve.prototype.userFeed = function (id, ext) { self.wrapMessages(), self.wrapUserFeed(isScrolled, id), self.wrapPage(about.name || id), - self.respondSink(200, { - 'Content-Type': ctype(ext) - }) + self.respondSink(200) ) }) } @@ -905,10 +900,27 @@ Serve.prototype.emoji = function (emoji) { serveEmoji(this.req, this.res, emoji) } -Serve.prototype.blob = function (id) { +Serve.prototype.blob = function (id, path) { var self = this var blobs = self.app.sbot.blobs - if (self.req.headers['if-none-match'] === id) return self.respond(304) + var etag = id + (path || '') + if (self.req.headers['if-none-match'] === etag) return self.respond(304) + var key + if (path) { + path = decodeURIComponent(path) + if (path[0] === '#') { + try { + key = new Buffer(path.substr(1), 'base64') + } catch(err) { + return self.respond(400, err.message) + } + if (key.length !== 32) { + return self.respond(400, 'Bad blob key') + } + } else { + return self.respond(400, 'Bad blob request') + } + } var done = multicb({pluck: 1, spread: true}) blobs.want(id, function (err, has) { if (err) { @@ -918,7 +930,7 @@ Serve.prototype.blob = function (id) { if (!has) return self.respond(404, 'Not found') blobs.size(id, done()) pull( - blobs.get(id), + self.app.getBlob(id, key), pull.map(Buffer), ident(done().bind(self, null)), self.respondSink() @@ -927,11 +939,14 @@ Serve.prototype.blob = function (id) { if (err) console.trace(err) type = type && mime.lookup(type) if (type) self.res.setHeader('Content-Type', type) - if (typeof size === 'number') self.res.setHeader('Content-Length', size) + // don't serve size for encrypted blob, because it refers to the size of + // the ciphertext + if (typeof size === 'number' && !key) + self.res.setHeader('Content-Length', size) if (self.query.name) self.res.setHeader('Content-Disposition', 'inline; filename='+encodeDispositionFilename(self.query.name)) self.res.setHeader('Cache-Control', 'public, max-age=315360000') - self.res.setHeader('etag', id) + self.res.setHeader('etag', etag) self.res.writeHead(200) }) }) @@ -1290,6 +1305,7 @@ Serve.prototype.wrapThread = function (opts) { branches: opts.branches, postBranches: opts.postBranches, recps: recps, + private: opts.recps != null, }, function (err, composer) { if (err) return cb(err) cb(null, [ @@ -1428,6 +1444,7 @@ Serve.prototype.composer = function (opts, cb) { blobs[data.upload.link] = { type: data.upload.type, size: data.upload.size, + key: data.upload.key, } } if (data.blob_type && blobs[data.blob_link]) { @@ -1443,11 +1460,13 @@ Serve.prototype.composer = function (opts, cb) { } if (data.upload) { + var href = data.upload.link + + (data.upload.key ? '#' + data.upload.key : '') // TODO: be able to change the content-type var isImage = /^image\//.test(data.upload.type) data.text = (data.text ? data.text + '\n' : '') + (isImage ? '!' : '') - + '[' + data.upload.name + '](' + data.upload.link + ')' + + '[' + data.upload.name + '](' + href + ')' } // get bare feed names @@ -1509,6 +1528,8 @@ Serve.prototype.composer = function (opts, cb) { h('table.ssb-msgs', h('tr.msg-row', h('td.msg-left', {colspan: 2}, + opts.private ? + h('input', {type: 'hidden', name: 'private', value: '1'}) : '', h('input', {type: 'file', name: 'upload'}) ), h('td.msg-right', -- cgit v1.2.3