UNPKG

@yipsec/rise

Version:

eclipse-resistant network identities

667 lines (576 loc) 17.9 kB
/** * Rise is a protocol for decentalized, eclipse-resistant identities. * @module rise * @author chihuahua.rodeo <161chihuahuas@disroot.org> * @license LGPL-2.1 */ 'use strict'; const crypto = require('node:crypto'); const bip39 = require('bip39'); const ecies = require('eciesjs'); const { secp256k1: secp } = require('@noble/curves/secp256k1'); const equihash = require('@yipsec/equihash'); function _hash(alg, input) { return crypto.createHash(alg).update(input).digest(); } function sha256(input) { return _hash('sha256', input); } function rmd160(input) { return _hash('rmd160', input); } class RiseIdentity { /** * Default difficuly setting. Require this many leading zeroes in solution * proofs. */ static get Z() { return 6; } /** * Lowered difficulty for testing. */ static get TEST_Z() { return 0; } /** * Equihash N parameter (width in bits). */ static get N() { return 102; } /** * Lowered width for testing. */ static get TEST_N() { return 90; } /** * Equihash K parameter (length). */ static get K() { return 5; } /** * Lowered length for testing. */ static get TEST_K() { return 5; } /** * Rise magic number. Used as message terminator and protocol identitifier. */ static get MAGIC() { return sha256(Buffer.from('¬«', 'binary')); } /** * Magic number to segment test network. */ static get TEST_MAGIC() { return sha256(Buffer.from('¡', 'binary')); } /** * Rise default salt for pbkdf2 operations. */ static get SALT() { return Buffer.from('\fæ×"\x94ì9\x82BW(i<ªþ`', 'binary'); } /** * Rise *private* identity bundle. This is the primary interface for using * this module. Allows to generate new identities and use them as the * context for protected operations. * @constructor * @param {Uint8Array|buffer} [entropy] - Private key (secp256k1). If absent * a new one will be created. * @param {RiseSolution} [solution] - Equihash solution corresponding to the * given private key. * @param {Uint8Array|buffer} [salt=module:rise~RiseSolution~SALT] - Salt used for local * pbkdf2 operations locking/unlocking this identity. */ constructor(entropy, solution, salt = RiseIdentity.SALT) { entropy = entropy || secp.utils.randomPrivateKey(); /** * Used for local PBKDF2. * @member {Uint8Array|buffer} */ this.salt = salt; /** * BIP39 recovery words. * @member {string} */ this.mnemonic = bip39.entropyToMnemonic(entropy); /** * Underlying secret key. * @member {module:rise~RiseSecret} */ this.secret = new RiseSecret(entropy); /** * Underlying equihash solution. * @member {module:rise~RiseSolution} */ this.solution = solution || {}; } /** * 160 bit solution hash. * @member {buffer} */ get fingerprint() { return this.solution.fingerprint; } /** * Creates a new {@link RiseSolution} for this identity. This method updates * the internal state and will overwrite any previous solution performed in * this context. * @param {number} [n=module:rise~RiseIdentity~N] - Width in bits. * @param {number} [k=module:rise~RiseIdentity~K] - Solution length. * @param {buffer} [epoch=module:rise~RiseIdentity~MAGIC] - Prepended to public key before * hashing. This can be used to segment protocol versions by changing this value * which would render solutions generated with a previous or otherwise different * value invalid and require a new solution. * @returns {Promise<module:rise~RiseSolution>} */ solve(n = RiseIdentity.N, k = RiseIdentity.K, epoch = RiseIdentity.MAGIC) { return new Promise((resolve, reject) => { equihash.solve(sha256( Buffer.concat([epoch, this.secret.publicKey]) ), n, k).then(solution => { this.solution = new RiseSolution(solution.proof, solution.nonce, this.secret.publicKey, epoch); resolve(this.solution); }, reject); }); } /** * Returns a plain object representation of this identity, serializable to JSON. * @returns {object} */ toJSON() { return { secret: Buffer.from(this.secret.privateKey).toString('base64'), salt: this.salt.toString('base64'), version: require('./package.json').version, solution: this.solution ? this.solution.toJSON() : {} }; } /** * Creates an encrypted blob representation of this identity, suitable for * persistance to disk. * @param {string} password - User provided passphrase used to encrypt this * identity. * @returns {buffer} */ lock(password) { const key = crypto.pbkdf2Sync(password, this.salt, 100000, 32, 'sha512'); const iv = key.subarray(0, 16); const cipher = crypto.createCipheriv('aes-256-cbc', key, iv); const encryptedData = Buffer.concat([ cipher.update(JSON.stringify(this.toJSON())), cipher.final() ]); return encryptedData; } /** * Constructs an encrypted and signed {@link module:rise~RiseMessage} for the given * public key. * @param {Uint8Array|buffer} toPublicKey - Recipient identity for encryption. * @param {Object.<string, string>} [body] - Key-value pairs to serialize in the * message. * @param {Object.<string, string>} [head] - Custom headers to include. **Headers * are NOT ENCRYPTED**. Only information that is necessary for routing should be * included here. * @returns {module:rise~SignedRiseMessage} */ message(toPublicKey, body = {}, head = {}) { const clearMsg = new RiseMessage(this.solution, body, head); const cryptMsg = clearMsg.encrypt(toPublicKey); return cryptMsg.sign(this.secret.privateKey); } /** * Decrypts the blob given the password and creates a new instance. * @param {string} password - User supplied passphrase for decryption. * @param {buffer} data - Binary blob of encrypted identity. * @param {buffer} [salt=module:rise~RiseIdentity~SALT] - Salt for pbkdf2. * @returns {modulex:rise~RiseIdentity} */ static unlock(password, data, salt = RiseIdentity.SALT) { const key = crypto.pbkdf2Sync(password, salt, 100000, 32, 'sha512'); const iv = key.subarray(0, 16); const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv); const decryptedData = Buffer.concat([ decipher.update(data), decipher.final() ]); const json = JSON.parse(decryptedData.toString()); return new RiseIdentity( Buffer.from(json.secret, 'base64'), RiseSolution.fromJSON(json.solution), Buffer.from(json.salt, 'base64') ); } /** * "Mines" a new {@link module:rise~RiseIdentity} and *iteratively* generates * {@link module:rise~RiseSolution}s until one is found that satisfies the stated * difficulty. * @param {number} [zeroes=module:rise~RiseIdentity~Z] - Difficulty level expressed in * number of leading zero bits. * @param {number} [n=module:rise~RiseIdentity~N] - Width in bits. * @param {number} [k=module:rise~RiseIdentity~K] - Solution length. * @param {buffer} [epoch=module:rise~RiseIdentity~MAGIC] - Network magic number. * @returns {module:rise~RiseIdentity} */ static generate(zeroes = RiseIdentity.Z, n, k, epoch) { return new Promise(async (resolve, reject) => { let id, sol; do { id = new RiseIdentity(); try { sol = await id.solve(n, k, epoch); } catch (e) { return reject(e); } } while (sol.difficulty < zeroes) resolve(id); }); } } class RiseSecret { /** * Interface for secp256k1 key pair. If no secret is provided, one will be * generated. * @constructor * @param {Uint8Array|buffer} [secret] - Private key to use. */ constructor(secret) { /** * Underlying private key. * @member {Uint8Array} */ this.privateKey = secret ? Uint8Array.from(secret) : secp.utils.randomPrivateKey(); } /** * Public key derived from private key. * @member {Uint8Array} */ get publicKey() { return secp.getPublicKey(this.privateKey); } /** * Decrypts the given data using the underlying private key. * @param {Uint8Array|buffer} message - Encrypted blob. * @returns {Uint8Array} */ decrypt(message) { return ecies.decrypt(this.privateKey, message); } /** * Creates a digital signature from the provided data. * @param {Uint8Array|buffer} message - Binary blob to sign. * @returns {string} hexSignature */ sign(message) { const buf = message; const msg = sha256(buf); const sig = secp.sign(msg, this.privateKey); return sig.toCompactHex(); } } class RiseMessage { /** * Protocol headers included in every rise message. * @typedef {object} module:rise~RiseMessage~Head * @prop {string} nonce - One-time token to prevent replay attacks. * @prop {string} version - Version of the rise package. Analagous to user agent. * @prop {boolean} ciphertext - Indicates if the message body should be treated * as ciphertext (it is encrypted). * @prop {module:rise~RiseSolution} solution - Sender authentication data. */ /** * Interface allowing for authenticated message exchange. * @constructor * @param {module:rise~RiseSolution} solution - Identity solution data. * @param {Object.<string, string>} [body] - Key-value data to include. * @param {Object.<string, string>} [head] - Additional headers. */ constructor(solution, body = {}, headers = {}) { /** * Default message headers, plus any custom ones supplied. * @member {module:rise~RiseMessage~Head} */ this.head = { nonce: Date.now() + '~' + crypto.randomBytes(8).toString('base64'), version: require('./package.json').version, ciphertext: false, solution, ...headers }; /** * Key-value pairs given for the message. * @member {Object.<string, string>|string} */ this.body = Buffer.isBuffer(body) ? body.toString('base64') : body; } /** * Encrypts the message state for the public key provided. * @param {Uint8Array|buffer} publicKey - Recipient public key. * @returns {module:rise~EncryptedRiseMessage} */ encrypt(publicKey) { const body = this.head.ciphertext ? this.body : JSON.stringify(this.body); return new EncryptedRiseMessage(this.head.solution, ecies.encrypt(publicKey, body), this.head); } /** * Signs the message state using the private key provided. * @param {Uint8Array|buffer} privateKey - Identity to use for signature. * @returns {module:rise~SignedRiseMessage} */ sign(privateKey) { const secret = new RiseSecret(privateKey); const buf = this.toBuffer(); const sig = secret.sign(buf); const msg = new SignedRiseMessage(this.head.solution, this.body, { ...this.head, signature: sig }); return msg; } /** * Returns only the body of this message. * @returns {Object.<string, string|module:rise~RiseMessage|module:rise~EncryptedRiseMessage|module:rise~SignedRiseMessage|string>} */ unwrap() { return this.body; } /** * Ensures the solution header is valid. * @returns {boolean} */ validate() { return this.head.solution.verify(...arguments); } /** * Serializes the message to wire format. * @returns {buffer} */ toBuffer() { const magicStr = RiseIdentity.MAGIC.toString('hex'); const head = JSON.stringify(this.head); const body = this.head.ciphertext ? this.body : JSON.stringify(this.body); const str = [head, body].join(magicStr); return Buffer.from(str); } /** * Creates a new message instance from the serialized message. * @param {buffer} message - Binary blob to deserialize. * @returns {module:rise~RiseMessage|module:rise~EncryptedRiseMessage|module:rise~SignedRiseMessage} */ static fromBuffer(buffer) { const str = buffer.toString(); const magicStr = RiseIdentity.MAGIC.toString('hex'); const [rawHead, rawBody] = str.split(magicStr); const head = JSON.parse(rawHead); const body = head.ciphertext ? rawBody : JSON.parse(rawBody); head.solution = RiseSolution.fromJSON(head.solution); if (head.signature && head.ciphertext) { return new SignedRiseMessage(head.solution, body, head); } if (head.ciphertext) { return new EncryptedRiseMessage(head.solution, body, head); } return new RiseMessage(head.solution, body, head); } } class EncryptedRiseMessage extends RiseMessage { /** * Interface for an encrypted message. * @constructor * @extends {module:rise~RiseMessage} */ constructor() { super(...arguments); this.head.ciphertext = true; } /** * Decrypts the message using the supplied private key. * @param {Uint8Array|buffer} privateKey - Private key to use. * @returns {module:rise~RiseMessage} */ decrypt(privateKey) { const secret = new RiseSecret(privateKey); let body = JSON.parse( Buffer.from(secret.decrypt(Buffer.from(this.body, 'base64'))) .toString()); if (body.head && body.body) { let { proof, nonce, epoch, pubkey } = body.head.solution; const sol = new RiseSolution( Buffer.from(proof, 'base64'), parseInt(nonce), Buffer.from(pubkey, 'base64'), Buffer.from(epoch, 'base64') ); body.head.solution = sol; if (body.head.signature && body.head.ciphertext) { body = new SignedRiseMessage(sol, body.body, body.head); } else if (body.head.ciphertext) { body = new EncryptedRiseMessage(sol, body.body, body.head); } else { body = new RiseMessage(sol, body.body, body.head); } } return new RiseMessage(this.head.solution, body, this.head); } } class SignedRiseMessage extends EncryptedRiseMessage { /** * Interface for digitall signed rise message * @constructor * @extends {module:rise~EncryptedRiseMessage} */ constructor() { super(...arguments); } /** * Ensures that the digita signature is valid. * @returns {boolean} */ verify() { const sig = this.head.signature; delete this.head.signature; const buf = this.toBuffer(); const msg = sha256(buf); const pub = this.head.solution.pubkey; const result = secp.verify(sig, msg, pub); this.head.signature = sig; return result; } } class RiseSolution { /** * Interface for identity solutions. * @param {buffer} proof - Equihash proof value. * @param {number} nonce - Solution nonce. * @param {buffer} pubkey - Public key solution was seeded from. * @param {buffer} epoch - Magic network number prepended to public key. */ constructor(proof, nonce, pubkey, epoch = RiseIdentity.MAGIC) { /** * Equihash proof. * @member {buffer} */ this.proof = proof; /** * Solution nonce. * @member {nuber} */ this.nonce = nonce; /** * Network magic number. * @member {buffer} */ this.epoch = epoch; /** * Public key. * @member {buffer} */ this.pubkey = Buffer.from(pubkey); } /** * RIPEMD-160 hash for SHA-256 hash of serialized solution. * @member {buffer} */ get fingerprint() { return rmd160(sha256(Buffer.from(JSON.stringify(this.toJSON())))); } /** * Number of leading zeroes in the proof. * @member {number} */ get difficulty() { const binStr = this.getProofAsBinaryString(); for (let i = 0; i < binStr.length; i++) { if (binStr[i] !== '0') { return i; } } return binStr.length; } /** * Serilaizes the solution into a JSON object. * @returns {Object.<string, string>} */ toJSON() { return { proof: this.proof.toString('base64'), nonce: this.nonce.toString(), pubkey: this.pubkey.toString('base64'), epoch: this.epoch.toString('base64') }; } /** * Constructs a {@link RiseSolution} from a JSON object. * @param {Object.<string, string>} json - Serialized solution. * @returns {module:rise~RiseSolution} */ static fromJSON(json) { return new RiseSolution(Buffer.from(json.proof, 'base64'), parseInt(json.nonce), Buffer.from(json.pubkey, 'base64'), Buffer.from(json.epoch, 'base64')); } /** * Ensures that the solution is valid. * @param {number} [n=RiseIdentity.N] - Width in bits. * @param {number} [k=RiseIdentity.K] - Proof length. * @returns {Promise<boolean>} */ verify(n = RiseIdentity.N, k = RiseIdentity.K) { return equihash.verify(sha256(Buffer.concat([this.epoch, this.pubkey])), this.proof, this.nonce, n, k); } /** * Represents the proof as a string of 1's and 0's. * @returns {string} */ getProofAsBinaryString() { const mapping = { '0': '0000', '1': '0001', '2': '0010', '3': '0011', '4': '0100', '5': '0101', '6': '0110', '7': '0111', '8': '1000', '9': '1001', 'a': '1010', 'b': '1011', 'c': '1100', 'd': '1101', 'e': '1110', 'f': '1111' }; const hexaString = this.proof.toString('hex').toLowerCase(); const bitmaps = []; for (let i = 0; i < hexaString.length; i++) { bitmaps.push(mapping[hexaString[i]]); } return bitmaps.join(''); } } module.exports.Secret = RiseSecret; module.exports.Message = RiseMessage; module.exports.EncryptedMessage = EncryptedRiseMessage; module.exports.SignedMessage = SignedRiseMessage; module.exports.Identity = RiseIdentity; module.exports.Solution = RiseSolution;