Created
November 2, 2019 09:47
-
-
Save Bluebie/62269ea6b7f3bc64fc5544244b803c5c to your computer and use it in GitHub Desktop.
Partially encrypted account model for IPFS app
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// Model to represent an account in the distributed database | |
// Accounts are mainly a place users can store information like their private keys, in a password protected | |
// vault, so they can login conveniently from other devices and keep hold of their private keys and record | |
// what blogs they are authors of. It's also a way for other users to lookup an authors public keys to validate | |
// their other objects when determining if a new version of some data really belongs to the blog it claims to be | |
// related to. | |
const nacl = require('tweetnacl') | |
const cbor = require('borc') | |
class HSAccount { | |
constructor(ipfs, settings = {}) { | |
this.ipfs = ipfs | |
// the public settings object, includes anything that isn't secret, like info on how this data is encrypted | |
this.settings = Object.assign({ | |
cypher: 'nacl.secretbox(1)', | |
hashRounds: 1000000, | |
signKey: null, // nacl.sign public key | |
boxKey: null, // nacl.box public key | |
nonce: null | |
}, settings) | |
// the private data, this will always be encrypted when stored to the network | |
this.private = { | |
email: '', | |
signKey: null, // a buffer containing nacl.sign private key | |
boxKey: null, // a buffer containing nacl.box private key | |
blogs: [], // an array of blogs (as CID strings) this account should have write access to | |
following: [], // an array of blogs (as CID strings) being followed by this account | |
blogKeys: {}, // an object, indexed by blog ID string, containing signing keys for blog publish updates | |
} | |
this.auth = null | |
} | |
// hash a value with a number of rounds specified in the hashRounds setting | |
recursiveHash(inputBuffer, rounds = 0) { | |
let value = inputBuffer | |
if (rounds < 1) throw new Error("hashRounds setting must be above 0") | |
for (let i = 0; i < rounds; i++) { | |
value = nacl.hash(value) | |
} | |
return value | |
} | |
// generate a new set of account keys | |
generateKeys() { | |
if (this.settings.signKey || this.settings.boxKey) throw new Error("Account already has keys!") | |
let signKeyPair = nacl.sign.keyPair() | |
this.settings.signKey = Buffer.from(signKeyPair.publicKey) | |
this.private.signKey = Buffer.from(signKeyPair.secretKey) | |
let boxKeyPair = nacl.box.keyPair() | |
this.settings.boxKey = Buffer.from(boxKeyPair.publicKey) | |
this.private.boxKey = Buffer.from(boxKeyPair.secretKey) | |
} | |
// set the email and password | |
authenticate(email, password) { | |
if (`${password}`.length < 8) { | |
throw new Error("Password must be at least 8 characters long") | |
} | |
this.auth = { | |
email, password, | |
key: this.recursiveHash(cbor.encode([email, password]), this.settings.hashRounds).slice(0, nacl.secretbox.keyLength) | |
} | |
return this | |
} | |
// create an account, which could be new, or could already exist | |
async publish() { | |
if (!this.auth) throw new Error("@authenticate must be called first") | |
// generate a new nonce | |
this.settings.nonce = Buffer.from(nacl.randomBytes(nacl.secretbox.nonceLength)) | |
// store email to private data | |
this.private.email = this.auth.email | |
// make sure account has keys, they're required | |
if (!this.settings.signKey) this.generateKeys() | |
// upload the resulting account to ipfs | |
this.cid = await this.ipfs.dag.put({ | |
type: this.constructor.name, | |
timestamp: Date.now(), | |
id: this.id = this.accountID(this.auth.email), | |
settings: this.settings, | |
private: Buffer.from(nacl.secretbox(cbor.encode(this.private), this.settings.nonce, this.auth.key)) | |
}) | |
return this.cid | |
} | |
// generate an account id hash, just a hashed "email" | |
accountID(email) { | |
return Buffer.from(nacl.hash(cbor.encode(email.toLowerCase()))) | |
} | |
// attempt to read an account without unlocking it (not logging in to it, just viewing it's public data) | |
// If an email and password is provided, will attempt to unlock account private data | |
async open(cid) { | |
// fetch the record from the IPFS DAG | |
let record = (await this.ipfs.dag.get(cid)).value | |
// validate the record looks like a safe and reasonable account record | |
if (typeof(record) != 'object') | |
throw new Error("DAG record doesn't contain an object") | |
if (record.type != this.constructor.name) | |
throw new Error("DAG record doesn't appear to be an account") | |
if (!record.id || !record.settings || !record.private) | |
throw new Error("DAG record doesn't look like an account record") | |
if (record.settings.cypher != this.settings.cypher) | |
throw new Error("Unsupported cypher") | |
if (record.settings.nonce.byteLength != nacl.secretbox.nonceLength) | |
throw new Error("Nonce length incorrect") | |
if (typeof(record.settings.hashRounds) != 'number' || record.settings.hashRounds < 1) | |
throw new Error("Hash Rounds in record isn't a number above 1! Invalid") | |
if (this.auth) { | |
if (!Buffer.from(record.id).equals(this.accountID(this.auth.email))) { | |
throw new Error("Account object doesn't use same email address") | |
} | |
// attempt to decrypt account record | |
let decrypted = nacl.secretbox.open(record.private, record.settings.nonce, this.auth.key) | |
if (decrypted) { | |
let privateData = cbor.decode(decrypted) | |
this.private = privateData | |
} else { | |
throw new Error("Failed to decrypt account. Password incorrect?") | |
} | |
return this | |
} | |
// if we got this far, looks like we were successful! | |
this.settings = record.settings | |
this.id = record.id | |
this.cid = cid | |
return this | |
} | |
} | |
module.exports = HSAccount |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment