diff --git a/lib/index.js b/lib/index.js index b6cea27..fdfd529 100644 --- a/lib/index.js +++ b/lib/index.js @@ -23,6 +23,7 @@ const { decrypt } = require('./encryption') * @typedef {import('ppppp-keypair').KeypairPrivateSlice} KeypairPrivateSlice * @typedef {import('./msg-v3').Msg} Msg * @typedef {import('./msg-v3').IdentityData} IdentityData + * @typedef {import('./msg-v3').IdentityPower} IdentityPower * @typedef {import('./encryption').EncryptionFormat} EncryptionFormat * * @typedef {Buffer | Uint8Array} B4A @@ -274,18 +275,23 @@ function initDB(peer, config) { // Or even better, a bloom filter. If you just want to answer no/perhaps. let rec if ((rec = getRecord(msgHash))) return cb(null, rec) + else rec = { msg, hash: msgHash } // TODO: optimize this. This may be slow if you're adding many msgs in a // row, because it creates a new Map() each time. Perhaps with QuickLRU const tangle = new DBTangle(tangleRootHash, records()) + // Find which pubkeys are authorized to sign this msg given the identity: const pubkeys = new Set() - if (msg.metadata.identity && msg.metadata.identity !== IDENTITY_SELF) { - const identityTangle = new DBTangle(msg.metadata.identity, records()) - if (!identityTangle.has(msg.metadata.identity)) { + const identity = getIdentityId(rec) + let identityTangle = /** @type {DBTangle | null} */ (null) + if (identity && !MsgV3.isRoot(msg)) { + identityTangle = new DBTangle(identity, records()) + if (!identityTangle.has(identity)) { // prettier-ignore return cb(new Error('add() failed because the identity tangle is unknown')) } + // TODO: prune the identityTangle beyond msg.metadata.identityTips for (const msgHash of identityTangle.topoSort()) { const msg = get(msgHash) if (!msg?.data) continue @@ -303,6 +309,22 @@ function initDB(peer, config) { return cb(new Error('add() failed msg validation', { cause: err })) } + // Identity tangle related validations + if (msg.metadata.identity === IDENTITY_SELF && identityTangle) { + /** @type {IdentityData} */ + const data = msg.data + if (data.action === 'add') { + // Does this msg.pubkey have the "add" power? + const keypair = { curve: 'ed25519', public: msg.pubkey } + const powers = getIdentityPowers(identityTangle, keypair) + if (!powers.has('add')) { + // prettier-ignore + return cb(new Error('add() failed because this msg.pubkey does not have "add" power')) + } + } + // TODO validate 'del' + } + logAppend(msgHash, msg, (err, rec) => { if (err) return cb(new Error('add() failed in the log', { cause: err })) onRecordAdded.set(rec) @@ -478,6 +500,30 @@ function initDB(peer, config) { }) } + /** + * @param {DBTangle} identityTangle + * @param {KeypairPublicSlice} keypair + * @returns {Set} + */ + function getIdentityPowers(identityTangle, keypair) { + const powers = new Set() + for (const msgHash of identityTangle.topoSort()) { + const msg = get(msgHash) + if (!msg?.data) continue + /** @type {IdentityData} */ + const data = msg.data + if (data.action !== 'add') continue + if (data.add.key.algorithm !== keypair.curve) continue + if (data.add.key.bytes !== keypair.public) continue + if (data.add.powers) { + for (const power of data.add.powers) { + powers.add(power) + } + } + } + return powers + } + /** * Create a consent signature for the given `keypair` (or the implicit * config.keypair) to be added to the given `identity`. @@ -502,8 +548,15 @@ function initDB(peer, config) { } /** + * Add the given `keypair` (or the implicit config.keypair) to the given + * `identity`, authorized by the given `consent` (or implicitly created on the + * fly if the `keypair` contains the private key) with the following `powers` + * (defaulting to no powers). + * * @param {{ * identity: string; + * powers?: Array; + * _disobey?: true; * } & ({ * keypair: KeypairPublicSlice & {private?: never}; * consent: string; @@ -514,12 +567,13 @@ function initDB(peer, config) { * @param {CB} cb */ function addToIdentity(opts, cb) { + if (!opts) return cb(new Error('identity.add() requires an `opts`')) // prettier-ignore - if (!opts?.identity) return cb(new Error('identity.add() requires a `identity`')) + if (!opts.identity) return cb(new Error('identity.add() requires a `identity`')) // prettier-ignore - if (!opts?.keypair) return cb(new Error('identity.add() requires a `keypair`')) + if (!opts.keypair) return cb(new Error('identity.add() requires a `keypair`')) // prettier-ignore - if (!opts?.keypair.public) return cb(new Error('identity.add() requires a `keypair` with `public`')) + if (!opts.keypair.public) return cb(new Error('identity.add() requires a `keypair` with `public`')) let consent = /** @type {string} */ (opts.consent) if (typeof opts.consent === 'undefined') { if (opts.keypair.private) { @@ -528,6 +582,7 @@ function initDB(peer, config) { return cb(new Error('identity.add() requires a `consent`')) } } + const obeying = !opts._disobey const addedKeypair = opts.keypair const signingKeypair = config.keypair @@ -535,11 +590,36 @@ function initDB(peer, config) { const signableBuf = b4a.from( SIGNATURE_TAG_IDENTITY_ADD + base58.decode(opts.identity) ) - if (!Keypair.verify(addedKeypair, signableBuf, consent)) { + if (obeying && !Keypair.verify(addedKeypair, signableBuf, consent)) { // prettier-ignore return cb(new Error('identity.add() failed because the consent is invalid')) } + // Verify powers of the signingKeypair: + const identityTangle = new DBTangle(opts.identity, records()) + if (obeying) { + const signingPowers = getIdentityPowers(identityTangle, signingKeypair) + if (!signingPowers.has('add')) { + // prettier-ignore + return cb(new Error('identity.add() failed because the signing keypair does not have the "add" power')) + } + } + + // Verify input powers for the addedKeypair: + if (obeying && opts.powers) { + if (!Array.isArray(opts.powers)) { + // prettier-ignore + return cb(new Error('identity.add() failed because opts.powers is not an array')) + } + for (const power of opts.powers) { + if (power !== 'add' && power !== 'del' && power !== 'box') { + // prettier-ignore + return cb(new Error(`identity.add() failed because opts.powers contains an unknown power "${power}"`)) + } + // TODO check against duplicates + } + } + const identityRoot = get(opts.identity) if (!identityRoot) { // prettier-ignore @@ -558,12 +638,15 @@ function initDB(peer, config) { consent, }, } + if (opts.powers) data.add.powers = opts.powers // Fill-in tangle opts: const fullOpts = { identity: IDENTITY_SELF, identityTips: null, - tangles: populateTangles([opts.identity]), + tangles: { + [opts.identity]: identityTangle, + }, keypair: signingKeypair, data, domain: identityRoot.metadata.domain, diff --git a/lib/msg-v3/constants.js b/lib/msg-v3/constants.js index 078fbfa..11c4669 100644 --- a/lib/msg-v3/constants.js +++ b/lib/msg-v3/constants.js @@ -2,6 +2,9 @@ module.exports = { /** @type {'self'} */ IDENTITY_SELF: 'self', + /** @type {'any'} */ + IDENTITY_ANY: 'any', + SIGNATURE_TAG_MSG_V3: ':msg-v3:', SIGNATURE_TAG_IDENTITY_ADD: ':identity-add:', } diff --git a/lib/msg-v3/index.js b/lib/msg-v3/index.js index bcc7d4d..6b0bb53 100644 --- a/lib/msg-v3/index.js +++ b/lib/msg-v3/index.js @@ -17,7 +17,12 @@ const { validateMsgHash, } = require('./validation') const Tangle = require('./tangle') -const { IDENTITY_SELF, SIGNATURE_TAG_MSG_V3 } = require('./constants') +const { + IDENTITY_SELF, + IDENTITY_ANY, + SIGNATURE_TAG_MSG_V3, +} = require('./constants') +const { isEmptyObject } = require('./util') /** * @typedef {import('ppppp-keypair').Keypair} Keypair @@ -38,7 +43,7 @@ const { IDENTITY_SELF, SIGNATURE_TAG_MSG_V3 } = require('./constants') * metadata: { * dataHash: string | null; * dataSize: number; - * identity: string | (typeof IDENTITY_SELF) | null; + * identity: string | (typeof IDENTITY_SELF) | (typeof IDENTITY_ANY); * identityTips: Array | null; * tangles: Record; * domain: string; @@ -54,10 +59,13 @@ const { IDENTITY_SELF, SIGNATURE_TAG_MSG_V3 } = require('./constants') * action: 'del', del: IdentityDel * }} IdentityData * + * @typedef {'add' | 'del' | 'box'} IdentityPower + * * @typedef {{ * key: IdentityKey; * nonce?: string; * consent?: string; + * powers?: Array; * }} IdentityAdd * * @typedef {{ @@ -71,24 +79,18 @@ const { IDENTITY_SELF, SIGNATURE_TAG_MSG_V3 } = require('./constants') * }} SigKey * * @typedef {{ - * purpose: 'subidentity'; - * algorithm: 'tangle'; - * bytes: string; - * }} SubidentityKey; - * - * @typedef {{ * purpose: 'box'; * algorithm: 'x25519-xsalsa20-poly1305'; * bytes: string; * }} BoxKey; * - * @typedef {SigKey | SubidentityKey | BoxKey} IdentityKey + * @typedef {SigKey | BoxKey} IdentityKey * * @typedef {{ * data: any; * domain: string; * keypair: Keypair; - * identity: string | null; + * identity: string | (typeof IDENTITY_SELF) | (typeof IDENTITY_ANY); * identityTips: Array | null; * tangles: Record; * }} CreateOpts @@ -137,7 +139,7 @@ function create(opts) { if (!opts.tangles) throw new Error('opts.tangles is required') const [dataHash, dataSize] = representData(opts.data) - const identity = opts.identity ? stripIdentity(opts.identity) : null + const identity = opts.identity const identityTips = opts.identityTips ? opts.identityTips.sort() : null const tangles = /** @type {Msg['metadata']['tangles']} */ ({}) @@ -238,6 +240,7 @@ function createIdentity(keypair, domain, nonce = getRandomNonce) { bytes: keypair.public, }, nonce: typeof nonce === 'function' ? nonce() : nonce, + powers: ['add', 'del', 'box'], }, } @@ -268,6 +271,13 @@ function fromPlaintextBuffer(plaintextBuf, msg) { return { ...msg, data: JSON.parse(plaintextBuf.toString('utf-8')) } } +/** + * @param {Msg} msg + */ +function isRoot(msg) { + return isEmptyObject(msg.metadata.tangles) +} + module.exports = { getMsgHash, getMsgId, @@ -280,6 +290,7 @@ module.exports = { stripIdentity, toPlaintextBuffer, fromPlaintextBuffer, + isRoot, Tangle, validate, } diff --git a/lib/msg-v3/is-feed-root.js b/lib/msg-v3/is-feed-root.js index cd309ae..e8403f2 100644 --- a/lib/msg-v3/is-feed-root.js +++ b/lib/msg-v3/is-feed-root.js @@ -1,19 +1,10 @@ const { stripIdentity } = require('./strip') +const { isEmptyObject } = require('./util') /** * @typedef {import('.').Msg} Msg */ -/** - * @param {any} obj - */ -function isEmptyObject(obj) { - for (const _key in obj) { - return false - } - return true -} - /** * @param {Msg} msg * @param {string | 0} id diff --git a/lib/msg-v3/util.js b/lib/msg-v3/util.js new file mode 100644 index 0000000..78e8a48 --- /dev/null +++ b/lib/msg-v3/util.js @@ -0,0 +1,14 @@ + +/** + * @param {any} obj + */ +function isEmptyObject(obj) { + for (const _key in obj) { + return false + } + return true +} + +module.exports = { + isEmptyObject, +} \ No newline at end of file diff --git a/protospec.md b/protospec.md index 4847a2c..95def84 100644 --- a/protospec.md +++ b/protospec.md @@ -24,7 +24,7 @@ interface Msg { metadata: { dataHash: ContentHash | null // blake3 hash of the `content` object serialized dataSize: number // byte size (unsigned integer) of the `content` object serialized - identity: string | 'self' | null // blake3 hash of an identity tangle root msg, or the string 'self', or null + identity: string | 'self' | 'any' // blake3 hash of an identity tangle root msg, or the string 'self', or 'any' identityTips: Array | null // list of blake3 hashes of identity tangle tips, or null tangles: { // for each tangle this msg belongs to, identified by the tangle's root @@ -67,13 +67,19 @@ interface Msg { } type IdentityData = - | { action: 'add' add: IdentityAdd } - | { action: 'del' del: IdentityDel } + | { action: 'add', add: IdentityAdd } + | { action: 'del', del: IdentityDel } + +// "add" means this keypair can validly add more keypairs to the identity tangle +// "del" means this keypair can validly revoke other keypairs from the identity +// "box" means the peer with this keypair should get access to the box keypair +type IdentityPower = 'add' | 'del' | 'box' type IdentityAdd = { key: Key nonce?: string // nonce required only on the identity tangle's root consent?: string // base58 encoded signature of the string `:identity-add:` where `` is the identity's ID, required only on non-root msgs + identityPowers?: Array // list of powers granted to this key, defaults to [] } type IdentityDel = { @@ -84,10 +90,9 @@ type Key = | { purpose: 'sig' // digital signatures algorithm: 'ed25519' // libsodium crypto_sign_detached - bytes: string // base58 encoded string for the public key being added + bytes: string // base58 encoded string for the public key } | { - // WIP!! purpose: 'box' // asymmetric encryption algorithm: 'x25519-xsalsa20-poly1305' // libsodium crypto_box_easy bytes: string // base58 encoded string of the public key diff --git a/test/identity-add.test.js b/test/identity-add.test.js index be1da73..4859b49 100644 --- a/test/identity-add.test.js +++ b/test/identity-add.test.js @@ -34,6 +34,7 @@ test('identity.add()', async (t) => { identity: id, keypair: keypair2, consent, + powers: ['box'], }) assert.ok(identityRec1, 'identityRec1 exists') const { hash, msg } = identityRec1 @@ -49,6 +50,7 @@ test('identity.add()', async (t) => { bytes: keypair2.public, }, consent, + powers: ['box'], }, }, 'msg.data.add NEW KEY' @@ -68,6 +70,86 @@ test('identity.add()', async (t) => { await p(peer.close)() }) +test('keypair with no "add" powers cannot identity.add()', async (t) => { + rimraf.sync(DIR) + const keypair1 = Keypair.generate('ed25519', 'alice') + const keypair2 = Keypair.generate('ed25519', 'bob') + const keypair3 = Keypair.generate('ed25519', 'carol') + + const peer1 = SecretStack({ appKey: caps.shse }) + .use(require('../lib')) + .use(require('ssb-box')) + .call(null, { keypair: keypair1, path: DIR }) + + await peer1.db.loaded() + const id = await p(peer1.db.identity.create)({ + keypair: keypair1, + domain: 'account', + }) + const msg1 = peer1.db.get(id) + + const { msg: msg2 } = await p(peer1.db.identity.add)({ + identity: id, + keypair: keypair2, + powers: [], + }) + assert.equal(msg2.data.add.key.bytes, keypair2.public) + + assert.equal(peer1.db.identity.has({ identity: id, keypair: keypair2 }), true) + + await p(peer1.close)() + rimraf.sync(DIR) + + const peer2 = SecretStack({ appKey: caps.shse }) + .use(require('../lib')) + .use(require('ssb-box')) + .call(null, { keypair: keypair2, path: DIR }) + + await peer2.db.loaded() + await p(peer2.db.add)(msg1, id) + await p(peer2.db.add)(msg2, id) + + // Test author-side power validation + assert.rejects( + p(peer2.db.identity.add)({ + identity: id, + keypair: keypair3, + powers: [], + }), + /signing keypair does not have the "add" power/ + ) + + // Make the author disobey power validation + const { msg: msg3 } = await p(peer2.db.identity.add)({ + identity: id, + keypair: keypair3, + powers: [], + _disobey: true, + }) + + assert.equal(msg3.data.add.key.bytes, keypair3.public) + + await p(peer2.close)() + rimraf.sync(DIR) + + const peer1again = SecretStack({ appKey: caps.shse }) + .use(require('../lib')) + .use(require('ssb-box')) + .call(null, { keypair: keypair1, path: DIR }) + + await peer1again.db.loaded() + await p(peer1again.db.add)(msg1, id) // re-add because lost during rimraf + await p(peer1again.db.add)(msg2, id) // re-add because lost during rimraf + + // Test replicator-side power validation + assert.rejects( + p(peer1again.db.add)(msg3, id), + /msg\.pubkey does not have "add" power/ + ) + + await p(peer1again.close)() +}) + test('publish with a key in the identity', async (t) => { rimraf.sync(DIR) diff --git a/test/identity-create.test.js b/test/identity-create.test.js index a8bcd69..67578d5 100644 --- a/test/identity-create.test.js +++ b/test/identity-create.test.js @@ -36,6 +36,7 @@ test('identity.create() with just "domain"', async (t) => { bytes: keypair.public, }, nonce: 'MYNONCE', + powers: ['add', 'del', 'box'], }, }, 'msg.data.add' diff --git a/test/msg-v3/create.test.js b/test/msg-v3/create.test.js index 2e19b52..50b456b 100644 --- a/test/msg-v3/create.test.js +++ b/test/msg-v3/create.test.js @@ -21,12 +21,13 @@ test('MsgV3.createIdentity()', (t) => { bytes: keypair.public, }, nonce: 'MYNONCE', + powers: ['add', 'del', 'box'], }, }, 'data' ) - assert.equal(identityMsg0.metadata.dataHash, 'C9XZXiZV4kxD6MtVqNkuBw', 'hash') - assert.equal(identityMsg0.metadata.dataSize, 143, 'size') + assert.equal(identityMsg0.metadata.dataHash, 'R5az9nC1CB3Afd5Q57HYRQ', 'hash') + assert.equal(identityMsg0.metadata.dataSize, 172, 'size') assert.equal(identityMsg0.metadata.identity, 'self', 'identity') assert.equal(identityMsg0.metadata.identityTips, null, 'identityTips') assert.deepEqual(identityMsg0.metadata.tangles, {}, 'tangles') @@ -35,7 +36,7 @@ test('MsgV3.createIdentity()', (t) => { assert.equal(identityMsg0.pubkey, keypair.public, 'pubkey') identity = MsgV3.getMsgHash(identityMsg0) - assert.equal(identity, 'NwZbYERYSrShDwcKrrLVYe', 'identity ID') + assert.equal(identity, 'GZJ1T864pFVHKJ2mRS2c5q', 'identity ID') }) let rootMsg = null @@ -57,7 +58,7 @@ test('MsgV3.createRoot()', (t) => { assert.equal(rootMsg.pubkey, keypair.public, 'pubkey') rootHash = MsgV3.getMsgHash(rootMsg) - assert.equal(rootHash, '2yqmqJLffAeomD93izwjx9', 'root hash') + assert.equal(rootHash, '4VfVj9DQArX5Vk6PVz5s5J', 'root hash') }) test('MsgV3.create()', (t) => { @@ -125,11 +126,11 @@ test('MsgV3.create()', (t) => { ) assert.equal( msg1.sig, - '2GsXePCkaJk1emgjRmwTrn9qqA5GozG8BrDa9je4SeCNJX8KVYr45MyZGmfkJsGBoMocZCMhP4jiFgdL1PqURhx6', + '23CPZzKBAeRa6gb2ijwUJAd4VrYmokLSbQTmWEFMCiSogjViwqvms6ShyPq1UCzNWKAggmmJP4qETnVrY4iEMQ5J', 'sig' ) - const msgHash1 = 'BTybPnRjVjVZMdWBr52kfz' + const msgHash1 = 'kF6XHyi1LtJdttRDp54VM' assert.equal( MsgV3.getMsgId(msg1), @@ -201,13 +202,13 @@ test('MsgV3.create()', (t) => { ) assert.equal( msg2.sig, - 'EA8PUp44ZBdgsXa4DRioejc4W5NMapoA9oUbgJwQSsvKDj9nh3oAHn7ScnZo2Hfw67JzHeZXeXVwgLCWzsKCFYw', + 'tpMaMqV7t4hhYtLPZu7nFmUZej3pXVAYWf3pwXChThsQ8qT9Zxxym2TDDTUrT9VF7CNXRnLNoLMgYuZKAQrZ5bR', 'sig' ) assert.deepEqual( MsgV3.getMsgId(msg2), - `ppppp:message/v3/${identity}/post/3bwFna3PvkSNxssPd9QDYe`, + `ppppp:message/v3/${identity}/post/7W2nJCdpMeco7D8BYvRq7A`, 'getMsgId' ) }) diff --git a/test/msg-v3/validate.test.js b/test/msg-v3/validate.test.js index 00abad6..a49fbd1 100644 --- a/test/msg-v3/validate.test.js +++ b/test/msg-v3/validate.test.js @@ -43,7 +43,7 @@ test('validate identity tangle', (t) => { const keypair2 = Keypair.generate('ed25519', 'bob') const identityMsg1 = MsgV3.create({ - identity: null, + identity: 'self', identityTips: null, domain: 'identity', data: { add: keypair2.public },