diff --git a/lib/feed-v1/get-msg-id.js b/lib/feed-v1/get-msg-id.js deleted file mode 100644 index a6a00b5..0000000 --- a/lib/feed-v1/get-msg-id.js +++ /dev/null @@ -1,53 +0,0 @@ -const blake3 = require('blake3') -const base58 = require('bs58') -const stringify = require('json-canon') - -/** - * @typedef {import('./index').Msg} Msg - */ - -/** - * @param {Msg} msg - * @returns {Buffer} - */ -function getMsgHashBuf(msg) { - const metadataBuf = Buffer.from(stringify(msg.metadata), 'utf8') - return blake3.hash(metadataBuf).subarray(0, 16) -} - -/** - * @param {Msg | string} x - * @returns {string} - */ -function getMsgHash(x) { - if (typeof x === 'string') { - if (x.startsWith('ppppp:message/v1/')) { - const msgUri = x - const parts = msgUri.split('/') - return parts[parts.length - 1] - } else { - const msgHash = x - return msgHash - } - } else { - const msg = x - const msgHashBuf = getMsgHashBuf(msg) - return base58.encode(msgHashBuf) - } -} - -/** - * @param {Msg} msg - * @returns {string} - */ -function getMsgId(msg) { - const { who, type } = msg.metadata - const msgHash = getMsgHash(msg) - if (type) { - return `ppppp:message/v1/${who}/${type}/${msgHash}` - } else { - return `ppppp:message/v1/${who}/${msgHash}` - } -} - -module.exports = { getMsgId, getMsgHash } diff --git a/lib/feed-v1/index.js b/lib/feed-v1/index.js deleted file mode 100644 index 04db4ef..0000000 --- a/lib/feed-v1/index.js +++ /dev/null @@ -1,212 +0,0 @@ -const stringify = require('json-canon') -const ed25519 = require('ssb-keys/sodium') -const base58 = require('bs58') -const union = require('set.prototype.union') -const { stripAuthor } = require('./strip') -const { getMsgId, getMsgHash } = require('./get-msg-id') -const representContent = require('./represent-content') -const { - validateType, - validateContent, - validate, - validateBatch, - validateMsgHash, -} = require('./validation') -const Tangle = require('./tangle') - -function isEmptyObject(obj) { - for (const _key in obj) { - return false - } - return true -} - -/** - * @typedef {Iterator & {values: () => Iterator}} MsgIter - */ - -/** - * @typedef {Object} TangleMetadata - * @property {number} depth - * @property {Array} prev - */ - -/** - * @typedef {Object} Msg - * @property {*} content - * @property {Object} metadata - * @property {string} metadata.hash - * @property {number} metadata.size - * @property {Record} metadata.tangles - * @property {string} metadata.type - * @property {1} metadata.v - * @property {string} metadata.who - * @property {string} sig - */ - -/** - * @typedef {Object} Keys - * @property {string} keys.id - * @property {string} keys.private - */ - -/** - * @typedef {Object} CreateOpts - * @property {*} content - * @property {string} type - * @property {Keys} keys - * @property {Record} tangles - */ - -/** - * @typedef {Object} CreateRootOpts - * @property {string} type - * @property {Keys} keys - * @property {string} keys.id - * @property {string} keys.private - */ - -function isFeedRoot(msg, authorId, findType) { - const findWho = stripAuthor(authorId) - const { who, type, tangles } = msg.metadata - return who === findWho && type === findType && isEmptyObject(tangles) -} - -function getFeedRootHash(authorId, type) { - const who = stripAuthor(authorId) - - const msg = { - content: null, - metadata: { - hash: null, - size: 0, - tangles: {}, - type, - v: 1, - who, - }, - sig: '', - } - - return getMsgHash(msg) -} - -function toPlaintextBuffer(opts) { - return Buffer.from(stringify(opts.content), 'utf8') -} - -/** - * @param {CreateOpts} opts - * @returns {Msg} - */ -function create(opts) { - let err - if ((err = validateType(opts.type))) throw err - if (!opts.tangles) throw new Error('opts.tangles is required') - - const [hash, size] = representContent(opts.content) - - const tangles = {} - if (opts.tangles) { - for (const rootId in opts.tangles) { - if ((err = validateMsgHash(rootId))) throw err - const tangle = opts.tangles[rootId] - const depth = tangle.getMaxDepth() + 1 - const tips = tangle.getTips() - const lipmaaSet = tangle.getLipmaaSet(depth) - const prev = ([...union(lipmaaSet, tips)]).sort() - tangles[rootId] = { depth, prev } - } - } else { - // prettier-ignore - throw new Error(`cannot create msg without tangles, that's the case for createRoot()`) - } - - const msg = { - content: opts.content, - metadata: { - hash, - size, - tangles, - type: opts.type, - v: 1, - who: stripAuthor(opts.keys.id), - }, - sig: '', - } - if ((err = validateContent(msg))) throw err - - const privateKey = Buffer.from(opts.keys.private, 'base64') - // TODO: add a label prefix to the metadata before signing - const metadataBuf = Buffer.from(stringify(msg.metadata), 'utf8') - // TODO: when signing, what's the point of a customizable hmac? - const sigBuf = ed25519.sign(privateKey, metadataBuf) - msg.sig = base58.encode(sigBuf) - - return msg -} - -/** - * @param {Keys} keys - * @param {string} type - * @returns {Msg} - */ -function createRoot(keys, type) { - let err - if ((err = validateType(type))) throw err - - const msg = { - content: null, - metadata: { - hash: null, - size: 0, - tangles: {}, - type, - v: 1, - who: stripAuthor(keys.id), - }, - sig: '', - } - - const privateKey = Buffer.from(keys.private, 'base64') - // TODO: add a label prefix to the metadata before signing - const metadataBuf = Buffer.from(stringify(msg.metadata), 'utf8') - // TODO: when signing, what's the point of a customizable hmac? - const sigBuf = ed25519.sign(privateKey, metadataBuf) - msg.sig = base58.encode(sigBuf) - - return msg -} - -/** - * @param {Msg} msg - * @returns {Msg} - */ -function erase(msg) { - return { ...msg, content: null } -} - -/** - * @param {Buffer} plaintextBuf - * @param {Msg} msg - * @returns {Msg} - */ -function fromPlaintextBuffer(plaintextBuf, msg) { - return { ...msg, content: JSON.parse(plaintextBuf.toString('utf-8')) } -} - -module.exports = { - getMsgHash, - getMsgId, - isFeedRoot, - getFeedRootHash, - create, - createRoot, - erase, - stripAuthor, - toPlaintextBuffer, - fromPlaintextBuffer, - Tangle, - validate, - validateBatch, -} diff --git a/lib/feed-v1/represent-content.js b/lib/feed-v1/represent-content.js deleted file mode 100644 index d5615e8..0000000 --- a/lib/feed-v1/represent-content.js +++ /dev/null @@ -1,16 +0,0 @@ -const blake3 = require('blake3') -const base58 = require('bs58') -const stringify = require('json-canon') - -/** - * @param {any} content - * @returns {[string, number]} - */ -function representContent(content) { - const contentBuf = Buffer.from(stringify(content), 'utf8') - const hash = base58.encode(blake3.hash(contentBuf).subarray(0, 16)) - const size = contentBuf.length - return [hash, size] -} - -module.exports = representContent diff --git a/lib/feed-v1/strip.js b/lib/feed-v1/strip.js deleted file mode 100644 index 36fe310..0000000 --- a/lib/feed-v1/strip.js +++ /dev/null @@ -1,29 +0,0 @@ -const { getMsgHash } = require('./get-msg-id') - -function stripMsgKey(msgKey) { - if (typeof msgKey === 'object') { - if (msgKey.key) return stripMsgKey(msgKey.key) - else return getMsgHash(msgKey) - } - if (msgKey.startsWith('ppppp:message/v1/')) { - const parts = msgKey.split('/') - return parts[parts.length - 1] - } else { - return msgKey - } -} - -/** - * @param {string} id - * @returns {string} - */ -function stripAuthor(id) { - if (id.startsWith('ppppp:feed/v1/') === false) return id - const withoutPrefix = id.replace('ppppp:feed/v1/', '') - return withoutPrefix.split('/')[0] -} - -module.exports = { - stripMsgKey, - stripAuthor, -} diff --git a/lib/feed-v1/tangle.js b/lib/feed-v1/tangle.js deleted file mode 100644 index 5c48023..0000000 --- a/lib/feed-v1/tangle.js +++ /dev/null @@ -1,261 +0,0 @@ -/** - * @typedef {import("./index").Msg} Msg - */ - -function lipmaa(n) { - let m = 1 - let po3 = 3 - let u = n - - // find k such that (3^k - 1)/2 >= n - while (m < n) { - po3 *= 3 - m = (po3 - 1) / 2 - } - - // find longest possible backjump - po3 /= 3 - if (m !== n) { - while (u !== 0) { - m = (po3 - 1) / 2 - po3 /= 3 - u %= m - } - - if (m !== po3) { - po3 = m - } - } - - return n - po3 -} - -/** - * @param {string} a - * @param {string} b - * @returns number - */ -function compareMsgHashes(a, b) { - return a.localeCompare(b) -} - -class Tangle { - /** - * @type {string} - */ - #rootHash - - /** - * @type {Msg} - */ - #rootMsg - - /** - * @type {Set} - */ - #tips = new Set() - - /** - * @type {Map>} - */ - #prev = new Map() - - /** - * @type {Map} - */ - #depth = new Map() - - /** - * @type {Map>} - */ - #perDepth = new Map() - - /** - * @type {number} - */ - #maxDepth - - /** - * @param {string} rootHash - * @param {Iterable} msgsIter - */ - constructor(rootHash) { - this.#rootHash = rootHash - this.#maxDepth = 0 - } - - add(msgHash, msg) { - if (msgHash === this.#rootHash && !this.#rootMsg) { - this.#tips.add(msgHash) - this.#perDepth.set(0, [msgHash]) - this.#depth.set(msgHash, 0) - this.#rootMsg = msg - return - } - - const tangles = msg.metadata.tangles - if (msgHash !== this.#rootHash && tangles[this.#rootHash]) { - this.#tips.add(msgHash) - const prev = tangles[this.#rootHash].prev - for (const p of prev) { - this.#tips.delete(p) - } - this.#prev.set(msgHash, prev) - const depth = tangles[this.#rootHash].depth - if (depth > this.#maxDepth) this.#maxDepth = depth - this.#depth.set(msgHash, depth) - const atDepth = this.#perDepth.get(depth) ?? [] - atDepth.push(msgHash) - atDepth.sort(compareMsgHashes) - this.#perDepth.set(depth, atDepth) - return - } - } - - /** - * @param {number} depth - * @returns {Array} - */ - #getAllAtDepth(depth) { - return this.#perDepth.get(depth) ?? [] - } - - /** - * @returns {Array} - */ - topoSort() { - if (!this.#rootMsg) { - console.warn('Tangle is missing root message') - return [] - } - const sorted = [] - const max = this.#maxDepth - for (let i = 0; i <= max; i++) { - const atDepth = this.#getAllAtDepth(i) - for (const msgHash of atDepth) { - sorted.push(msgHash) - } - } - return sorted - } - - /** - * @returns {Set} - */ - getTips() { - if (!this.#rootMsg) { - console.warn('Tangle is missing root message') - return new Set() - } - return this.#tips - } - - /** - * @param {number} depth - * @returns {Set} - */ - getLipmaaSet(depth) { - if (!this.#rootMsg) { - console.warn('Tangle is missing root message') - return new Set() - } - const lipmaaDepth = lipmaa(depth + 1) - 1 - return new Set(this.#getAllAtDepth(lipmaaDepth)) - } - - /** - * @param {string} msgHash - * @returns {boolean} - */ - has(msgHash) { - return this.#depth.has(msgHash) - } - - /** - * @param {string} msgHash - * @returns {number} - */ - getDepth(msgHash) { - return this.#depth.get(msgHash) ?? -1 - } - - isFeed() { - if (!this.#rootMsg) { - console.warn('Tangle is missing root message') - return false - } - if (this.#rootMsg.content) return false - const metadata = this.#rootMsg.metadata - return metadata.size === 0 && metadata.hash === null - } - - getFeed() { - if (!this.isFeed()) return null - const { type, who } = this.#rootMsg.metadata - return { type, who } - } - - shortestPathToRoot(msgHash) { - if (!this.#rootMsg) { - console.warn('Tangle is missing root message') - return [] - } - const path = [] - let current = msgHash - while (true) { - const prev = this.#prev.get(current) - if (!prev) break - let minDepth = this.#depth.get(current) - let min = current - for (const p of prev) { - const d = this.#depth.get(p) - if (d < minDepth) { - minDepth = d - min = p - } else if (d === minDepth && compareMsgHashes(p, min) < 0) { - min = p - } - } - path.push(min) - current = min - } - return path - } - - precedes(a, b) { - if (!this.#rootMsg) { - console.warn('Tangle is missing root message') - return false - } - if (a === b) return false - if (b === this.#rootHash) return false - let toCheck = [b] - while (toCheck.length > 0) { - const prev = this.#prev.get(toCheck.shift()) - if (!prev) continue - if (prev.includes(a)) return true - toCheck.push(...prev) - } - return false - } - - size() { - return this.#depth.size - } - - getMaxDepth() { - return this.#maxDepth - } - - debug() { - let str = '' - const max = this.#maxDepth - for (let i = 0; i <= max; i++) { - const atDepth = this.#getAllAtDepth(i) - str += `Depth ${i}: ${atDepth.join(', ')}\n` - } - return str - } -} - -module.exports = Tangle diff --git a/lib/feed-v1/validation.js b/lib/feed-v1/validation.js deleted file mode 100644 index 6f8f18e..0000000 --- a/lib/feed-v1/validation.js +++ /dev/null @@ -1,249 +0,0 @@ -const base58 = require('bs58') -const ed25519 = require('ssb-keys/sodium') -const stringify = require('json-canon') -const Tangle = require('./tangle') -const representContent = require('./represent-content') - -function validateShape(msg) { - if (!msg || typeof msg !== 'object') { - return new Error('invalid message: not an object') - } - if (!msg.metadata || typeof msg.metadata !== 'object') { - return new Error('invalid message: must have metadata') - } - if (typeof msg.metadata.who === 'undefined') { - return new Error('invalid message: must have metadata.who') - } - if (msg.metadata.v !== 1) { - return new Error('invalid message: must have metadata.v 1') - } - if (typeof msg.metadata.tangles !== 'object') { - return new Error('invalid message: must have metadata.tangles') - } - if (typeof msg.metadata.hash === 'undefined') { - return new Error('invalid message: must have metadata.hash') - } - if (typeof msg.metadata.size === 'undefined') { - return new Error('invalid message: must have metadata.size') - } - if (typeof msg.content === 'undefined') { - return new Error('invalid message: must have content') - } - if (typeof msg.sig === 'undefined') { - return new Error('invalid message: must have sig') - } -} - -function validateWho(msg) { - try { - const whoBuf = base58.decode(msg.metadata.who) - if (whoBuf.length !== 32) { - return new Error( - `invalid message: decoded "who" should be 32 bytes but was ${whoBuf.length}` - ) - } - } catch (err) { - return new Error('invalid message: must have "who" as base58 string') - } -} - -function validateMsgHash(str) { - try { - const hashBuf = Buffer.from(base58.decode(str)) - if (hashBuf.length !== 16) { - return new Error( - `invalid message: decoded hash should be 16 bytes but was ${hashBuf.length}` - ) - } - } catch (err) { - return new Error( - `invalid message: msgHash ${str} should have been a base58 string` - ) - } -} - -function validateSize(msg) { - const { - metadata: { size }, - } = msg - if (!Number.isSafeInteger(size) || size < 0) { - return new Error(`invalid message: "size" should be an unsigned integer`) - } -} - -function validateSignature(msg) { - const { sig } = msg - if (typeof sig !== 'string') { - return new Error('invalid message: must have sig as a string') - } - let sigBuf - try { - sigBuf = Buffer.from(base58.decode(sig)) - if (sigBuf.length !== 64) { - // prettier-ignore - return new Error('invalid message: sig should be 64 bytes but was ' + sigBuf.length + ', on feed: ' + msg.metadata.who); - } - } catch (err) { - return new Error('invalid message: sig must be a base58 string') - } - - const publicKeyBuf = Buffer.from(base58.decode(msg.metadata.who)) - const signableBuf = Buffer.from(stringify(msg.metadata), 'utf8') - const verified = ed25519.verify(publicKeyBuf, sigBuf, signableBuf) - if (!verified) { - // prettier-ignore - return new Error('invalid message: sig does not match, on feed: ' + msg.metadata.who); - } -} - -/** - * - * @param {any} msg - * @param {Tangle} tangle - * @param {*} tangleId - * @returns - */ -function validateTangle(msg, tangle, tangleId) { - if (!msg.metadata.tangles[tangleId]) { - return new Error('invalid message: must have metadata.tangles.' + tangleId) - } - const { depth, prev } = msg.metadata.tangles[tangleId] - if (!prev || !Array.isArray(prev)) { - // prettier-ignore - return new Error('invalid message: prev must be an array, on feed: ' + msg.metadata.who); - } - if (!Number.isSafeInteger(depth) || depth <= 0) { - // prettier-ignore - return new Error('invalid message: depth must be a positive integer, on feed: ' + msg.metadata.who); - } - if (tangle.isFeed()) { - const { type, who } = tangle.getFeed() - if (type !== msg.metadata.type) { - // prettier-ignore - return new Error(`invalid message: type "${msg.metadata.type}" does not match feed type "${type}"`) - } - if (who !== msg.metadata.who) { - // prettier-ignore - return new Error(`invalid message: who "${msg.metadata.who}" does not match feed who "${who}"`) - } - } - let lastPrev = null - let minDiff = Infinity - let countPrevUnknown = 0 - for (const p of prev) { - if (typeof p !== 'string') { - // prettier-ignore - return new Error('invalid message: prev must contain strings but found ' + p + ', on feed: ' + msg.metadata.who); - } - if (p.startsWith('ppppp:')) { - // prettier-ignore - return new Error('invalid message: prev must not contain URIs, on feed: ' + msg.metadata.who); - } - if (lastPrev !== null) { - if (p === lastPrev) { - return new Error(`invalid message: prev must be unique set, on feed ${msg.metadata.who}`) - } - if (p < lastPrev) { - return new Error(`invalid message: prev must be sorted in alphabetical order, on feed ${msg.metadata.who}`) - } - } - lastPrev = p - - if (!tangle.has(p)) { - countPrevUnknown += 1 - continue - } - const prevDepth = tangle.getDepth(p) - - const diff = depth - prevDepth - if (diff <= 0) { - // prettier-ignore - return new Error('invalid message: depth of prev ' + p + ' is not lower, on feed: ' + msg.metadata.who); - } - if (diff < minDiff) minDiff = diff - } - - if (countPrevUnknown === prev.length) { - // prettier-ignore - return new Error('invalid message: all prev are locally unknown, on feed: ' + msg.metadata.who) - } - - if (countPrevUnknown === 0 && minDiff !== 1) { - // prettier-ignore - return new Error('invalid message: depth must be the largest prev depth plus one'); - } -} - -function validateTangleRoot(msg, msgHash, tangleId) { - if (msgHash !== tangleId) { - // prettier-ignore - return new Error('invalid message: tangle root hash must match tangleId, on feed: ' + msg.metadata.who); - } - if (msg.metadata.tangles[tangleId]) { - // prettier-ignore - return new Error('invalid message: tangle root must not have self tangle data, on feed: ' + msg.metadata.who); - } -} - -function validateType(type) { - if (!type || typeof type !== 'string') { - // prettier-ignore - return new Error('type is not a string'); - } - if (type.length > 100) { - // prettier-ignore - return new Error('invalid type ' + type + ' is 100+ characters long'); - } - if (type.length < 3) { - // prettier-ignore - return new Error('invalid type ' + type + ' is shorter than 3 characters'); - } - if (/[^a-zA-Z0-9_]/.test(type)) { - // prettier-ignore - return new Error('invalid type ' + type + ' contains characters other than a-z, A-Z, 0-9, or _'); - } -} - -function validateContent(msg) { - const { content } = msg - if (content === null) { - return - } - if (Array.isArray(content)) { - return new Error('invalid message: content must not be an array') - } - if (typeof content !== 'object' && typeof content !== 'string') { - // prettier-ignore - return new Error('invalid message: content must be an object or string, on feed: ' + msg.metadata.who); - } - const [hash, size] = representContent(content) - if (hash !== msg.metadata.hash) { - // prettier-ignore - return new Error('invalid message: content hash does not match metadata.hash, on feed: ' + msg.metadata.who); - } - if (size !== msg.metadata.size) { - // prettier-ignore - return new Error('invalid message: content size does not match metadata.size, on feed: ' + msg.metadata.who); - } -} - -function validate(msg, tangle, msgHash, rootHash) { - let err - if ((err = validateShape(msg))) return err - if ((err = validateWho(msg))) return err - if ((err = validateSize(msg))) return err - if (tangle.size() === 0) { - if ((err = validateTangleRoot(msg, msgHash, rootHash))) return err - } else { - if ((err = validateTangle(msg, tangle, rootHash))) return err - } - if ((err = validateContent(msg))) return err - if ((err = validateSignature(msg))) return err -} - -module.exports = { - validateType, - validateContent, - validate, - validateMsgHash, -}