implement powers in identity.add()

This commit is contained in:
Andre Staltz 2023-08-08 15:31:23 +03:00
parent e6aa33d3f0
commit 557ea2252c
No known key found for this signature in database
GPG Key ID: 9EDE23EA7E8A4890
10 changed files with 234 additions and 43 deletions

View File

@ -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<IdentityPower>}
*/
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<IdentityPower>;
* _disobey?: true;
* } & ({
* keypair: KeypairPublicSlice & {private?: never};
* consent: string;
@ -514,12 +567,13 @@ function initDB(peer, config) {
* @param {CB<Rec>} 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,

View File

@ -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:',
}

View File

@ -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<string> | null;
* tangles: Record<string, TangleMetadata>;
* 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<IdentityPower>;
* }} 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<string> | null;
* tangles: Record<string, Tangle>;
* }} 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,
}

View File

@ -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

14
lib/msg-v3/util.js Normal file
View File

@ -0,0 +1,14 @@
/**
* @param {any} obj
*/
function isEmptyObject(obj) {
for (const _key in obj) {
return false
}
return true
}
module.exports = {
isEmptyObject,
}

View File

@ -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<string> | 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:<ID>` where `<ID>` is the identity's ID, required only on non-root msgs
identityPowers?: Array<IdentityPower> // 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

View File

@ -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)

View File

@ -36,6 +36,7 @@ test('identity.create() with just "domain"', async (t) => {
bytes: keypair.public,
},
nonce: 'MYNONCE',
powers: ['add', 'del', 'box'],
},
},
'msg.data.add'

View File

@ -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'
)
})

View File

@ -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 },