Skip to content

Instantly share code, notes, and snippets.

@Bluebie
Created November 2, 2019 09:47
Show Gist options
  • Star 2 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save Bluebie/62269ea6b7f3bc64fc5544244b803c5c to your computer and use it in GitHub Desktop.
Save Bluebie/62269ea6b7f3bc64fc5544244b803c5c to your computer and use it in GitHub Desktop.
Partially encrypted account model for IPFS app
// 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