@tabcat/encrypted-docstore
Advanced tools
Comparing version 1.0.3 to 3.0.0
{ | ||
"name": "@tabcat/encrypted-docstore", | ||
"version": "1.0.3", | ||
"version": "3.0.0", | ||
"description": "mount encrypted docstores with a key", | ||
@@ -10,5 +10,2 @@ "repository": { | ||
"main": "src/index.js", | ||
"browser": { | ||
"./src/node-webcrypto-ossl.js": "./src/webcrypto.js" | ||
}, | ||
"keywords": [ | ||
@@ -19,9 +16,23 @@ "orbit-db", | ||
], | ||
"scripts": { | ||
"test": "mocha" | ||
}, | ||
"standard": { | ||
"env": "mocha", | ||
"ignore": [ | ||
"test/**" | ||
] | ||
}, | ||
"author": "anderbs@tuta.io", | ||
"license": "MIT", | ||
"dependencies": { | ||
"@tabcat/peer-account-crypto": "0.0.2", | ||
"bs58": "^4.0.1", | ||
"node-webcrypto-ossl": "^1.0.48", | ||
"safe-buffer": "^5.2.0" | ||
}, | ||
"devDependencies": { | ||
"@tabcat/ipfs-bundle-t": "0.0.5", | ||
"mocha": "^6.2.1", | ||
"orbit-db": "^0.22.0" | ||
} | ||
} |
105
README.md
@@ -6,4 +6,8 @@ # encrypted-docstore | ||
NOTE: *should* work in node but haven't tested at all | ||
NOTE: version 3.0.0 changes how EncryptedDocstore determines the orbitdb address, this is a breaking change. Some changes have been made to the api as well, mostly naming. | ||
TODO: </br> | ||
extend the docstore instead of wrap it. </br> | ||
make every entry iv deterministic? based off anything unique besides orbit id and clock with the goal of having duplicate entries from different nodes collapse. | ||
## Usage | ||
@@ -21,19 +25,10 @@ install with npm: | ||
// create the encryption key | ||
const secret = new TextEncoder().encode('something at least kind of random here') | ||
const salt = await window.crypto.getRandomValues(new Uint8Array(16)) // 128bit salt | ||
const key = EncryptedDocstore.deriveKey(secret, salt) | ||
const aesKey = EncryptedDocstore.generateKey() | ||
// create the docstore with orbit.create: | ||
const dbConfig = { name:'asdf', type:'docstore' } | ||
const encDbName = await EncryptedDocstore.determineEncDbName(orbit, dbConfig, key) | ||
const docstore = await open.create(encDbname, dbConfig.type, dbConfig.options) | ||
const encDocstore = await EncryptedDocstore.mount(docstore, key) | ||
// create the docstore with orbitdb: | ||
const dbConfig = { name:'asdf', type:'docstore', options: {} } | ||
const encAddr = await EncryptedDocstore.determineAddress(orbitdb, dbConfig, aesKey) | ||
const docstore = await orbitdb.docs(encAddr, dbConfig.options) | ||
// OR | ||
// create the docstore with orbit.docs: | ||
const encDbAddress = await EncryptedDocstore.determineEncDbAddress(orbit, dbConfig, key) | ||
const docstore = await orbit.docs(encDbAddress) | ||
const encDocstore = await EncryptedDocstore.mount(docstore, key) | ||
const encDocstore = await EncryptedDocstore.mount(docstore, aesKey) | ||
// get,put, del, query all exposed on encDocstore and returned results should be identical to docstore methods | ||
@@ -47,63 +42,53 @@ | ||
### Static Methods: | ||
#### EncDoc.mount(docstore, key) | ||
#### EncDoc.mount(docstore, aesKey) | ||
>mount an encrypted docstore | ||
*docstore:* orbit docstore made with name from EncDoc.determineEncDbName or address from EncDoc.determineEncDbAddress<br/> | ||
*key:* instance of key from src/key.js, made with EncDoc. | ||
*aesKey:* instance of AesKey from generateKey, deriveKey, or importKey static methods. | ||
returns a promise that resolves to an instance of EncDoc | ||
#### EncDoc.determineEncDbName(orbit, dbConfig, key) | ||
>determine the EncDoc name for a docstore config and key | ||
#### EncDoc.determineAddress(orbitdb, dbConfig, aesKey) | ||
>determine the docstore address for the encryptedDocstore, this is adding a way to check the aesKey against the db name | ||
*orbit:* an instance of OrbitDB<br/> | ||
*orbitdb:* an instance of OrbitDB<br/> | ||
*dbConfig:* an object containing name, type and options for an orbit store settings<br/> | ||
*key:* instance of Key from src/key.js, made with EncDoc.deriveKey or EncDoc.importKey<br/> | ||
*aesKey:* instance of AesKey from generateKey, deriveKey, or importKey static methods.<br/> | ||
returns a promise that resolves to a string made of:<br/> | ||
`<encrypted original config address root>/<original config address root>` both fields are base58 encoded | ||
#### EncDoc.determineEncDbAddress(orbit, dbConfig, key) | ||
>determine the EncDoc address for a docstore config and key | ||
returns a promise that resolves to an instance of orbit address | ||
#### EncDoc.keyCheck(encAddr, aesKey) | ||
>check if an orbitdb address and aesKey are a match | ||
*orbit:* an instance of OrbitDB<br/> | ||
*dbConfig:* an object containing name, type and options for an orbit store settings<br/> | ||
*key:* instance of Key from src/key.js, made with EncDoc.deriveKey or EncDoc.importKey<br/> | ||
*encAddr:* instance of orbit address from EncDoc.determineAddress<br/> | ||
*aesKey:* instance of AesKey from generateKey, deriveKey, or importKey static methods.<br/> | ||
returns a promise that resolves to an instance of orbit address | ||
#### EncDoc.keyCheck(address, key) | ||
>check if a key is used for this db address | ||
returns a promise that resolves to a boolean | ||
#### EncDoc.generateKey([length]) | ||
>generates a new aesKey | ||
*address:* instance of orbit address<br/> | ||
*key:* instance of Key from src/key.js, made with EncDoc.deriveKey or EncDoc.importKey<br/> | ||
*length:* number, aesKey length, defaults to 128. can be 128, 192, or 256<br/> | ||
returns promise that resolves to a boolean | ||
#### EncDoc.deriveKey(bytes, salt, [length, [purpose]]) | ||
>derive instance of Key from bytes and salt | ||
returns an instance of AesKey | ||
#### EncDoc.deriveKey(bytes, salt[, length]) | ||
>derive an instance of AesKey from bytes and salt, uses PBKDF2 with 10k iterations | ||
*bytes:* bytes array made from randomness or a strong password<br/> | ||
*salt:* bytes array to be used as salt for deriving the key, recommend using 128bit random value<br/> | ||
*length:* number representing cipherblock size, defaults to 128<br/> | ||
*purpose:* string that is used in generating the key somehow<br/> | ||
*bytes:* Uint8Array made from randomness or a strong password<br/> | ||
*salt:* Uint8Array to be used as salt for deriving the key, optimally a 128bit random value<br/> | ||
*length:* number, aesKey length, defaults to 128. can be 128, 192, or 256<br/> | ||
returns an instance of Key | ||
returns an instance of AesKey | ||
#### EncDoc.importKey(rawKey) | ||
>import a key from raw bytes from EncDoc.exportKey | ||
>import an exported aesKey | ||
*rawKey:* bytes array from EncDoc.exportKey | ||
*rawKey:* Uint8Array from EncDoc.exportKey | ||
returns an instance of Key | ||
#### EncDoc.exportKey(key) | ||
>export a key | ||
returns an instance of AesKey | ||
#### EncDoc.exportKey(aesKey) | ||
>export an aesKey | ||
*key:* instance of Key | ||
*aesKey:* instance of AesKey | ||
returns a bytes array that can be used as rawKey in EncDoc.importKey | ||
returns a Uint8Array rawKey | ||
### Instance Propterties: | ||
#### encDoc.encrypted | ||
> the orbit docstore being used as the encrypted docstore | ||
#### encDoc.key | ||
> an instance of the Key class from src/key.js | ||
### Instance Methods: | ||
- get, put, del, query all work by encapsulating the field it is indexed by (default is \_id) and should behave the same | ||
- get, put, del, query all work by encapsulating the whole doc and pass docstore tests for the orbitdb repo: https://github.com/orbitdb/orbit-db/blob/master/test/docstore.test.js | ||
#### encDoc.get(key) | ||
@@ -113,3 +98,3 @@ see: https://github.com/orbitdb/orbit-db/blob/master/API.md#getkey-1 | ||
differences: | ||
- is async function | ||
- is an async function | ||
#### encDoc.put(doc) | ||
@@ -127,9 +112,7 @@ >see: https://github.com/orbitdb/orbit-db/blob/master/API.md#putdoc | ||
differences: | ||
- is async function | ||
- is an async function | ||
- when calling with option fullOp: | ||
+ the payload.value is the decrypted/decapsulated doc. | ||
+ the payload.key which would usually match the payload.value[indexBy] field (indexBy default is '\_id') | ||
does not. | ||
+ the payload.value is the decrypted/decapsulated doc. | ||
+ anything in the fullOp entry relating to hashing the real payload.value will not match the payload.value | ||
- when not calling with option fullOp: | ||
+ no visible differences |
@@ -5,78 +5,109 @@ | ||
const Buffer = require('safe-buffer').Buffer | ||
const Key = require('./key') | ||
const { aes, util } = require('@tabcat/peer-account-crypto') | ||
// deterministic iv | ||
// used as iv when encrypting dbAddr root for encAddr | ||
const dIv = util.str2ab('encrypted-docstore') | ||
function encryptDoc (aesKey, indexBy) { | ||
return async (doc) => { | ||
try { | ||
if (doc === undefined) { | ||
throw new Error('doc must be defined') | ||
} | ||
const bytes = util.str2ab(JSON.stringify(doc)) | ||
const enc = await aesKey.encrypt(bytes) | ||
const cipherbytes = Object.values(enc.cipherbytes) | ||
const iv = Object.values(enc.iv) | ||
return { [indexBy]: `entry-${iv.join('.')}`, cipherbytes, iv } | ||
} catch (e) { | ||
console.error(e) | ||
console.error('failed to encrypt doc') | ||
return undefined | ||
} | ||
} | ||
} | ||
function decryptDoc (aesKey) { | ||
return async (encDoc) => { | ||
try { | ||
if (encDoc === undefined) { | ||
throw new Error('encDoc must be defined') | ||
} | ||
const cipherbytes = new Uint8Array(encDoc.cipherbytes) | ||
const iv = new Uint8Array(encDoc.iv) | ||
const decrypted = await aesKey.decrypt(cipherbytes, iv) | ||
const doc = JSON.parse(util.ab2str(decrypted.buffer)) | ||
return { internal: doc, external: encDoc } | ||
} catch (e) { | ||
console.error(e) | ||
console.error('failed to decrypt doc') | ||
return undefined | ||
} | ||
} | ||
} | ||
function decryptDocs (aesKey) { | ||
const decrypt = decryptDoc(aesKey) | ||
return async (encDocs) => { | ||
const docs = await Promise.all(encDocs.map(encDoc => decrypt(encDoc))) | ||
return docs.filter(t => t) // prune undefined | ||
} | ||
} | ||
class EncryptedDocstore { | ||
constructor(encryptedDocstore, key) { | ||
if (!encryptedDocstore) throw new Error('encryptedDocstore must be defined') | ||
if (!key) throw new Error('key must be defined') | ||
this._docstore = encryptedDocstore | ||
this._key = key | ||
this.indexBy = this._docstore.options.indexBy | ||
constructor (docstore, aesKey) { | ||
if (!docstore) throw new Error('docstore must be defined') | ||
if (!aesKey) throw new Error('aesKey must be defined') | ||
this._docstore = docstore | ||
this._aesKey = aesKey | ||
this._indexBy = this._docstore.options.indexBy | ||
} | ||
// docstore: an instance of orbitdb docstore with name from determineEncDbName | ||
// (get this by opening a docstore by address or creating one with a config) | ||
// key: aes cryptoKey (get this from this.deriveKey) | ||
static async mount(docstore, key) { | ||
if (key === undefined || docstore === undefined) { | ||
throw new Error('key and encryptedDocstore must be defined') | ||
static async mount (docstore, aesKey) { | ||
if (!docstore || !aesKey) { | ||
throw new Error('docstore and aesKey must be defined') | ||
} | ||
// if keyCheck fails throw | ||
if (!(await this.keyCheck(docstore.address, key))) { | ||
if (!await this.keyCheck(docstore.address, aesKey)) { | ||
throw new Error('keyCheck failed while trying to mount store') | ||
} | ||
return new EncryptedDocstore(docstore, key) | ||
return new EncryptedDocstore(docstore, aesKey) | ||
} | ||
// use to get name of encDocstore for creating the docstore | ||
static async determineEncDbName(orbitdb, dbConfig, key) { | ||
if (orbitdb === undefined || dbConfig === undefined || key === undefined) { | ||
throw new Error('orbitdb, dbConfig and key must be defined') | ||
// use to determine address of the docstore to be used for encryption | ||
static async determineAddress (orbitdb, dbConfig, aesKey) { | ||
if (!orbitdb || !dbConfig || !aesKey) { | ||
throw new Error('orbitdb, dbConfig and aesKey must be defined') | ||
} | ||
if (dbConfig.type !== 'docstore') { | ||
throw new Error('dbConfig type must be docstore') | ||
} | ||
if (!dbConfig.name) throw new Error('') | ||
const { name, type, options } = dbConfig | ||
const root = (await orbitdb.determineAddress(name, type, options)).root | ||
const decodedRoot = bs58.decode(root) | ||
const encRoot = bs58.encode( | ||
Buffer.from( | ||
(await key.encrypt(decodedRoot, decodedRoot)).cipherbytes | ||
) | ||
) | ||
return `${encRoot}/${root}` | ||
} | ||
// use to determine address of encDocstore | ||
static async determineEncDbAddress(orbitdb, dbConfig, key) { | ||
if (orbitdb === undefined || dbConfig === undefined || key === undefined) { | ||
throw new Error('orbitdb, dbConfig and key must be defined') | ||
try { | ||
const encRoot = bs58.encode(Buffer.from( | ||
(await aesKey.encrypt(decodedRoot, dIv)).cipherbytes | ||
)) | ||
const encName = `${encRoot}/${name}` | ||
return orbitdb.determineAddress(encName, type, options) | ||
} catch (e) { | ||
console.error(e) | ||
throw new Error('failed to determine address') | ||
} | ||
if (dbConfig.type !== 'docstore') { | ||
throw new Error('dbConfig type must be docstore') | ||
} | ||
const encDbName = await this.determineEncDbName(orbitdb, dbConfig, key) | ||
return await orbitdb.determineAddress(encDbName, dbConfig.type, dbConfig.options) | ||
} | ||
static async keyCheck(address, key) { | ||
if (address === undefined || key === undefined) { | ||
throw new Error('address and key must be defined') | ||
// encAddr is whats returned from this.determineAddress | ||
// it is an instance of orbitdb's address | ||
static async keyCheck (encAddr, aesKey) { | ||
if (!encAddr || !aesKey) { | ||
throw new Error('encAddr and aesKey must be defined') | ||
} | ||
const [ encRoot, root ] = address.path.split('/') | ||
const decodedEncRoot = bs58.decode(encRoot) | ||
// d for deterministic | ||
// probably the weakest part of the encryptedDocstore to precomputed tables. | ||
// weakness is related to there being basically no salt so attacker needs | ||
// a table with an entry of every encrypted original root from encrypting every | ||
// every likely original root with every possible aes-gcm key to know your key? | ||
// seems like a lot but targeting specific orbitdb stores would require only | ||
// finding every encrypted original root by encrypting one orignal root with | ||
// every possible aes-gcm key (if the amateur cryptographer in me is correct) | ||
// hopefully i am correct in thinking this is adequate security for now | ||
// if the bytes deriving the aes key are random enough | ||
const dIv = bs58.decode(root) | ||
const encRoot = encAddr.path.split('/')[0] | ||
if (!encRoot) throw new Error('invalid encrypted docstore address') | ||
try { | ||
const decrypted = await key.decrypt(decodedEncRoot, dIv) | ||
return bs58.encode(Buffer.from(decrypted)) === root | ||
} catch(e) { | ||
console.error(e) | ||
await aesKey.decrypt(bs58.decode(encRoot), dIv) | ||
return true | ||
} catch (e) { | ||
return false | ||
@@ -86,27 +117,21 @@ } | ||
// cryptographic opterations imported from ./key.js | ||
// creates an AES key that can be used for an encryptedDocstore | ||
// require bytes source to have sufficient entropy when implementing | ||
static async genKey(...params) { | ||
return await Key.genKey(...params) | ||
// creates an aesKey that can be used for an encryptedDocstore | ||
static async generateKey (...params) { | ||
return aes.generateKey(...params) | ||
} | ||
static async deriveKey(...params) { | ||
return await Key.deriveKey(...params) | ||
static async deriveKey (...params) { | ||
return aes.deriveKey(...params) | ||
} | ||
static async importKey(...params) { | ||
return await Key.importKey(...params) | ||
static async importKey (...params) { | ||
return aes.importKey(...params) | ||
} | ||
static async exportKey(...params) { | ||
return await Key.exportKey(...params) | ||
} | ||
// helpers | ||
async decryptRecords(encryptedRecords) { | ||
return await Promise.all( | ||
encryptedRecords.map(encDoc => this._key.decryptMsg(encDoc)) | ||
) | ||
static async exportKey (...params) { | ||
return aes.exportKey(...params) | ||
} | ||
// docstore operations | ||
async get(indexKey, caseSensitive = false) { | ||
// docstore operations | ||
async get (indexKey, caseSensitive = false) { | ||
if (indexKey === undefined) { | ||
@@ -119,7 +144,7 @@ throw new Error('indexKey is undefined') | ||
const terms = indexKey.split(' ') | ||
const replaceAll = (str, search, replacement) => | ||
str.toString().split(search).join(replacement) | ||
indexKey = terms.length > 1 | ||
? replaceAll(indexKey, '.', ' ').toLowerCase() | ||
: indexKey.toLowerCase() | ||
const replaceAll = (str, search, replacement) => | ||
str.toString().split(search).join(replacement) | ||
const search = (e) => { | ||
@@ -132,57 +157,69 @@ if (terms.length > 1) { | ||
const filter = caseSensitive | ||
? (i) => i[this.indexBy].indexOf(indexKey) !== -1 | ||
: (i) => search(i[this.indexBy]) | ||
const records = await this.decryptRecords(this._docstore.query(() => true)) | ||
return await Promise.all(records.map(res => res.internal).filter(filter)) | ||
? (i) => i[this._indexBy].indexOf(indexKey) !== -1 | ||
: (i) => search(i[this._indexBy]) | ||
const docs = | ||
await decryptDocs(this._aesKey)(this._docstore.query(() => true)) | ||
return Promise.all(docs.map(res => res.internal).filter(filter)) | ||
} | ||
async put(doc) { | ||
async put (doc) { | ||
if (typeof doc !== 'object') { | ||
throw new Error('doc must have type of object') | ||
} | ||
if (!doc[this.indexBy]) { | ||
throw new Error(`doc requires an ${this.indexBy} field`) | ||
if (!doc[this._indexBy]) { | ||
throw new Error(`doc requires an ${this._indexBy} field`) | ||
} | ||
// since real _id is encapsulated in cipherbytes field and external _id is | ||
// random, we must delete the old entry by querying for the same id | ||
try { await this.del(doc[this.indexBy]) } catch(e) {} | ||
return await this._docstore.put(await this._key.encryptMsg(doc)) | ||
try { await this.del(doc[this._indexBy]) } catch (e) {} | ||
const encDoc = await encryptDoc(this._aesKey, this._indexBy)(doc) | ||
if (!encDoc) throw new Error('failed to encrypt doc') | ||
return this._docstore.put(encDoc) | ||
} | ||
async del(indexKey) { | ||
async del (indexKey) { | ||
if (indexKey === undefined) { | ||
throw new Error('indexKey must be defined') | ||
} | ||
const records = await this.decryptRecords(this._docstore.query(() => true)) | ||
const matches = records.filter(res => res.internal[this.indexBy] === indexKey) | ||
// if a deletion fails this will clean it up old records | ||
const docs = | ||
await decryptDocs(this._aesKey)(this._docstore.query(() => true)) | ||
const matches = docs.filter(res => res.internal[this._indexBy] === indexKey) | ||
if (matches.length > 1) { | ||
console.error(`there was more than one entry with internal key ${indexKey}`) | ||
console.error( | ||
`there was more than one entry with internal key: ${indexKey}` | ||
) | ||
} | ||
if (matches.length === 0) { | ||
throw new Error(`No entry with key '${indexKey}' in the database`) | ||
throw new Error(`No entry with key: '${indexKey}' in the database`) | ||
} | ||
// if a deletion failed this will clean it up old docs | ||
// only return first deletion to keep same api as docstore | ||
return Promise.all( | ||
matches.map(res => this._docstore.del(res.external[this.indexBy])) | ||
matches.map(res => this._docstore.del(res.external[this._indexBy])) | ||
).then(arr => arr[0]) | ||
} | ||
async query(mapper, options = {}) { | ||
if (mapper === undefined) { | ||
throw new Error('mapper was undefined') | ||
async query (mapper, options = {}) { | ||
if (mapper === undefined) throw new Error('mapper was undefined') | ||
if (typeof mapper !== 'function') throw new Error('mapper must be function') | ||
const decrypt = decryptDoc(this._aesKey) | ||
const decryptFullOp = async (entry) => { | ||
const doc = await decrypt(entry.payload.value).then(res => res.internal) | ||
return doc | ||
? { | ||
...entry, | ||
payload: { | ||
...entry.payload, | ||
key: doc[this._indexBy], | ||
value: doc | ||
} | ||
} | ||
: undefined | ||
} | ||
const fullOp = options.fullOp || false | ||
const decryptFullOp = async(entry) => ({ | ||
...entry, | ||
payload: { | ||
...entry.payload, | ||
value:await this._key.decryptMsg(entry.payload.value).then(res => res.internal), | ||
}, | ||
}) | ||
const index = this._docstore._index | ||
const indexGet = fullOp | ||
? async(_id) => decryptFullOp(index._index[_id]) | ||
: async(_id) => index._index[_id] | ||
? await this._key.decryptMsg(index._index[_id].payload.value) | ||
const indexGet = options.fullOp || false | ||
? async (_id) => decryptFullOp(index._index[_id]) | ||
: async (_id) => index._index[_id] | ||
? decrypt(index._index[_id].payload.value) | ||
.then(res => res.internal) | ||
@@ -192,8 +229,7 @@ : null | ||
return Promise.all(indexKeys.map(key => indexGet(key))) | ||
.then(arr => arr.filter(mapper)) | ||
// remove undefined docs before handing to mapper | ||
.then(arr => arr.filter(t => t).filter(mapper)) | ||
} | ||
} | ||
module.exports = EncryptedDocstore | ||
'use strict' | ||
module.exports = require('./encryptedDocstore') |
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
Major refactor
Supply chain riskPackage has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes.
Found 1 instance in 1 package
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
24279
478
3
6
114
1
+ Added@tabcat/peer-account-crypto@0.0.2(transitive)
+ Addedasn1.js@5.4.1(transitive)
+ Addedbn.js@4.12.1(transitive)
+ Addedinherits@2.0.4(transitive)
+ Addedminimalistic-assert@1.0.1(transitive)
+ Addedsafer-buffer@2.1.2(transitive)
- Removednode-webcrypto-ossl@^1.0.48