const dataparty_crypto = require('@dataparty/crypto')
const debug = require('debug')('dataparty.config.secure-config')
const deepSet = require('lodash').set
const reach = require('../utils/reach')
const IConfig = require('./iconfig')
const PASSWORD_HASHING_ROUNDS = 1000000
const DEFAULT_TIMEOUT_MS = 5*60*1000 //! 5min
const ARGON_TIME_COST = 3
const ARGON_MEMORY_COST = 65536
const ARGON_PARALLELLISM = 4
const ARGON_TYPE = 'argon2id'
class SecureConfig extends IConfig {
/**
* A secure configuration. This uses an underlying `IConfig` for storage. Multiple
* secure configs can be placed within the same `IConfig` so long as different `id`
* is set. By default `pbkdf2` is used to generate NaCl keys. If `argon` is provided
* then `argon2` will be used to generate the NaCl keys. Applications should
* use `argon2`.
* @class module:Config.SecureConfig
* @implements {module:Config.SecureConfig}
* @link module.Config
* @param {string} id The id of this secure config. Multiple secure configs can be stored within a single `IConfig`
* @param {IConfig} config The underlying IConfig to use for storage
* @param {number} timeoutMs Timeout since last unlock, after which the config will be locked. Defaults to 5 minutes.
* @param {boolean} includeActivity When set to `true` the timeout is reset after any read/write activity. Defaults to `true`
* @param {Argon2} argon Instance of argon2 from either `npm:argon2` or `npm:argon2-browser`
* @see https://github.com/ranisalt/node-argon2
* @see https://github.com/antelle/argon2-browser
*/
constructor({
id = 'secure-config',
config, timeoutMs=DEFAULT_TIMEOUT_MS, includeActivity=true,
argon
}){
super()
this.id = id || 'secure-config'
this.config = config
this.argon = argon
if(!this.argon){
console.warn('Warning - PBKDF2 based secure config. You should probably use argon2!')
}
this.content = null
this.identity = null
this.timer = null
this.lastActivity = null
this.timeoutMs = timeoutMs || DEFAULT_TIMEOUT_MS
this.includeActivity = (typeof includeActivity === 'boolean') ? includeActivity : true
this.blocked = false
}
/**
* Start the secure storage
* @fires module:Config.SecureConfig#setup-required
* @fires module:Config.SecureConfig#ready
* @method module:Config.SecureConfig.start
* @async
*/
async start(){
await this.config.start()
const isReady = await this.isInitialized()
if(!isReady){
/**
* Setup required event. The secure config has not yet has a password
* or key configured.
* @event module:Config.SecureConfig#setup-required
*/
this.emit('setup-required')
} else {
/**
* Ready event. The secure config is ready to be unlocked and have
* configuration values read or written.
* @event module:Config.SecureConfig#ready
*/
this.emit('ready')
}
}
/**
* Checks if the secure config has initialized with a password or key
* @method module:Config.SecureConfig.isInitialized
* @returns {boolean}
* @async
*/
async isInitialized(){
let keyType = await this.config.read(this.id+'.settings.type')
if(keyType == 'pbkdf2'){
let salt = await this.config.read(this.id+'.settings.salt')
let rounds = await this.config.read(this.id+'.settings.rounds')
return (salt != undefined && salt.length > 16 && rounds > 100000)
} else if(keyType == 'argon2'){
let salt = await this.config.read(this.id+'.settings.salt')
let timeCost = await this.config.read(this.id+'.settings.timeCost')
let memoryCost = await this.config.read(this.id+'.settings.memoryCost')
let parallelism = await this.config.read(this.id+'.settings.parallelism')
let argonType = await this.config.read(this.id+'.settings.argonType')
return (salt != undefined && salt.length > 16 && memoryCost > 1024)
} else if(keyType == 'key'){
return true
} else if(!keyType) {
return false
} else {
debug('type', keyType)
throw new Error('unexpected key type')
}
}
/**
* Checks if the secure config is locked. If not locked the secure config
* can be used without blocking waiting for user to unlock.
* @method module:Config.SecureConfig.isLocked
* @returns {boolean}
*/
isLocked(){
return this.content == null || this.identity == null
}
/**
* Initialize the secure config with a password
* @fires module:Config.SecureConfig#intialized
* @fires module:Config.SecureConfig#ready
* @method module:Config.SecureConfig.setPassword
* @param {string} password
* @param {object} defaults
* @async
*/
async setPassword(password, defaults={}){
debug('setPassword')
if(await this.isInitialized()){ throw new Error('already initialized') }
let key = null
let settings = null
if(!this.argon){
//! pbkdf2
const salt = await dataparty_crypto.Routines.generateSalt()
const rounds = PASSWORD_HASHING_ROUNDS
settings = {
type: 'pbkdf2',
salt: salt.toString('hex'),
rounds
}
key = await dataparty_crypto.Routines.createKeyFromPasswordPbkdf2(password, salt, rounds)
} else if(this.argon){
//! argon2
const salt = await dataparty_crypto.Routines.generateSalt()
let timeCost = ARGON_TIME_COST
let memoryCost = ARGON_MEMORY_COST
let parallelism = ARGON_PARALLELLISM
let argonType = ARGON_TYPE
settings = {
type: 'argon2',
salt: salt.toString('hex'),
timeCost,
memoryCost,
parallelism,
argonType
}
key = await dataparty_crypto.Routines.createKeyFromPasswordArgon2(
this.argon,
password,
salt,
timeCost,
memoryCost,
parallelism,
argonType
)
} else {
throw new Error('unsupported KDF['+type+']')
}
await this.initialize(key, defaults, settings)
}
/**
* Initialize the secure config with a key
* @fires module:Config.SecureConfig#intialized
* @fires module:Config.SecureConfig#ready
* @method module:Config.SecureConfig.setIdentity
* @param {dataparty_crypto/IKey} key
* @param {object} defaults
* @async
*/
async setIdentity(key, defaults){
debug('setIdentity')
if(await this.isInitialized()){ throw new Error('already initialized') }
const settings = {
type: 'key'
}
await this.initialize(key, defaults, settings)
}
async initialize(key, defaults, settings){
debug('initialize - type:', settings.type)
if(await this.isInitialized()){ throw new Error('already initialized') }
const pwIdentity = new dataparty_crypto.Identity({
key,
id: this.id,
})
const insecureContent = {
...settings
}
const secureContent = {
created: Date.now(),
...defaults
}
const initialContent = new dataparty_crypto.Message({ msg: secureContent })
await initialContent.encrypt(pwIdentity, pwIdentity.toMini())
await this.config.write(this.id+'.settings', insecureContent)
await this.config.write(this.id+'.content', initialContent.toJSON())
debug('\t', 'identity', pwIdentity)
debug('\t', 'insecure content', insecureContent)
debug('\t', 'secure content', secureContent)
debug('\t', 'encrypted content', initialContent.toJSON())
await this.config.save()
const contentMsg = new dataparty_crypto.Message( initialContent )
//! Verify message
await contentMsg.decrypt(pwIdentity)
/**
* The secure config has been successfully initialized with a passowrd
* or key.
* @event module:Config.SecureConfig#intialized
*/
this.emit('initialized')
this.emit('ready')
}
/**
* Wait for config to be unlocked
* @method module:Config.SecureConfig.waitForUnlocked
* @param {string} reason Optional reason message if config is locked
* @async
*/
async waitForUnlocked(reason){
if(!this.isLocked()){
return
}
debug('waitForUnlocked', reason)
if(!this.blocked){
/**
* An read/write operation has been blocked due to the secure config
* being locked.
* @event module:Config.SecureConfig#blocked
* @type
*/
this.emit('blocked', reason)
}
this.blocked = true
let waiting = new Promise((resolve,reject)=>{
this.once('unlocked', ()=>{
this.blocked = false
resolve()
debug('waitForUnlocked - done')
})
})
await waiting
}
/**
* Unlocks the secure config
* @fires module:Config.SecureConfig#unlocked
* @method module:Config.SecureConfig.unlock
* @param {string} password
* @async
*/
async unlock(password){
if(this.timer != null){
clearTimeout(this.timer)
this.timer = null
}
let key = null
let keyType = await this.config.read(this.id+'.settings.type')
if(keyType == 'pbkdf2'){
let salt = Buffer.from(await this.config.read(this.id+'.settings.salt'),'hex')
let rounds = await this.config.read(this.id+'.settings.rounds')
key = await dataparty_crypto.Routines.createKeyFromPasswordPbkdf2(password, salt, rounds)
} else if(keyType == 'argon2'){
let salt = Buffer.from(await this.config.read(this.id+'.settings.salt'), 'hex')
let timeCost = await this.config.read(this.id+'.settings.timeCost')
let memoryCost = await this.config.read(this.id+'.settings.memoryCost')
let parallelism = await this.config.read(this.id+'.settings.parallelism')
let argonType = await this.config.read(this.id+'.settings.argonType')
key = await dataparty_crypto.Routines.createKeyFromPasswordArgon2(
this.argon,
password,
salt,
timeCost,
memoryCost,
parallelism,
argonType
)
}
const pwIdentity = new dataparty_crypto.Identity({
key,
id: this.id
})
this.content = await this.config.read(this.id+'.content')
const contentMsg = new dataparty_crypto.Message( this.content )
//! Verify message
await contentMsg.decrypt(pwIdentity)
this.identity = pwIdentity
if(this.timeoutMs >= 0){
this.timer = setTimeout(this.onTimeout.bind(this), this.timeoutMs)
}
/**
* The secure config has been unlocked
* @event module:Config.SecureConfig#unlocked
*/
this.emit('unlocked')
}
/**
* Locks the secure config
* @fires module:Config.SecureConfig#locked
* @method module:Config.SecureConfig.lock
*/
lock(){
if(this.timer != null){
clearTimeout(this.timer)
this.timer = null
}
delete this.content
delete this.identity
this.content = null
this.identity = null
/**
* The secure config has been locked
* @event module:Config.SecureConfig#locked
*/
this.emit('locked')
}
onTimeout(){
this.timer = null
this.lock()
this.emit('timeout')
debug('timeout')
}
updateTimeout(){
if(!this.includeActivity && !this.isLocked()){
return
}
clearTimeout(this.timer)
if(this.timeoutMs >= 0){
this.timer = setTimeout(this.onTimeout.bind(this), this.timeoutMs)
}
this.lastActivity = Date.now()
}
/**
* @method module:Config.SecureConfig.clear
* @async
*/
async clear(){
debug('clear')
if(this.isLocked()){
await this.waitForUnlocked('clearing config')
}
this.updateTimeout()
const updatedContent = new dataparty_crypto.Message({ msg: {} })
await updatedContent.encrypt(this.identity, this.identity.toMini())
this.content = updatedContent.toJSON()
await this.save()
}
/**
* @method module:Config.SecureConfig.readAll
* @async
* @returns {object}
*/
async readAll(){
debug('readAll')
if(this.isLocked()){
await this.waitForUnlocked('read all')
}
this.updateTimeout()
const decryptedContent = new dataparty_crypto.Message( this.content )
await decryptedContent.decrypt(this.identity)
return decryptedContent.msg
}
/**
* @method module:Config.SecureConfig.read
* @param {string} key
* @async
*/
async read(key){
debug('read',key)
if(this.isLocked()){
await this.waitForUnlocked('read key - '+key)
}
this.updateTimeout()
const data = await this.readAll()
return reach( data, key )
}
/**
* @method module:Config.SecureConfig.write
* @param {string} key
* @param {object} data
* @async
*/
async write(key, value){
debug('write',key)
if(this.isLocked()){
await this.waitForUnlocked('write key - '+key)
}
this.updateTimeout()
let data = await this.readAll()
deepSet(data, key, value)
const updatedContent = new dataparty_crypto.Message({ msg: data })
await updatedContent.encrypt(this.identity, this.identity.toMini())
this.content = updatedContent.toJSON()
await this.save()
}
async exists(key){
return (await this.read(key)) !== undefined
}
/**
* @method module:Config.SecureConfig.save
* @async
*/
async save(){
debug('save')
if(this.isLocked()){
await this.waitForUnlocked('save config')
}
this.updateTimeout()
await this.config.write(this.id+'.content',{
enc: this.content.enc,
sig: this.content.sig
})
}
}
module.exports = SecureConfig