├── .babelrc ├── .eslintignore ├── .eslintrc.json ├── .gitignore ├── .npmignore ├── .nycrc ├── .travis.yml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── build-utils ├── generate-encrypted-keystore.js └── generate-key-bundle.js ├── docs ├── CODEOWNERS ├── README.md ├── SECURITY.md ├── code_of_conduct.md ├── contributing.md ├── index.md ├── install.md └── releases.md ├── jsdoc.conf.js ├── karma.conf-base.js ├── karma.conf-debug.js ├── karma.conf.js ├── lib ├── constants.js ├── datastore.js ├── index.js ├── itemkeystore.js ├── items.js ├── localdatabase.js └── util │ ├── errors.js │ └── instance.js ├── mkdocs.yml ├── package-lock.json ├── package.json ├── test ├── .eslintrc.json ├── datastore-test.js ├── index-test.js ├── itemkeystore-test.js ├── items-test.js ├── localdatabase-test.js ├── setup │ ├── assert.js │ ├── encrypted-4items.json │ ├── encrypted-empty.json │ └── key-bundle.json └── util │ ├── errors-test.js │ └── instance-test.js └── webpack.config.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | ["transform-object-rest-spread", { "useBuiltins": true }] 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | coverage 2 | site 3 | node_modules 4 | package-lock.json 5 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "eslint:recommended", 4 | "plugin:security/recommended" 5 | ], 6 | "env": { 7 | "browser": true, 8 | "commonjs": true, 9 | "es6": true, 10 | "node": true 11 | }, 12 | "parserOptions": { 13 | "ecmaVersion": 2017 14 | }, 15 | "plugins": [ 16 | "jsdoc", 17 | "json", 18 | "security" 19 | ], 20 | "root": true, 21 | "rules": { 22 | "jsdoc/check-types": "error", 23 | "security/detect-non-literal-fs-filename": "off", 24 | "security/detect-object-injection": "off", 25 | 26 | "eqeqeq": "error", 27 | "indent": ["error", 2, {"SwitchCase": 1, "VariableDeclarator": {"var": 2, "let": 2, "const": 3}}], 28 | "linebreak-style": ["error", "unix"], 29 | "no-var": "off", 30 | "no-warning-comments": "warn", 31 | "prefer-const": "off", 32 | "quotes": ["error", "double"], 33 | "require-jsdoc": "off", 34 | "semi": ["error", "always"], 35 | "valid-jsdoc": "error" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /coverage 2 | /docs/api.md 3 | /node_modules 4 | /site 5 | .npmrc 6 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .babelrc 2 | .eslint* 3 | .nycrc 4 | .travis.yml 5 | .vscode 6 | CHANGELOG.md 7 | jsdoc.conf.js 8 | karma.*.js 9 | mkdocs.yml 10 | webpack.config.js 11 | build-utils/ 12 | coverage/ 13 | docs/ 14 | site/ 15 | test/ 16 | -------------------------------------------------------------------------------- /.nycrc: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["lib/**/*.js"], 3 | "reporter": ["html", "text-summary"], 4 | "report-dir": "coverage/node", 5 | "temp-directory": "coverage/.nyc", 6 | "cache": false 7 | } 8 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | 3 | node_js: 4 | - "node" 5 | 6 | addons: 7 | firefox: latest-beta 8 | 9 | # DEFAULTS: Travis runs `npm install` and `npm test` 10 | 11 | after_success: 12 | - npm run codecov 13 | - pip install --user mkdocs 14 | - npm run doc 15 | 16 | deploy: 17 | - provider: pages 18 | skip_cleanup: true 19 | local_dir: site 20 | github_token: $GITHUB_TOKEN 21 | on: 22 | branch: master 23 | - provider: npm 24 | email: $NPM_EMAIL 25 | api_key: $NPM_TOKEN 26 | on: 27 | tags: true 28 | repo: mozilla-lockbox/lockbox-datastore 29 | branch: production 30 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Lockbox Datastore 2 | 3 | 4 | ## 0.2.1 5 | 6 | _Date: 2018-05-21_ 7 | 8 | **NOTE:** This update includes changes to dependencies to address security vulnerabilities. 9 | 10 | ### What's New 11 | 12 | * Added a `touch()` method to record when an item is used. 13 | * Updated local database schema in preparations for syncing. 14 | * Updated various dependencies to their latest versions. 15 | 16 | ### What's Fixed 17 | 18 | _No issues fixed_ 19 | 20 | 21 | 22 | ## 0.2.0 23 | 24 | _Date: 2018-01-16_ 25 | 26 | **NOTE**: This release requires applications to specify an `appKey` when initializing or unlocking. 27 | 28 | ### What's New 29 | 30 | * Rwmoved the default `appKey`. Consumers of this API _must_ specify an `appKey` when initializing or unlocking the datastore instance. 31 | * Exports the `DataStore` class to allow for extending it. 32 | * Updated various dependencies to their latest versions. 33 | 34 | ### What's Fixed 35 | 36 | _No issues fixed_ 37 | 38 | 39 | ## 0.1.0 40 | 41 | _Date: 2017-12-14_ 42 | 43 | **NOTE**: This release now uses a symmetric key to lock/unlock the datastore, instead of a master password. Any previous data from a previous instance is now lost. 44 | 45 | ### What's New 46 | 47 | * Lock/unlock the datastore using a 48 | * Full item validation and completion 49 | * Generate history patches 50 | 51 | ### What's Fixed 52 | 53 | * API documentation is generated correctly, and checked. 54 | * A Datastore can be re-initialized (updated) to use a different symmetric key 55 | 56 | 57 | 58 | ## 0.0.1 59 | 60 | _Date: 2017-10-26_ 61 | 62 | This is the initial release of Datastore. 63 | 64 | ### What's New 65 | 66 | * Adds, updates, and removes items from the datastore 67 | * Stores items using IndexedDB 68 | * Encrypts items using a master password 69 | * Get a Datastore asynchronously 70 | 71 | ### Known Issues 72 | 73 | * Once initialized, a datastore's password cannot be changed. 74 | 75 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Mozilla Public License Version 2.0 2 | ================================== 3 | 4 | 1. Definitions 5 | -------------- 6 | 7 | 1.1. "Contributor" 8 | means each individual or legal entity that creates, contributes to 9 | the creation of, or owns Covered Software. 10 | 11 | 1.2. "Contributor Version" 12 | means the combination of the Contributions of others (if any) used 13 | by a Contributor and that particular Contributor's Contribution. 14 | 15 | 1.3. "Contribution" 16 | means Covered Software of a particular Contributor. 17 | 18 | 1.4. "Covered Software" 19 | means Source Code Form to which the initial Contributor has attached 20 | the notice in Exhibit A, the Executable Form of such Source Code 21 | Form, and Modifications of such Source Code Form, in each case 22 | including portions thereof. 23 | 24 | 1.5. "Incompatible With Secondary Licenses" 25 | means 26 | 27 | (a) that the initial Contributor has attached the notice described 28 | in Exhibit B to the Covered Software; or 29 | 30 | (b) that the Covered Software was made available under the terms of 31 | version 1.1 or earlier of the License, but not also under the 32 | terms of a Secondary License. 33 | 34 | 1.6. "Executable Form" 35 | means any form of the work other than Source Code Form. 36 | 37 | 1.7. "Larger Work" 38 | means a work that combines Covered Software with other material, in 39 | a separate file or files, that is not Covered Software. 40 | 41 | 1.8. "License" 42 | means this document. 43 | 44 | 1.9. "Licensable" 45 | means having the right to grant, to the maximum extent possible, 46 | whether at the time of the initial grant or subsequently, any and 47 | all of the rights conveyed by this License. 48 | 49 | 1.10. "Modifications" 50 | means any of the following: 51 | 52 | (a) any file in Source Code Form that results from an addition to, 53 | deletion from, or modification of the contents of Covered 54 | Software; or 55 | 56 | (b) any new file in Source Code Form that contains any Covered 57 | Software. 58 | 59 | 1.11. "Patent Claims" of a Contributor 60 | means any patent claim(s), including without limitation, method, 61 | process, and apparatus claims, in any patent Licensable by such 62 | Contributor that would be infringed, but for the grant of the 63 | License, by the making, using, selling, offering for sale, having 64 | made, import, or transfer of either its Contributions or its 65 | Contributor Version. 66 | 67 | 1.12. "Secondary License" 68 | means either the GNU General Public License, Version 2.0, the GNU 69 | Lesser General Public License, Version 2.1, the GNU Affero General 70 | Public License, Version 3.0, or any later versions of those 71 | licenses. 72 | 73 | 1.13. "Source Code Form" 74 | means the form of the work preferred for making modifications. 75 | 76 | 1.14. "You" (or "Your") 77 | means an individual or a legal entity exercising rights under this 78 | License. For legal entities, "You" includes any entity that 79 | controls, is controlled by, or is under common control with You. For 80 | purposes of this definition, "control" means (a) the power, direct 81 | or indirect, to cause the direction or management of such entity, 82 | whether by contract or otherwise, or (b) ownership of more than 83 | fifty percent (50%) of the outstanding shares or beneficial 84 | ownership of such entity. 85 | 86 | 2. License Grants and Conditions 87 | -------------------------------- 88 | 89 | 2.1. Grants 90 | 91 | Each Contributor hereby grants You a world-wide, royalty-free, 92 | non-exclusive license: 93 | 94 | (a) under intellectual property rights (other than patent or trademark) 95 | Licensable by such Contributor to use, reproduce, make available, 96 | modify, display, perform, distribute, and otherwise exploit its 97 | Contributions, either on an unmodified basis, with Modifications, or 98 | as part of a Larger Work; and 99 | 100 | (b) under Patent Claims of such Contributor to make, use, sell, offer 101 | for sale, have made, import, and otherwise transfer either its 102 | Contributions or its Contributor Version. 103 | 104 | 2.2. Effective Date 105 | 106 | The licenses granted in Section 2.1 with respect to any Contribution 107 | become effective for each Contribution on the date the Contributor first 108 | distributes such Contribution. 109 | 110 | 2.3. Limitations on Grant Scope 111 | 112 | The licenses granted in this Section 2 are the only rights granted under 113 | this License. No additional rights or licenses will be implied from the 114 | distribution or licensing of Covered Software under this License. 115 | Notwithstanding Section 2.1(b) above, no patent license is granted by a 116 | Contributor: 117 | 118 | (a) for any code that a Contributor has removed from Covered Software; 119 | or 120 | 121 | (b) for infringements caused by: (i) Your and any other third party's 122 | modifications of Covered Software, or (ii) the combination of its 123 | Contributions with other software (except as part of its Contributor 124 | Version); or 125 | 126 | (c) under Patent Claims infringed by Covered Software in the absence of 127 | its Contributions. 128 | 129 | This License does not grant any rights in the trademarks, service marks, 130 | or logos of any Contributor (except as may be necessary to comply with 131 | the notice requirements in Section 3.4). 132 | 133 | 2.4. Subsequent Licenses 134 | 135 | No Contributor makes additional grants as a result of Your choice to 136 | distribute the Covered Software under a subsequent version of this 137 | License (see Section 10.2) or under the terms of a Secondary License (if 138 | permitted under the terms of Section 3.3). 139 | 140 | 2.5. Representation 141 | 142 | Each Contributor represents that the Contributor believes its 143 | Contributions are its original creation(s) or it has sufficient rights 144 | to grant the rights to its Contributions conveyed by this License. 145 | 146 | 2.6. Fair Use 147 | 148 | This License is not intended to limit any rights You have under 149 | applicable copyright doctrines of fair use, fair dealing, or other 150 | equivalents. 151 | 152 | 2.7. Conditions 153 | 154 | Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted 155 | in Section 2.1. 156 | 157 | 3. Responsibilities 158 | ------------------- 159 | 160 | 3.1. Distribution of Source Form 161 | 162 | All distribution of Covered Software in Source Code Form, including any 163 | Modifications that You create or to which You contribute, must be under 164 | the terms of this License. You must inform recipients that the Source 165 | Code Form of the Covered Software is governed by the terms of this 166 | License, and how they can obtain a copy of this License. You may not 167 | attempt to alter or restrict the recipients' rights in the Source Code 168 | Form. 169 | 170 | 3.2. Distribution of Executable Form 171 | 172 | If You distribute Covered Software in Executable Form then: 173 | 174 | (a) such Covered Software must also be made available in Source Code 175 | Form, as described in Section 3.1, and You must inform recipients of 176 | the Executable Form how they can obtain a copy of such Source Code 177 | Form by reasonable means in a timely manner, at a charge no more 178 | than the cost of distribution to the recipient; and 179 | 180 | (b) You may distribute such Executable Form under the terms of this 181 | License, or sublicense it under different terms, provided that the 182 | license for the Executable Form does not attempt to limit or alter 183 | the recipients' rights in the Source Code Form under this License. 184 | 185 | 3.3. Distribution of a Larger Work 186 | 187 | You may create and distribute a Larger Work under terms of Your choice, 188 | provided that You also comply with the requirements of this License for 189 | the Covered Software. If the Larger Work is a combination of Covered 190 | Software with a work governed by one or more Secondary Licenses, and the 191 | Covered Software is not Incompatible With Secondary Licenses, this 192 | License permits You to additionally distribute such Covered Software 193 | under the terms of such Secondary License(s), so that the recipient of 194 | the Larger Work may, at their option, further distribute the Covered 195 | Software under the terms of either this License or such Secondary 196 | License(s). 197 | 198 | 3.4. Notices 199 | 200 | You may not remove or alter the substance of any license notices 201 | (including copyright notices, patent notices, disclaimers of warranty, 202 | or limitations of liability) contained within the Source Code Form of 203 | the Covered Software, except that You may alter any license notices to 204 | the extent required to remedy known factual inaccuracies. 205 | 206 | 3.5. Application of Additional Terms 207 | 208 | You may choose to offer, and to charge a fee for, warranty, support, 209 | indemnity or liability obligations to one or more recipients of Covered 210 | Software. However, You may do so only on Your own behalf, and not on 211 | behalf of any Contributor. You must make it absolutely clear that any 212 | such warranty, support, indemnity, or liability obligation is offered by 213 | You alone, and You hereby agree to indemnify every Contributor for any 214 | liability incurred by such Contributor as a result of warranty, support, 215 | indemnity or liability terms You offer. You may include additional 216 | disclaimers of warranty and limitations of liability specific to any 217 | jurisdiction. 218 | 219 | 4. Inability to Comply Due to Statute or Regulation 220 | --------------------------------------------------- 221 | 222 | If it is impossible for You to comply with any of the terms of this 223 | License with respect to some or all of the Covered Software due to 224 | statute, judicial order, or regulation then You must: (a) comply with 225 | the terms of this License to the maximum extent possible; and (b) 226 | describe the limitations and the code they affect. Such description must 227 | be placed in a text file included with all distributions of the Covered 228 | Software under this License. Except to the extent prohibited by statute 229 | or regulation, such description must be sufficiently detailed for a 230 | recipient of ordinary skill to be able to understand it. 231 | 232 | 5. Termination 233 | -------------- 234 | 235 | 5.1. The rights granted under this License will terminate automatically 236 | if You fail to comply with any of its terms. However, if You become 237 | compliant, then the rights granted under this License from a particular 238 | Contributor are reinstated (a) provisionally, unless and until such 239 | Contributor explicitly and finally terminates Your grants, and (b) on an 240 | ongoing basis, if such Contributor fails to notify You of the 241 | non-compliance by some reasonable means prior to 60 days after You have 242 | come back into compliance. Moreover, Your grants from a particular 243 | Contributor are reinstated on an ongoing basis if such Contributor 244 | notifies You of the non-compliance by some reasonable means, this is the 245 | first time You have received notice of non-compliance with this License 246 | from such Contributor, and You become compliant prior to 30 days after 247 | Your receipt of the notice. 248 | 249 | 5.2. If You initiate litigation against any entity by asserting a patent 250 | infringement claim (excluding declaratory judgment actions, 251 | counter-claims, and cross-claims) alleging that a Contributor Version 252 | directly or indirectly infringes any patent, then the rights granted to 253 | You by any and all Contributors for the Covered Software under Section 254 | 2.1 of this License shall terminate. 255 | 256 | 5.3. In the event of termination under Sections 5.1 or 5.2 above, all 257 | end user license agreements (excluding distributors and resellers) which 258 | have been validly granted by You or Your distributors under this License 259 | prior to termination shall survive termination. 260 | 261 | ************************************************************************ 262 | * * 263 | * 6. Disclaimer of Warranty * 264 | * ------------------------- * 265 | * * 266 | * Covered Software is provided under this License on an "as is" * 267 | * basis, without warranty of any kind, either expressed, implied, or * 268 | * statutory, including, without limitation, warranties that the * 269 | * Covered Software is free of defects, merchantable, fit for a * 270 | * particular purpose or non-infringing. The entire risk as to the * 271 | * quality and performance of the Covered Software is with You. * 272 | * Should any Covered Software prove defective in any respect, You * 273 | * (not any Contributor) assume the cost of any necessary servicing, * 274 | * repair, or correction. This disclaimer of warranty constitutes an * 275 | * essential part of this License. No use of any Covered Software is * 276 | * authorized under this License except under this disclaimer. * 277 | * * 278 | ************************************************************************ 279 | 280 | ************************************************************************ 281 | * * 282 | * 7. Limitation of Liability * 283 | * -------------------------- * 284 | * * 285 | * Under no circumstances and under no legal theory, whether tort * 286 | * (including negligence), contract, or otherwise, shall any * 287 | * Contributor, or anyone who distributes Covered Software as * 288 | * permitted above, be liable to You for any direct, indirect, * 289 | * special, incidental, or consequential damages of any character * 290 | * including, without limitation, damages for lost profits, loss of * 291 | * goodwill, work stoppage, computer failure or malfunction, or any * 292 | * and all other commercial damages or losses, even if such party * 293 | * shall have been informed of the possibility of such damages. This * 294 | * limitation of liability shall not apply to liability for death or * 295 | * personal injury resulting from such party's negligence to the * 296 | * extent applicable law prohibits such limitation. Some * 297 | * jurisdictions do not allow the exclusion or limitation of * 298 | * incidental or consequential damages, so this exclusion and * 299 | * limitation may not apply to You. * 300 | * * 301 | ************************************************************************ 302 | 303 | 8. Litigation 304 | ------------- 305 | 306 | Any litigation relating to this License may be brought only in the 307 | courts of a jurisdiction where the defendant maintains its principal 308 | place of business and such litigation shall be governed by laws of that 309 | jurisdiction, without reference to its conflict-of-law provisions. 310 | Nothing in this Section shall prevent a party's ability to bring 311 | cross-claims or counter-claims. 312 | 313 | 9. Miscellaneous 314 | ---------------- 315 | 316 | This License represents the complete agreement concerning the subject 317 | matter hereof. If any provision of this License is held to be 318 | unenforceable, such provision shall be reformed only to the extent 319 | necessary to make it enforceable. Any law or regulation which provides 320 | that the language of a contract shall be construed against the drafter 321 | shall not be used to construe this License against a Contributor. 322 | 323 | 10. Versions of the License 324 | --------------------------- 325 | 326 | 10.1. New Versions 327 | 328 | Mozilla Foundation is the license steward. Except as provided in Section 329 | 10.3, no one other than the license steward has the right to modify or 330 | publish new versions of this License. Each version will be given a 331 | distinguishing version number. 332 | 333 | 10.2. Effect of New Versions 334 | 335 | You may distribute the Covered Software under the terms of the version 336 | of the License under which You originally received the Covered Software, 337 | or under the terms of any subsequent version published by the license 338 | steward. 339 | 340 | 10.3. Modified Versions 341 | 342 | If you create software not governed by this License, and you want to 343 | create a new license for such software, you may create and use a 344 | modified version of this License if you rename the license and remove 345 | any references to the name of the license steward (except to note that 346 | such modified license differs from this License). 347 | 348 | 10.4. Distributing Source Code Form that is Incompatible With Secondary 349 | Licenses 350 | 351 | If You choose to distribute Source Code Form that is Incompatible With 352 | Secondary Licenses under the terms of this version of the License, the 353 | notice described in Exhibit B of this License must be attached. 354 | 355 | Exhibit A - Source Code Form License Notice 356 | ------------------------------------------- 357 | 358 | This Source Code Form is subject to the terms of the Mozilla Public 359 | License, v. 2.0. If a copy of the MPL was not distributed with this 360 | file, You can obtain one at http://mozilla.org/MPL/2.0/. 361 | 362 | If it is not possible or desirable to put the notice in a particular 363 | file, then You may include the notice in a location (such as a LICENSE 364 | file in a relevant directory) where a recipient would be likely to look 365 | for such a notice. 366 | 367 | You may add additional accurate notices of copyright ownership. 368 | 369 | Exhibit B - "Incompatible With Secondary Licenses" Notice 370 | --------------------------------------------------------- 371 | 372 | This Source Code Form is "Incompatible With Secondary Licenses", as 373 | defined by the Mozilla Public License, v. 2.0. 374 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status][travis-image]][travis-link] 2 | [![Coverage Status][codecov-image]][codecov-link] 3 | [![Waffle Board][waffle-image]][waffle-link] 4 | 5 | # Lockbox DataStore 6 | 7 | [![Greenkeeper badge](https://badges.greenkeeper.io/mozilla-lockbox/lockbox-datastore.svg)](https://greenkeeper.io/) 8 | 9 | :warning: **Note: This is no longer an actively-developed prototype and not 10 | officially supported.** 11 | 12 | The data storage module for Lockbox. This module maintains the collection of 13 | entries, protected behind a preconfigured secret. 14 | 15 | Items persisted to storage are encrypted. 16 | 17 | ## [Documentation][docs-link] 18 | 19 | *This is just one component of the Lockbox product. Please see the 20 | [Lockbox website][org-website] for more context.* 21 | 22 | For detailed documentation and installation instructions, please see the 23 | [`docs` directory][docs-link]. 24 | 25 | ## Contributing ## 26 | 27 | :warning: **Note: This is not an actively-developed prototype and we are not 28 | actively seeking contributions at this time.** 29 | 30 | See the [guidelines][contributing-link] for contributing to this project. 31 | 32 | This project is governed by a [Code Of Conduct][coc-link]. 33 | 34 | To disclose potential a security vulnerability please see our 35 | [security][security-link] documentation. 36 | 37 | ## [License][license-link] 38 | 39 | This module is licensed under the [Mozilla Public License, 40 | version 2.0][license-link]. 41 | 42 | [travis-image]: https://travis-ci.org/mozilla-lockbox/lockbox-datastore.svg?branch=master 43 | [travis-link]: https://travis-ci.org/mozilla-lockbox/lockbox-datastore 44 | [codecov-image]: https://img.shields.io/codecov/c/github/mozilla-lockbox/lockbox-datastore.svg 45 | [codecov-link]: https://codecov.io/gh/mozilla-lockbox/lockbox-datastore 46 | [waffle-image]: https://badge.waffle.io/mozilla-lockbox/lockbox-extension.svg?columns=In%20Progress 47 | [waffle-link]: https://waffle.io/mozilla-lockbox/lockbox-extension 48 | [docs-link]: docs/ 49 | [org-website]: https://mozilla-lockbox.github.io/ 50 | [contributing-link]: docs/contributing.md 51 | [coc-link]: docs/code_of_conduct.md 52 | [security-link]: docs/SECURITY.md 53 | [license-link]: /LICENSE 54 | -------------------------------------------------------------------------------- /build-utils/generate-encrypted-keystore.js: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env node 2 | /*! 3 | * This Source Code Form is subject to the terms of the Mozilla Public 4 | * License, v. 2.0. If a copy of the MPL was not distributed with this 5 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. 6 | */ 7 | 8 | const jose = require("node-jose"), 9 | fs = require("promisified-fs"), 10 | yargs = require("yargs"), 11 | UUID = require("uuid"); 12 | 13 | 14 | var argv = yargs. 15 | option("bundle", { 16 | desc: "the key bundle to use for encryption", 17 | required: true, 18 | requiresArg: true 19 | }). 20 | option("output", { 21 | desc: "the file to write the results to", 22 | required: true, 23 | requiresArg: true 24 | }). 25 | option("count", { 26 | desc: "the number of test item keys to generate", 27 | number: true, 28 | default: 4 29 | }). 30 | help(). 31 | argv; 32 | 33 | var keystore = jose.JWK.createKeyStore(); 34 | 35 | async function createItemKey() { 36 | let params = { 37 | alg: "A256GCM", 38 | kid: UUID() 39 | }; 40 | return await keystore.generate("oct", 256, params); 41 | } 42 | 43 | async function main() { 44 | let { count, bundle, output } = argv; 45 | 46 | bundle = await fs.readFile(bundle, "utf8"); 47 | bundle = JSON.parse(bundle); 48 | bundle.encryptKey = await jose.JWK.asKey(bundle.encryptKey); 49 | 50 | let itemKeys = {}; 51 | for (let idx = 0; count > idx; idx++) { 52 | let k = await createItemKey(); 53 | itemKeys[k.kid] = k.toJSON(true); 54 | } 55 | 56 | let params = { 57 | format: "compact", 58 | contentAlg: "A256GCM", 59 | fields: { 60 | alg: "dir" 61 | } 62 | }; 63 | 64 | let { encryptKey, salt } = bundle; 65 | let encrypted; 66 | encrypted = JSON.stringify(itemKeys); 67 | encrypted = await jose.JWE.createEncrypt(params, encryptKey).final(encrypted, "utf8"); 68 | 69 | let results = { 70 | encrypted, 71 | salt 72 | }; 73 | results = JSON.stringify(results, null, " ") + "\n"; 74 | 75 | await fs.writeFile(output, results); 76 | // eslint-disable-next-line no-console 77 | console.log(`generated encrypted keysstore of ${count} keys: [${Object.keys(itemKeys).join(", ")}]`); 78 | } 79 | 80 | main(); 81 | -------------------------------------------------------------------------------- /build-utils/generate-key-bundle.js: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env node 2 | /*! 3 | * This Source Code Form is subject to the terms of the Mozilla Public 4 | * License, v. 2.0. If a copy of the MPL was not distributed with this 5 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. 6 | */ 7 | 8 | const jose = require("node-jose"); 9 | const fs = require("promisified-fs"); 10 | const yargs = require("yargs"); 11 | 12 | const DEFAULT_APP_KEY = { 13 | "kty": "oct", 14 | "kid": "L9-eBkDrYHdPdXV_ymuzy_u9n3drkQcSw5pskrNl4pg", 15 | "k": "WsTdZ2tjji2W36JN9vk9s2AYsvp8eYy1pBbKPgcSLL4" 16 | }; 17 | 18 | // SHA-256("lockbox encrypt") 19 | const HKDF_INFO_ENCRYPT = "9UUucG8PDHPGXwM-pGoT0-aFGu74M54k55AykEgOx98"; 20 | // SHA-256("lockbox hashing") 21 | const HKDF_INFO_HASHING = "pz8gGLGYNLV6haKwjJ1dR-YKX5zDMhPHw2DuXGNu6cw"; 22 | 23 | async function deriveKeys(appKey, salt) { 24 | if (!appKey) { 25 | appKey = DEFAULT_APP_KEY; 26 | } 27 | appKey = await jose.JWK.asKey(appKey); 28 | 29 | salt = Buffer.from(salt || "", "utf8"); 30 | 31 | let keyval = appKey.get("k", true); 32 | let encryptKey = await jose.JWA.derive("HKDF-SHA-256", keyval, { 33 | salt: Buffer.from(salt), 34 | info: jose.util.base64url.decode(HKDF_INFO_ENCRYPT) 35 | }); 36 | encryptKey = await jose.JWK.asKey({ 37 | kty: "oct", 38 | alg: "A256GCM", 39 | k: encryptKey 40 | }); 41 | 42 | let hashingKey = await jose.JWA.derive("HKDF-SHA-256", keyval, { 43 | salt: Buffer.from(salt), 44 | info: jose.util.base64url.decode(HKDF_INFO_HASHING) 45 | }); 46 | hashingKey = await jose.JWK.asKey({ 47 | kty: "oct", 48 | alg: "HS256", 49 | k: hashingKey 50 | }); 51 | 52 | return { 53 | encryptKey, 54 | hashingKey, 55 | salt: salt.toString("utf8") 56 | }; 57 | } 58 | 59 | var argv = yargs. 60 | option("output", { 61 | desc: "the file to write the results to", 62 | required: true, 63 | requiresArg: true 64 | }). 65 | help(). 66 | argv; 67 | 68 | async function main() { 69 | let { output } = argv; 70 | 71 | let appKey = DEFAULT_APP_KEY; 72 | let bundle = { 73 | appKey 74 | }; 75 | Object.assign(bundle, await deriveKeys(appKey)); 76 | bundle.appKey = appKey; 77 | Object.keys(bundle).forEach((k) => { 78 | if (!jose.JWK.isKey(bundle[k])) { return; } 79 | bundle[k] = bundle[k].toJSON(true); 80 | }); 81 | bundle = JSON.stringify(bundle, null, " ") + "\n"; 82 | await fs.writeFile(output, bundle); 83 | } 84 | 85 | main(); 86 | -------------------------------------------------------------------------------- /docs/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # lockbox-datastore code owners 2 | # https://help.github.com/articles/about-codeowners/ 3 | 4 | # tests should also be reviewed by @m8ttyB 5 | /test/ @linuxwolf @m8ttyB 6 | 7 | # docs are shepherded by @devinreams 8 | /docs/ @linuxwolf @devinreams 9 | 10 | # otherwise, everything is to be reviewed by @linuxwolf 11 | * @linuxwolf 12 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Welcome to Lockbox's DataStore documentation! 2 | 3 | **Note: This is no longer an actively-developed prototype and not 4 | officially supported.** 5 | 6 | You can read this documentation [online][online-docs-link] or by browsing the 7 | [`docs` directory on GitHub][repo-docs-link]. 8 | 9 | [online-docs-link]: https://mozilla-lockbox.github.io/lockbox-datastore/ 10 | [repo-docs-link]: https://github.com/mozilla-lockbox/lockbox-datastore/tree/master/docs 11 | -------------------------------------------------------------------------------- /docs/SECURITY.md: -------------------------------------------------------------------------------- 1 | # Mozilla Security # 2 | 3 | - Mozilla cares about privacy and security. For more information please see: https://www.mozilla.org/security/ 4 | 5 | - If you believe that you've found a security vulnerability, please report it by sending email to the addresses: security@mozilla.org and lockbox-dev@mozilla.com 6 | -------------------------------------------------------------------------------- /docs/code_of_conduct.md: -------------------------------------------------------------------------------- 1 | # Code of Conduct 2 | 3 | This repository is governed by Mozilla's code of conduct and etiquette guidelines. For more details please see the [Mozilla Community Participation Guidelines](https://www.mozilla.org/about/governance/policies/participation/) and [Developer Etiquette Guidelines](https://bugzilla.mozilla.org/page.cgi?id=etiquette.html). 4 | -------------------------------------------------------------------------------- /docs/contributing.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Thank you for considering and taking the time to contribute! **Please note: this project is not currently or actively planning to fix non-critical (data loss, security related) bugs or implement new features.** This is an experimental prototype. 4 | 5 | ## Code of Conduct 6 | 7 | This repository is governed by Mozilla's code of conduct and etiquette guidelines. For more details please see the [Mozilla Community Participation Guidelines](https://www.mozilla.org/about/governance/policies/participation/) and [Developer Etiquette Guidelines](https://bugzilla.mozilla.org/page.cgi?id=etiquette.html). 8 | 9 | ## How to Get Started 10 | 11 | Please refer to installation and build instructions in the [documentation](install.md). 12 | 13 | ## How to Report Bugs 14 | 15 | Please open [a new issue in the GitHub repository](https://github.com/mozilla-lockbox/lockbox-datastore/issues/new) with steps to reproduce the problem you're experiencing. 16 | 17 | Be sure to include as much information including screenshots, text output, and both your expected and actual results. 18 | 19 | If you believe that you've found a security vulnerability, please report it by sending email to the addresses: security@mozilla.org and lockbox-dev@mozilla.com 20 | 21 | ## How to Request Enhancements 22 | 23 | First, please refer to the applicable [GitHub repository](https://github.com/orgs/mozilla-lockbox/) and search [the repository's GitHub issues](https://github.com/mozilla-lockbox/lockbox-datastore/issues) to make sure your idea has not been (or is not still) considered. 24 | 25 | Then, please [create a new issue in the GitHub repository](https://github.com/mozilla-lockbox/lockbox-datastore/issues/new) describing your enhancement. 26 | 27 | Be sure to include as much detail as possible including step-by-step descriptions, specific examples, screenshots or mockups, and reasoning for why the enhancement might be worthwhile. 28 | 29 | Please keep in mind, by opening an issue we provide no guarantee the enhancement will be implemented. 30 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # Welcome to Lockbox's DataStore documentation 2 | 3 | **Note: This is no longer an actively-developed prototype and not 4 | officially supported.** 5 | 6 | *This is just one component of the Lockbox product. Please see the 7 | [Lockbox website][org-website] for more documentation and context.* 8 | 9 | [org-website]: https://mozilla-lockbox.github.io/ 10 | -------------------------------------------------------------------------------- /docs/install.md: -------------------------------------------------------------------------------- 1 | ## Installing 2 | 3 | To use `lockbox-datastore` in your own projects, install it from the repository: 4 | 5 | ```bash 6 | npm install --save git+https://github.com/mozilla-lockbox/lockbox-datastore.git 7 | ``` 8 | 9 | ## Building 10 | 11 | To build `lockbox-datastore`, first clone this repository then install dependencies: 12 | 13 | ```bash 14 | git clone https://github.com/mozilla-lockbox/lockbox-datastore.git 15 | cd lockbox-datastore 16 | npm install 17 | ``` 18 | 19 | To run tests in a web browser: 20 | 21 | ```bash 22 | npm test 23 | ``` 24 | -------------------------------------------------------------------------------- /docs/releases.md: -------------------------------------------------------------------------------- 1 | # Releasing to npm 2 | 3 | ## Checklist 4 | 5 | Unlike other projects in Lockbox, this project releases new versions as needed by other projects. 6 | 7 | * All finished work is verified to work as expected and committed to `master` 8 | * Engineering and PI have voiced approval to release (e.g., via Slack **#lockbox** channel) 9 | 10 | ## Instructions 11 | 12 | **NOTE:** these instructions assume: 13 | 14 | * All of the [checklist items](#checklist) are complete 15 | * You are an administrator of the project `lockbox-datastore` 16 | * Your local git working copy has a remote named `upstream` pointing to `git@github.com:mozilla-lockbox/lockbox-datastore.git` 17 | 18 | To generate the next release binary: 19 | 20 | 1. Update "version" in `package.json` (and `package-lock.json`): 21 | - We follow the [semver](https://semver.org/) syntax 22 | - _Prerelease_ (prior to _Stable_) have a major version of "0" (e.g., "0.2.0") 23 | - _Beta_ releases will be labeled with "-beta" and an optional number (e.g., "0.2.0-beta" or "0.2.1-beta3") 24 | - _Stable_ releases will **not** be labeled, and follow semver starting with "1.0.0" 25 | 2. Update `CHANGELOG.md`: 26 | - The latest release is at the top, under a second-level header 27 | - Each release includes the sub headings "What's New", "What's Fixed", and "Known Issues" 28 | - Consult with Product Management for wording if needed. 29 | 3. Commit and ultimately merge to `master` branch. 30 | 4. Create a pull request on GitHub [comparing changes from the `master` branch against/to `production`][production-compare] 31 | 5. Tag the latest commit on `production` branch with an annotated version and push the tag: 32 | - `git tag -a -m "Release 0.3.0" 0.3.0` 33 | - `git push upstream 0.3.0` 34 | - Travis-CI will build and publish to [npm] 35 | 6. Send an announcement to the team (e.g., via Slack **#lockbox** channel) 36 | 37 | [production-compare]: https://github.com/mozilla-lockbox/lockbox-datastore/compare/production...master 38 | [npm]: https://npmjs.com/package/lockbox-datastore 39 | -------------------------------------------------------------------------------- /jsdoc.conf.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | opts: { 3 | destination: "./doc/api", 4 | recurse: true 5 | }, 6 | plugins: ["plugins/markdown"], 7 | source: { 8 | include: ["README.md", "./lib"] 9 | } 10 | }; 11 | -------------------------------------------------------------------------------- /karma.conf-base.js: -------------------------------------------------------------------------------- 1 | // Karma configuration 2 | // Generated on Tue Jul 25 2017 09:02:14 GMT-0600 (MDT) 3 | 4 | var PATH = require("path"); 5 | 6 | module.exports = function(config, more) { 7 | let opts = { 8 | // base path that will be used to resolve all patterns (eg. files, exclude) 9 | basePath: ".", 10 | 11 | // frameworks to use 12 | // available frameworks: https://npmjs.org/browse/keyword/karma-adapter 13 | frameworks: ["mocha"], 14 | 15 | // list of files / patterns to load in the browser 16 | files: [ 17 | "test/**/*-test.js" 18 | ], 19 | 20 | // preprocess matching files before serving them to the browser 21 | // available preprocessors: https://npmjs.org/browse/keyword/karma-preprocessor 22 | preprocessors: { 23 | "test/**/*-test.js": ["webpack", "sourcemap"] 24 | }, 25 | 26 | // test results reporter to use 27 | // possible values: "dots", "progress" 28 | // available reporters: https://npmjs.org/browse/keyword/karma-reporter 29 | reporters: ["mocha", "coverage-istanbul", "coverage"], 30 | 31 | //coverage configuration to upload to codecov 32 | coverageReporter: { 33 | type: "lcov", // lcov or lcovonly are required for generating lcov.info files 34 | dir: "coverage/" 35 | }, 36 | 37 | // webpack configuration 38 | webpack: require("./webpack.config.js"), 39 | webpackMiddleware: { 40 | stats: "errors-only" 41 | }, 42 | 43 | // coverage configuration 44 | coverageIstanbulReporter: { 45 | reports: ["html", "text-summary"], 46 | dir: PATH.resolve("coverage/%browser%"), 47 | fixWebpackSourcePaths: true 48 | }, 49 | 50 | // web server port 51 | port: 9876, 52 | // enable / disable colors in the output (reporters and logs) 53 | colors: true, 54 | // level of logging 55 | // possible values: config.LOG_DISABLE || config.LOG_ERROR || config.LOG_WARN || config.LOG_INFO || config.LOG_DEBUG 56 | logLevel: config.LOG_INFO, 57 | // enable / disable watching file and executing tests whenever any file changes 58 | autoWatch: false, 59 | 60 | // Concurrency level 61 | // how many browser should be started simultaneous 62 | concurrency: Infinity 63 | }; 64 | Object.assign(opts, more); 65 | 66 | config.set(opts); 67 | }; 68 | -------------------------------------------------------------------------------- /karma.conf-debug.js: -------------------------------------------------------------------------------- 1 | // Karma configuration 2 | 3 | let setup = require("./karma.conf-base.js"); 4 | 5 | module.exports = function(config) { 6 | setup(config, { 7 | // disable single run 8 | singleRun: false 9 | }); 10 | }; 11 | -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | // Karma configuration 2 | 3 | let setup = require("./karma.conf-base.js"); 4 | 5 | module.exports = function(config) { 6 | setup(config, { 7 | // custom mocha config 8 | client: { 9 | mocha: { 10 | timeout: 10000 11 | } 12 | }, 13 | // single run ONLY 14 | singleRun: true, 15 | customLaunchers: { 16 | FirefoxHeadless: { 17 | base: "Firefox", 18 | flags: "-headless" 19 | } 20 | }, 21 | // start these browsers 22 | // available browser launchers: https://npmjs.org/browse/keyword/karma-launcher 23 | browsers: ["FirefoxHeadless"] 24 | }); 25 | }; 26 | -------------------------------------------------------------------------------- /lib/constants.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. 5 | */ 6 | 7 | /** 8 | * Default keystore identifier. 9 | * 10 | * @memberof ItemKeyStore 11 | */ 12 | const DEFAULT_KEYSTORE_ID = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; 13 | 14 | /** 15 | * Default keystore group name. 16 | * 17 | * @memberof ItemKeyStore 18 | */ 19 | const DEFAULT_KEYSTORE_GROUP = ""; 20 | 21 | Object.assign(exports, { 22 | DEFAULT_KEYSTORE_ID, 23 | DEFAULT_KEYSTORE_GROUP 24 | }); 25 | -------------------------------------------------------------------------------- /lib/datastore.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. 5 | */ 6 | 7 | const Items = require("./items"), 8 | ItemKeyStore = require("./itemkeystore"), 9 | DataStoreError = require("./util/errors"), 10 | constants = require("./constants"), 11 | instance = require("./util/instance"), 12 | localdatabase = require("./localdatabase"), 13 | jose = require("node-jose"); 14 | 15 | // SHA-256("lockbox encrypt") 16 | const HKDF_INFO_ENCRYPT = "9UUucG8PDHPGXwM-pGoT0-aFGu74M54k55AykEgOx98"; 17 | // SHA-256("lockbox hashing") 18 | const HKDF_INFO_HASHING = "pz8gGLGYNLV6haKwjJ1dR-YKX5zDMhPHw2DuXGNu6cw"; 19 | 20 | async function deriveKeys(appKey, salt) { 21 | if (!appKey) { 22 | throw new DataStoreError(DataStoreError.MISSING_APP_KEY); 23 | } 24 | appKey = await jose.JWK.asKey(appKey); 25 | 26 | salt = Buffer.from(salt || ""); 27 | 28 | let keyval = appKey.get("k", true); 29 | let encryptKey = await jose.JWA.derive("HKDF-SHA-256", keyval, { 30 | salt, 31 | info: jose.util.base64url.decode(HKDF_INFO_ENCRYPT) 32 | }); 33 | encryptKey = await jose.JWK.asKey({ 34 | kty: "oct", 35 | alg: "A256GCM", 36 | k: encryptKey 37 | }); 38 | 39 | let hashingKey = await jose.JWA.derive("HKDF-SHA-256", keyval, { 40 | salt, 41 | info: jose.util.base64url.decode(HKDF_INFO_HASHING) 42 | }); 43 | hashingKey = await jose.JWK.asKey({ 44 | kty: "oct", 45 | alg: "HS256", 46 | k: hashingKey 47 | }); 48 | 49 | return { 50 | encryptKey, 51 | hashingKey, 52 | salt 53 | }; 54 | } 55 | 56 | function checkState(ds, unlocking) { 57 | if (!ds.initialized) { 58 | throw new DataStoreError(DataStoreError.NOT_INITIALIZED); 59 | } 60 | if (!unlocking && ds.locked) { 61 | throw new DataStoreError(DataStoreError.LOCKED); 62 | } 63 | } 64 | 65 | function determineItemChanges(prev, next) { 66 | // TODO: calculate JSON-merge diff 67 | 68 | prev = prev || {}; 69 | next = next || {}; 70 | next = Object.assign({}, prev, next); 71 | 72 | let fields = []; 73 | // check title 74 | if (prev.title !== next.title) { 75 | fields.push("title"); 76 | } 77 | // check previns 78 | let prevOrigins = [...(prev.origins || [])]; 79 | let nextOrigins = [...(next.origins || [])]; 80 | if (prevOrigins.length !== nextOrigins.length || !prevOrigins.every((d) => nextOrigins.indexOf(d) !== -1)) { 81 | fields.push("origins"); 82 | } 83 | 84 | // check entries.(username,password,notes) 85 | let prevEntry = prev.entry || {}; 86 | let nextEntry = next.entry || {}; 87 | ["username", "password", "notes"].forEach((f) => { 88 | if (prevEntry[f] !== nextEntry[f]) { 89 | fields.push(`entry.${f}`); 90 | } 91 | }); 92 | fields = fields.join(","); 93 | 94 | return { 95 | fields 96 | }; 97 | } 98 | 99 | /** 100 | * Represents item storage. 101 | */ 102 | class DataStore { 103 | /** 104 | * Creates a new DataStore. 105 | * 106 | * **NOTE:** This constructor is not called directly. Instead call 107 | * {@link open} to obtain a [prepared]{@link DataStore#prepare} instance. 108 | * 109 | * See {@link datastore.create} for the details of what `{cfg}` 110 | * parameters are supported. 111 | * 112 | * @param {Object} cfg The configuration parameters. 113 | * @constructor 114 | */ 115 | constructor(cfg) { 116 | cfg = cfg || {}; 117 | 118 | let self = instance.stage(this); 119 | self.items = new Map(); 120 | 121 | self.bucket = cfg.bucket; 122 | self.salt = cfg.salt || ""; 123 | self.recordMetric = cfg.recordMetric || (async () => {}); 124 | 125 | // TESTING ONLY: accept an (encrypted) item keys map 126 | self.keystore = new ItemKeyStore({ 127 | encrypted: cfg.keys 128 | }); 129 | } 130 | 131 | /** 132 | * Prepares this DataStore. This method: 133 | * 134 | * 1. initializes and opens the local database; and 135 | * 2. loads any stored keys from the local database. 136 | * 137 | * If the database is already prepared, this method does nothing. 138 | * 139 | * @returns {DataStore} This DataStore. 140 | */ 141 | async prepare() { 142 | let self = instance.get(this); 143 | 144 | let ldb = self.ldb; 145 | if (!ldb) { 146 | ldb = await localdatabase.open(self.bucket); 147 | let keystore = await ldb.keystores.get(constants.DEFAULT_KEYSTORE_GROUP); 148 | if (!keystore) { 149 | keystore = self.keystore.toJSON(); 150 | } 151 | 152 | keystore = new ItemKeyStore(keystore); 153 | Object.assign(self, { 154 | ldb, 155 | keystore 156 | }); 157 | } 158 | 159 | return this; 160 | } 161 | 162 | /** 163 | * Indicates whether this DataStore is initialized. 164 | * 165 | * @type {boolean} 166 | * @readonly 167 | */ 168 | get initialized() { 169 | return !!(instance.get(this).keystore.encrypted); 170 | } 171 | 172 | /** 173 | * Initializes this DataStore with the given options. This method 174 | * creates an empty item keystore, and encrypts it using the password 175 | * specified in `{opts}`. 176 | * 177 | * **NOTE:** If {salt} is provided here, it overrides and replaces any 178 | * previously set salt, including from {DataStore.open}. 179 | * 180 | * @param {Object} opts The initialization options 181 | * @param {jose.JWK.Key} [opts.appKey] The master app key to setup with 182 | * @param {string} [opts.salt] The salt to use in deriving the master 183 | * keys 184 | * @param {boolean} [opts.rebase=false] Rebase an already initialized 185 | * DataStore to use a new password 186 | * @returns {DataStore} This datastore 187 | */ 188 | async initialize(opts) { 189 | // TODO: remove this when everything is prepared 190 | await this.prepare(); 191 | 192 | opts = opts || {}; 193 | let self = instance.get(this); 194 | 195 | // TODO: deal with soft reset 196 | let listing = undefined; 197 | if (self.keystore.encrypted) { 198 | if (!opts.rebase) { 199 | throw new DataStoreError(DataStoreError.INITIALIZED); 200 | } else if (opts.rebase && this.locked) { 201 | throw new DataStoreError(DataStoreError.LOCKED, "must be unlocked in order to rebase"); 202 | } 203 | 204 | // migrate it out 205 | listing = await self.keystore.all(); 206 | } 207 | 208 | opts = opts || {}; 209 | let appKey = opts.appKey, 210 | salt = self.salt; 211 | if ("salt" in opts) { 212 | // if salt is present in any form, replace it 213 | salt = opts.salt; 214 | } 215 | let { encryptKey, hashingKey } = await deriveKeys(appKey, salt); 216 | let keystore = new ItemKeyStore({ 217 | encryptKey, 218 | hashingKey, 219 | listing 220 | }); 221 | self.keystore = await keystore.save(); 222 | self.salt = salt; 223 | await self.ldb.keystores.put(self.keystore.toJSON()); 224 | 225 | return this; 226 | } 227 | /** 228 | * Resets this Datastore. This method deletes all items and keys stored. 229 | * This is not a recoverable action. 230 | * 231 | * @returns {DataStore} This datastore instance 232 | */ 233 | async reset() { 234 | if (this.initialized) { 235 | let self = instance.get(this); 236 | await self.ldb.delete(); 237 | self.keystore.clear(true); 238 | delete self.ldb; 239 | } 240 | 241 | return this; 242 | } 243 | 244 | /** 245 | * Indicates if this datastore is locked or unlocked. 246 | * 247 | * @type {boolean} 248 | * @readonly 249 | */ 250 | get locked() { 251 | return !(instance.get(this).keystore.encryptKey); 252 | } 253 | /** 254 | * Locks this datastore. 255 | * 256 | * @returns {DataStore} This DataStore once locked 257 | */ 258 | async lock() { 259 | let self = instance.get(this); 260 | 261 | await self.keystore.clear(); 262 | 263 | return this; 264 | } 265 | /** 266 | * Attempts to unlock this datastore. 267 | * 268 | * @param {jose.JWK.Key} appKey The application key to unlock the datastore 269 | * @returns {DataStore} This DataStore once unlocked 270 | */ 271 | async unlock(appKey) { 272 | checkState(this, true); 273 | 274 | let self = instance.get(this); 275 | let { keystore } = self; 276 | 277 | if (!this.locked) { 278 | // fast win 279 | return this; 280 | } 281 | 282 | try { 283 | let { encryptKey } = await deriveKeys(appKey, self.salt); 284 | await keystore.load(encryptKey); 285 | } catch (err) { 286 | // TODO: differentiate errors? 287 | throw err; 288 | } 289 | 290 | return this; 291 | } 292 | 293 | /** 294 | * Retrieves all of the items stored in this DataStore. 295 | * 296 | * @returns {Map} The map of stored item, by id 297 | */ 298 | async list() { 299 | checkState(this); 300 | 301 | let self = instance.get(this); 302 | let all; 303 | all = await self.ldb.items.toArray(); 304 | all = all.map(async i => { 305 | let { id, encrypted } = i; 306 | let item = await self.keystore.unprotect(id, encrypted); 307 | return [ id, item ]; 308 | }); 309 | all = await Promise.all(all); 310 | 311 | let result = new Map(all); 312 | 313 | return result; 314 | } 315 | 316 | /** 317 | * Retrieves a single item from this DataStore 318 | * 319 | * @param {string} id The item id to retrieve 320 | * @returns {Object} The JSON representing the item, or `null` if there is 321 | * no item for `{id}` 322 | */ 323 | async get(id) { 324 | checkState(this); 325 | 326 | let self = instance.get(this); 327 | let one = await self.ldb.items.get(id); 328 | if (one) { 329 | one = one.encrypted; 330 | one = await self.keystore.unprotect(id, one); 331 | } 332 | return one || null; 333 | } 334 | /** 335 | * Adds a new item to this DataStore. 336 | * 337 | * The `{id}` of the item is replaced with a new UUID. 338 | * 339 | * @param {Object} item The item to add 340 | * @returns {Object} The added item, with all fields completed 341 | * @throws {TypeError} if `item` is invalid 342 | */ 343 | async add(item) { 344 | checkState(this); 345 | 346 | let self = instance.get(this); 347 | if (!item) { 348 | throw new DataStoreError(DataStoreError.INVALID_ITEM); 349 | } 350 | 351 | // validate, and fill defaults into, {item} 352 | item = Items.prepare(item); 353 | 354 | let id = item.id, 355 | active = !item.disabled ? "active" : "", 356 | encrypted = await self.keystore.protect(item); 357 | 358 | let record = { 359 | id, 360 | active, 361 | encrypted, 362 | last_modified: undefined, 363 | }; 364 | let ldb = self.ldb; 365 | await self.keystore.save(); 366 | await ldb.transaction("rw", ldb.items, ldb.keystores, () => { 367 | ldb.items.add(record); 368 | ldb.keystores.put(self.keystore.toJSON()); 369 | }); 370 | self.recordMetric("added", item.id); 371 | 372 | return item; 373 | } 374 | /** 375 | * Updates an existing item in this DataStore. 376 | * 377 | * `{item}` is expected to be a complete object; any (mutable) fields missing 378 | * are removed from the stored value. API users should call {@link #get}, 379 | * then make the desired changes to the returned value. 380 | * 381 | * @param {Object} item The item to update 382 | * @returns {Object} The updated item 383 | * @throws {Error} if this item does not exist 384 | * @throws {TypeError} if `item` is not an object with a `id` member 385 | * @throws {DataStoreError} if the `item` violates the schema 386 | */ 387 | async update(item) { 388 | checkState(this); 389 | 390 | let self = instance.get(this); 391 | if (!item || !item.id) { 392 | throw new DataStoreError(DataStoreError.INVALID_ITEM); 393 | } 394 | 395 | let id = item.id; 396 | let record = await self.ldb.items.get(id); 397 | let orig , encrypted; 398 | if (!record) { 399 | throw new DataStoreError(DataStoreError.MISSING_ITEM); 400 | } else { 401 | encrypted = record.encrypted; 402 | } 403 | 404 | orig = await self.keystore.unprotect(id, encrypted); 405 | item = Items.prepare(item, orig); 406 | 407 | let changes = determineItemChanges(orig, item); 408 | 409 | let active = !item.disabled ? "active" : ""; 410 | encrypted = await self.keystore.protect(item); 411 | 412 | record = { 413 | id, 414 | active, 415 | encrypted, 416 | last_modified: record.last_modified 417 | }; 418 | await self.ldb.items.put(record); 419 | self.recordMetric("updated", item.id, changes.fields); 420 | 421 | return item; 422 | } 423 | /** 424 | * Touches an existing item in this DataStore. 425 | * 426 | * `{item}` is expected to be a complete object. API users should call 427 | * {@link #get}, then pass the returned value to `{touch}` 428 | * 429 | * @param {Object} item The item to touch 430 | * @returns {Object} The updated item 431 | * @throws {Error} if this item does not exist 432 | * @throws {TypeError} if `item` is not an object with a `id` member 433 | * @throws {DataStoreError} if the `item` violates the schema 434 | */ 435 | async touch(item) { 436 | checkState(this); 437 | 438 | let self = instance.get(this); 439 | if (!item || !item.id) { 440 | throw new DataStoreError(DataStoreError.INVALID_ITEM); 441 | } 442 | 443 | let id = item.id; 444 | let record = await self.ldb.items.get(id); 445 | let orig, encrypted; 446 | if (!record) { 447 | throw new DataStoreError(DataStoreError.MISSING_ITEM); 448 | } else { 449 | encrypted = record.encrypted; 450 | } 451 | 452 | orig = await self.keystore.unprotect(id, encrypted); 453 | 454 | orig.last_used = new Date().toISOString(); 455 | encrypted = await self.keystore.protect(orig); 456 | 457 | record = { 458 | id, 459 | encrypted, 460 | last_modified: record.last_modified 461 | }; 462 | 463 | await self.ldb.items.put(record); 464 | self.recordMetric("touched", item.id); 465 | return orig; 466 | } 467 | /** 468 | * Removes an item from this DataStore. 469 | * 470 | * @param {string} id The item id to remove 471 | * @returns {Object} The removed item, or `null` if no item was removed 472 | */ 473 | async remove(id) { 474 | checkState(this); 475 | 476 | let self = instance.get(this); 477 | let item = await self.ldb.items.get(id); 478 | if (item) { 479 | item = await self.keystore.unprotect(id, item.encrypted); 480 | self.keystore.delete(id); 481 | 482 | let ldb = self.ldb; 483 | await self.keystore.save(); 484 | await ldb.transaction("rw", ldb.items, ldb.keystores, () => { 485 | ldb.items.delete(id); 486 | ldb.keystores.put(self.keystore.toJSON()); 487 | }); 488 | self.recordMetric("deleted", item.id); 489 | } 490 | 491 | return item || null; 492 | } 493 | } 494 | 495 | module.exports = DataStore; 496 | -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. 5 | */ 6 | 7 | const DataStore = require("./datastore"), 8 | DataStoreError = require("./util/errors"); 9 | 10 | /** 11 | * Creates a new {DataStore} using the given configuration, and prepares it 12 | * for use. This method calls {@link DataStore#prepare}, returning the 13 | * (promised) DataStore once it's ready for use. 14 | * 15 | * The signature for `{recordMetric}` (if provided) should conform to 16 | * the following: 17 | * 18 | * ``` 19 | * async function recordMetric(method, id, fields) { 20 | * // {method} is one of "added" | "updated" | "deleted" 21 | * // {id} is the item.id 22 | * // {fields} either: 23 | * // * array strings denoting which fields were changed (if method === "updated") 24 | * // * undefined (otherwise) 25 | * } 26 | * ``` 27 | * 28 | * @param {Object} [cfg] - The configuration parameters 29 | * @param {string} [cfg.bucket="lockbox"] - The bucket to persist data to 30 | * @param {string} [cfg.salt] - the salt to use for key derivations 31 | * @param {Function} [cfg.recordMetric] - The function to record item metrics 32 | * events 33 | * @returns {DataStore} A new (prepared) DataStore 34 | */ 35 | async function open(cfg) { 36 | return (new DataStore(cfg)).prepare(); 37 | } 38 | 39 | Object.assign(exports, { 40 | DataStore, 41 | DataStoreError, 42 | open 43 | }); 44 | -------------------------------------------------------------------------------- /lib/itemkeystore.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. 5 | */ 6 | 7 | const instance = require("./util/instance"), 8 | jose = require("node-jose"), 9 | constants = require("./constants"), 10 | DataStoreError = require("./util/errors"); 11 | 12 | /** 13 | * Manages a set of item keys. This includes generation, encryption, and 14 | * decryption of the set. 15 | */ 16 | class ItemKeyStore { 17 | /** 18 | * Creates a new ItemKeyStore. 19 | * 20 | * @param {Object} [self] The properties of this ItemKeyStore 21 | * @param {jose.JWK.Key} [self.encryptKey] The master encryption key 22 | * @param {string} [self.encrypted] The initial encrypted item key set to 23 | * use 24 | */ 25 | constructor(self) { 26 | self = instance.stage(this, self); 27 | 28 | // prepare some required properties 29 | self.id = self.id || constants.DEFAULT_KEYSTORE_ID; 30 | self.listing = self.listing || new Map(); 31 | } 32 | 33 | /** 34 | * The master encryption key. 35 | * 36 | * @type {jose.JWK.Key} 37 | * @readonly 38 | */ 39 | get encryptKey() { return instance.get(this).encryptKey || undefined; } 40 | 41 | /** 42 | * The keystore identifier. 43 | * 44 | * @type {string} 45 | * @readonly 46 | */ 47 | get id() { return instance.get(this).id || constants.DEFAULT_KEYSTORE_ID; } 48 | /** 49 | * The group name. 50 | * 51 | * @type {string} 52 | * @readonly 53 | */ 54 | get group() { return instance.get(this).group || constants.DEFAULT_KEYSTORE_GROUP; } 55 | /** 56 | * The encrypted key set, as a Compact JWE. 57 | * 58 | * @type {string} 59 | * @readonly 60 | */ 61 | get encrypted() { return instance.get(this).encrypted || undefined; } 62 | 63 | /** 64 | * Creates a JSON representation of this ItemKeyStore. This method returns 65 | * a plain Object with the following properties: 66 | * 67 | * * `group` {**String**} The name of the keystore's group 68 | * * `encrypted` {**Stirng**} The encrypted map of keys 69 | * 70 | * @returns {Object} The JSON representation 71 | */ 72 | toJSON() { 73 | return { 74 | id: this.id, 75 | group: this.group, 76 | encrypted: this.encrypted, 77 | last_modified: this.last_modified || undefined, 78 | }; 79 | } 80 | 81 | /** 82 | * Loads the item keys. This method decrypts the stored encrypted key set 83 | * using the current or specified master encryption key. 84 | * 85 | * If `{master}` is provided, it overwrites the current master encryption 86 | * key. 87 | * 88 | * @param {jose.JWK.Key} [master] The new master encryption key to use 89 | * @returns {ItemKeyStore} This ItemKeyStore 90 | * @throws {Error} If the master encryption key is not set, or there is 91 | * no encrypted key set, or encryption fails 92 | */ 93 | async load(master) { 94 | let self = instance.get(this); 95 | let { encryptKey, encrypted } = self; 96 | if (master) { 97 | encryptKey = master; 98 | } 99 | 100 | if (!encryptKey) { 101 | throw new DataStoreError(DataStoreError.MISSING_APP_KEY, "missing master encrypt key"); 102 | } 103 | if (!encrypted) { 104 | throw new DataStoreError(DataStoreError.CRYPTO, "keystore not encrypted"); 105 | } 106 | 107 | let keystore = jose.JWK.createKeyStore(), 108 | decrypted = await jose.JWE.createDecrypt(encryptKey).decrypt(encrypted); 109 | decrypted = decrypted.payload.toString("utf8"); 110 | decrypted = JSON.parse(decrypted); 111 | decrypted = await Promise.all(Object.entries(decrypted).map(async (e) => { 112 | let [id, key] = e; 113 | key = await keystore.add(key); 114 | return [id, key]; 115 | })); 116 | 117 | let listing = self.listing = new Map(); 118 | decrypted.forEach(entry => { 119 | let [id, key] = entry; 120 | listing.set(id, key); 121 | }); 122 | self.encryptKey = encryptKey; 123 | 124 | return this; 125 | } 126 | /** 127 | * Saves the item keys. This method serializes the item keys into and 128 | * encrypts them using the current master encryption key. 129 | * 130 | * Once this method completes, `{encrypted}` is set to the newly encrypted 131 | * key set. 132 | * 133 | * @returns {ItemKeyStore} This ItemKeyStore 134 | * @throws {Error} If the master encryption key is invalid, or encryption 135 | * fails 136 | */ 137 | async save() { 138 | let self = instance.get(this); 139 | let { 140 | encryptKey, 141 | listing 142 | } = self; 143 | 144 | if (!jose.JWK.isKey(encryptKey)) { 145 | throw new DataStoreError(DataStoreError.MISSING_APP_KEY, "missing master encrypt key"); 146 | } 147 | 148 | let encrypted = {}; 149 | for (let entry of listing.entries()) { 150 | let [id, key] = entry; 151 | encrypted[id] = key.toJSON(true); 152 | } 153 | encrypted = JSON.stringify(encrypted); 154 | 155 | let params = { 156 | format: "compact", 157 | contentAlg: "A256GCM", 158 | fields: { 159 | alg: "dir" 160 | } 161 | }; 162 | encrypted = await jose.JWE.createEncrypt(params, encryptKey).final(encrypted, "utf8"); 163 | 164 | Object.assign(self, { 165 | encrypted 166 | }); 167 | 168 | return this; 169 | } 170 | /** 171 | * Clears all decrypted values. Optionally, it also clears the 172 | * encrypted store. 173 | * 174 | * @param {boolean} [all=false] `true` to also delete encrypted keys 175 | * @return {ItemKeyStore} This ItemKeyStore 176 | */ 177 | async clear(all) { 178 | let self = instance.get(this); 179 | 180 | self.listing = new Map(); 181 | delete self.encryptKey; 182 | if (all) { 183 | delete self.encrypted; 184 | } 185 | 186 | return this; 187 | } 188 | 189 | /** 190 | * The number of item keys available (not encrypted). 191 | * 192 | * @type {number} 193 | * @readonly 194 | */ 195 | get size() { return instance.get(this).listing.size; } 196 | /** 197 | * Retrieves all of the item keys. The returned map is a snapshot of 198 | * the current state, and will not reflect any changes made after this 199 | * method is called. 200 | * 201 | * @returns {Map} The list of item keys. 202 | */ 203 | async all() { 204 | return new Map(instance.get(this).listing); 205 | } 206 | /** 207 | * Retrieves an item key for the given id. 208 | * 209 | * @param {string} id The item key identifier 210 | * @returns {jose.JWK.Key} The item key, or `undefined` if not known 211 | */ 212 | async get(id) { 213 | return instance.get(this).listing.get(id); 214 | } 215 | /** 216 | * Adds an item key for the given id. If a key for `{id}` is already 217 | * known, it is returned instead of generating a new one. 218 | * 219 | * @param {string} id The item key identifier 220 | * @returns {jose.JWK.Key} The new or existing item key 221 | */ 222 | async add(id) { 223 | let key = await this.get(id); 224 | if (!key) { 225 | key = await jose.JWK.createKeyStore().generate("oct", 256, { 226 | alg: "A256GCM", 227 | kid: id 228 | }); 229 | instance.get(this).listing.set(id, key); 230 | } 231 | return key; 232 | } 233 | /** 234 | * Removes the item key for the given id. 235 | * 236 | * @param {string} id The item key identifier 237 | * @returns {void} 238 | */ 239 | async delete(id) { 240 | let listing = instance.get(this).listing; 241 | delete listing.delete(id); 242 | } 243 | 244 | /** 245 | * Encrypts an item. 246 | * 247 | * The `{item}` is expected to have a string `id` property, which is 248 | * used to match it its key. If a key for the item's id does not exist, 249 | * it is first created. 250 | * 251 | * @param {Object} item The item to encrypt 252 | * @returns {string} The encrypted item as a compact JWE 253 | * @throws {Error} if `{item}` is invalid, or if encryption failed 254 | */ 255 | async protect(item) { 256 | if (!item || !item.id) { 257 | throw new DataStoreError(DataStoreError.INVALID_ITEM); 258 | } 259 | 260 | let { id } = item; 261 | let key = await this.add(id); 262 | 263 | item = JSON.stringify(item); 264 | let jwe = jose.JWE.createEncrypt({ format: "compact" }, key); 265 | jwe = await jwe.final(item, "utf8"); 266 | 267 | return jwe; 268 | } 269 | /** 270 | * Decrypts an item. 271 | * 272 | * @param {string} id The item id 273 | * @param {string} jwe The encrypted item as a compact JWE 274 | * @returns {Object} The decrypted item 275 | * @throws {Error} if `{id}` or `{jwe}` is invalid, or if decryption failed 276 | */ 277 | async unprotect(id, jwe) { 278 | if (!jwe || "string" !== typeof jwe) { 279 | throw new DataStoreError(DataStoreError.CRYPTO, "invalid encrypted item"); 280 | } 281 | 282 | let key = await this.get(id); 283 | if (!key) { 284 | throw new DataStoreError(DataStoreError.CRYPTO, "unknown item key"); 285 | } 286 | 287 | let item = await jose.JWE.createDecrypt(key).decrypt(jwe); 288 | item = item.payload.toString("utf8"); 289 | item = JSON.parse(item); 290 | 291 | return item; 292 | } 293 | } 294 | 295 | module.exports = ItemKeyStore; 296 | -------------------------------------------------------------------------------- /lib/items.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. 5 | */ 6 | 7 | const jsonmergepatch = require("json-merge-patch"), 8 | joi = require("joi"), 9 | UUID = require("uuid"); 10 | 11 | const DataStoreError = require("./util/errors"); 12 | 13 | const STRING_500 = joi.string().max(500).allow("").allow(null).default(""); 14 | const STRING_10K = joi.string().max(10000).allow("").allow(null).default(""); 15 | const BASE_ENTRY_SCHEMA = joi.object().keys({ 16 | kind: joi.string().required(), 17 | notes: STRING_10K 18 | }); 19 | const ENTRY_SCHEMAS = [ 20 | BASE_ENTRY_SCHEMA.keys({ 21 | kind: "login", 22 | username: STRING_500, 23 | password: STRING_500 24 | }), 25 | ]; 26 | const SCHEMA = joi.object().keys({ 27 | title: STRING_500, 28 | origins: joi.array().items(STRING_500).max(5).default([]), 29 | tags: joi.array().items(STRING_500).max(10).default([]), 30 | entry: joi.alternatives(ENTRY_SCHEMAS).required(), 31 | }); 32 | const VALIDATE_OPTIONS = { 33 | abortEarly: false, 34 | stripUnknown: true 35 | }; 36 | 37 | const HISTORY_MAX = 100; 38 | 39 | function prepare(item, source) { 40 | // strip out anything not in the whitelist 41 | let { error, value: destination } = SCHEMA.validate(item, VALIDATE_OPTIONS); 42 | if (error) { 43 | let details = error.details; 44 | let thrown = new DataStoreError(DataStoreError.INVALID_ITEM); 45 | thrown.details = {}; 46 | details.forEach((d) => { 47 | let path = Array.isArray(d.path) ? d.path.join(".") : d.path; 48 | thrown.details[path] = d.type; 49 | }); 50 | throw thrown; 51 | } 52 | 53 | // apply read-only values 54 | source = source || {}; 55 | destination.id = source.id || UUID(); 56 | destination.last_used = source.last_used || null; 57 | destination.disabled = source.disabled || false; 58 | destination.created = source.created || new Date().toISOString(); 59 | // always assume the item is modified 60 | destination.modified = new Date().toISOString(); 61 | 62 | // generate history patch (to go backward) 63 | let history = []; 64 | if (source && source.entry) { 65 | history = (source.history || []).slice(0, HISTORY_MAX - 1); 66 | let dstEntry = destination.entry, 67 | srcEntry = source.entry; 68 | let patch = jsonmergepatch.generate(dstEntry, srcEntry); 69 | if (undefined !== patch) { 70 | history.unshift({ 71 | created: new Date().toISOString(), 72 | patch 73 | }); 74 | } 75 | } 76 | destination.history = history; 77 | 78 | return destination; 79 | } 80 | 81 | Object.assign(exports, { 82 | prepare 83 | }); 84 | -------------------------------------------------------------------------------- /lib/localdatabase.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. 5 | */ 6 | 7 | const DataStoreError = require("./util/errors"), 8 | constants = require("./constants"); 9 | 10 | const indexedDB = require("fake-indexeddb"), 11 | IDBKeyRange = require("fake-indexeddb/lib/FDBKeyRange"); 12 | 13 | const Dexie = ((module) => { 14 | if (module.__esModule && "default" in module) { 15 | return module.default; 16 | } 17 | return module; 18 | })(require("dexie")); 19 | 20 | // Prepare Dexie 21 | if (Object.keys(indexedDB).length && Object.keys(IDBKeyRange).length) { 22 | Object.assign(Dexie.dependencies, { 23 | indexedDB, 24 | IDBKeyRange 25 | }); 26 | } 27 | 28 | /** 29 | * Default bucket name to use for {@link localdatabase.open}. 30 | * 31 | * @memberof localdatabase 32 | */ 33 | const DEFAULT_BUCKET = "lockboxdatastore"; 34 | /** 35 | * The current (local) database version number. 36 | * 37 | * @memberof localdatabase 38 | */ 39 | const DATABASE_VERSION = 0.2; 40 | 41 | let DATABASES; 42 | 43 | const VERSIONS = { 44 | "0.1": (db) => ( 45 | db.version(0.1).stores({ 46 | items: "id,active,*origins,*tags", 47 | keystores: "group,uuid" 48 | }) 49 | ), 50 | "0.2": (db) => ( 51 | db.version(0.2).stores({ 52 | keystores: "group,id" 53 | }).upgrade((tx) => { 54 | // upgrade keystores 55 | tx.keystores.toCollection().modify((ks) => { 56 | if (ks.group) { 57 | // eslint-disable-next-line no-console 58 | console.error(`WARNING: non-default keystore found "${ks.group}"`); 59 | } else { 60 | ks.id = constants.DEFAULT_KEYSTORE_ID; 61 | } 62 | // remove extraneous (and invalid) uuid 63 | delete ks.uuid; 64 | }); 65 | }) 66 | ) 67 | }; 68 | 69 | /** 70 | * Opens a (Dexie) Database with the given (bucket) name. This method: 71 | * 1. creates a new Dexie instance; 72 | * 2. initializes up to the latest; and 73 | * 3. opens the database 74 | * 75 | * @param {string} [bucket] - The name of the database. 76 | * @returns {Dexie} The initialized and opened Dexie database. 77 | * @memberof localdatabase 78 | */ 79 | async function open(bucket) { 80 | let db = new Dexie(bucket = bucket || DEFAULT_BUCKET); 81 | 82 | // setup versions 83 | // NOTE: this looks a little convoluted ... 84 | // .. but best helps with testing while being explicit. 85 | VERSIONS["0.1"](db); 86 | VERSIONS["0.2"](db); 87 | 88 | if (DATABASES) { 89 | DATABASES.add(db); 90 | } 91 | 92 | try { 93 | await db.open(); 94 | } catch (err) { 95 | if (err instanceof Dexie.VersionError) { 96 | throw new DataStoreError(DataStoreError.LOCALDB_VERSION); 97 | } 98 | throw new DataStoreError(DataStoreError.GENERIC_ERROR, err.message); 99 | } 100 | return db; 101 | } 102 | 103 | /** 104 | * Starts up testing by remembering every Dexie database created. 105 | * 106 | * **NOTE**: This method is only for testing purposes! 107 | * 108 | * @returns {void} 109 | * @private 110 | * @memberof localdatabase 111 | */ 112 | async function startup() { 113 | DATABASES = new Set(); 114 | } 115 | /** 116 | * Tears down testing by deleting all opened Dexie databases. 117 | * 118 | * **NOTE**: This method is only for testing purposes! 119 | * 120 | * @returns {void} 121 | * @private 122 | * @memberof localdatabase 123 | */ 124 | async function teardown() { 125 | if (!DATABASES) { 126 | return; 127 | } 128 | 129 | let all = [...DATABASES]; 130 | all = all.map(async db => db.delete()); 131 | await Promise.all(all); 132 | } 133 | 134 | Object.assign(exports, { 135 | open, 136 | teardown, 137 | startup, 138 | DEFAULT_BUCKET, 139 | DATABASE_VERSION, 140 | VERSIONS, 141 | Dexie 142 | }); 143 | -------------------------------------------------------------------------------- /lib/util/errors.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. 5 | */ 6 | 7 | const REASONS = [ 8 | "LOCALDB_VERSION", 9 | "NOT_INITIALIZED", 10 | "INITIALIZED", 11 | "MISSING_APP_KEY", 12 | "WRONG_APP_KEY", 13 | "CRYPTO", 14 | "LOCKED", 15 | "INVALID_ITEM", 16 | "MISSING_ITEM", 17 | "OFFLINE", 18 | "AUTH", 19 | "NETWORK", 20 | "SYNC_LOCKED", 21 | "GENERIC_ERROR" 22 | ]; 23 | 24 | /** 25 | * Errors specific to DataStore operations. 26 | * 27 | */ 28 | class DataStoreError extends Error { 29 | /** 30 | * @constructs 31 | * Creates a new DataStoreError with the given message and reason. 32 | * 33 | * @param {string} [reason=DataStoreError.GENERIC_ERROR] - The reason for the error 34 | * @param {string} [message] - The error message 35 | */ 36 | constructor(reason, message) { 37 | if (-1 === REASONS.indexOf(reason)) { 38 | message = reason; 39 | reason = null; 40 | } 41 | if (!reason) { 42 | reason = DataStoreError.GENERIC_ERROR; 43 | } 44 | if (!message) { 45 | message = reason; 46 | } else { 47 | message = `${reason}: ${message}`; 48 | } 49 | 50 | super(message); 51 | this.name = this.constructor.name; 52 | if ("function" === Error.captureStackTrace) { 53 | Error.captureStackTrace(this, this.constructor); 54 | } else { 55 | this.stack = (new Error(message)).stack; 56 | } 57 | 58 | /** 59 | * The reason name for this DataStoreError. 60 | * 61 | * @member {string} 62 | */ 63 | this.reason = reason; 64 | } 65 | } 66 | 67 | REASONS.forEach((r) => DataStoreError[r] = r); 68 | 69 | /** 70 | * When opening a local database, the actual database version does not match the expected version. 71 | * @member {string} DataStoreError.LOCALDB_VERSION 72 | */ 73 | /** 74 | * The datastore is not yet initialized. 75 | * @member {string} DataStoreError.NOT_INITIALIZED 76 | */ 77 | /** 78 | * An attempt was made to initialize a datastore that is already initialized. 79 | * @member {string} DataStoreError.INITIALIZED 80 | */ 81 | /** 82 | * No master key was provided. 83 | * @member {string} DataStoreError.MISSING_APP_KEY 84 | */ 85 | /** 86 | * The master key is not valid for the encrypted data. 87 | * @member {string} DataStoreError.WRONG_APP_KEY 88 | */ 89 | /** 90 | * An attempt was made to use a datastore that is still locked. 91 | * @member {string} DataStoreError.LOCKED 92 | */ 93 | /** 94 | * The item to be added/updated is invalid. 95 | * @member {string} DataStoreError.INVALID_ITEM 96 | */ 97 | /** 98 | * The item to be updated does not exist. 99 | * @member {string} DataStoreError.MISSING_ITEM 100 | */ 101 | /** 102 | * There was a cryptographic error. 103 | * @member {string} DataStoreError.CRYPTO 104 | */ 105 | /** 106 | * An operation requires network connectivity, but there is none. 107 | * @member {string} DataStoreError.OFFLINE 108 | */ 109 | /** 110 | * An operation requires (remote) authentication before it can be performed. 111 | * @member {string} DataStoreError.AUTH 112 | */ 113 | /** 114 | * An operation encountered a (generic) network error. 115 | * @member {string} DataStoreError.NETWORK 116 | */ 117 | /** 118 | * An attempt was made to sync a datastore, but cannot be completed until unlocked. 119 | * @member {string} DataStoreError.SYNC_LOCKED 120 | */ 121 | /** 122 | * An otherwise unspecified error occurred with the datastore. 123 | * @member {string} DataStoreError.GENERIC_ERROR 124 | */ 125 | 126 | module.exports = DataStoreError; 127 | -------------------------------------------------------------------------------- /lib/util/instance.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. 5 | */ 6 | 7 | const INST_DATA = new WeakMap(); 8 | 9 | function get(self) { return INST_DATA.get(self); } 10 | 11 | function stage(self, ref) { 12 | let data = get(self); 13 | // assume {ref} overwrites any existing {data} 14 | if (data && ref) { 15 | // TODO: copy {data} into {ref} ... without overwriting {ref} 16 | INST_DATA.set(self, data = ref); 17 | } else if (!data) { 18 | INST_DATA.set(self, data = ref || {}); 19 | } 20 | return data; 21 | } 22 | 23 | Object.assign(exports, { 24 | get, 25 | stage 26 | }); 27 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: Lockbox DataStore 2 | site_description: Documentation for Lockbox's data storage module 3 | theme: readthedocs 4 | repo_name: GitHub 5 | repo_url: https://github.com/mozilla-lockbox/lockbox-datastore 6 | 7 | pages: 8 | - 'Introduction': 'index.md' 9 | - 'Installing': 'install.md' 10 | - 'Contributing': 'contributing.md' 11 | - 'Code of Conduct': 'code_of_conduct.md' 12 | - 'API Guide': 'api.md' 13 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "lockbox-datastore", 3 | "version": "0.2.1", 4 | "description": "DataStore module for Lockbox", 5 | "main": "lib/index.js", 6 | "browser": { 7 | "fake-indexeddb": false, 8 | "fake-indexeddb/lib/FDBKeyRange": false, 9 | "joi": "joi-browser" 10 | }, 11 | "scripts": { 12 | "doc": "documentation build lib/** -f md -o docs/api.md", 13 | "postdoc": "mkdocs build", 14 | ":clean:doc": "git clean -fdX ./site ./docs", 15 | "lint": "eslint . --ext=.js --ext=.json", 16 | "pretest": "npm run lint && npm run :clean:coverage", 17 | ":test:karma": "karma start", 18 | ":test:node": "mocha --recursive test", 19 | "test": "npm run :test:karma", 20 | "predebug": "npm run lint", 21 | "debug": "karma start karma.conf-debug.js", 22 | "watch": "watch --interval=10 'npm run test && npm run doc' ./lib ./test", 23 | ":clean:coverage": "git clean -fdX ./coverage", 24 | "codecov": "codecov", 25 | "clean": "npm run :clean:coverage && npm run :clean:doc" 26 | }, 27 | "repository": { 28 | "type": "git", 29 | "url": "git+https://github.com/mozilla-lockbox/lockbox-datastore.git" 30 | }, 31 | "keywords": [ 32 | "lockbox", 33 | "passwords", 34 | "crypto", 35 | "security" 36 | ], 37 | "author": "Lockbox Team ", 38 | "license": "MPL-2.0", 39 | "bugs": { 40 | "url": "https://github.com/mozilla-lockbox/lockbox-datastore/issues" 41 | }, 42 | "homepage": "https://github.com/mozilla-lockbox/lockbox-datastore#readme", 43 | "devDependencies": { 44 | "babel-core": "^6.26.3", 45 | "babel-loader": "^7.1.4", 46 | "babel-plugin-transform-object-rest-spread": "^6.26.0", 47 | "chai": "^4.1.2", 48 | "codecov": "^3.0.2", 49 | "documentation": "^8.0.1", 50 | "eslint": "^5.1.0", 51 | "eslint-config-standard": "^11.0.0", 52 | "eslint-plugin-import": "^2.12.0", 53 | "eslint-plugin-jsdoc": "^3.7.0", 54 | "eslint-plugin-json": "1.2.1", 55 | "eslint-plugin-node": "^7.0.1", 56 | "eslint-plugin-promise": "^4.0.0", 57 | "eslint-plugin-security": "1.4.0", 58 | "eslint-plugin-standard": "^4.0.0", 59 | "istanbul-instrumenter-loader": "^3.0.1", 60 | "karma": "^3.0.0", 61 | "karma-coverage": "^1.1.2", 62 | "karma-coverage-istanbul-reporter": "^2.0.1", 63 | "karma-firefox-launcher": "^1.1.0", 64 | "karma-mocha": "^1.3.0", 65 | "karma-mocha-reporter": "^2.2.5", 66 | "karma-sourcemap-loader": "^0.3.7", 67 | "karma-webpack": "^3.0.5", 68 | "mocha": "^5.2.0", 69 | "nyc": "^12.0.2", 70 | "password": "^0.1.1", 71 | "promisified-fs": "^1.0.1", 72 | "watch": "^1.0.2", 73 | "webpack": "^4.19.1", 74 | "yargs": "^12.0.1" 75 | }, 76 | "dependencies": { 77 | "dexie": "^2.0.3", 78 | "fake-indexeddb": "^2.0.4", 79 | "joi": "^13.3.0", 80 | "joi-browser": "^13.0.1", 81 | "json-merge-patch": "^0.2.3", 82 | "node-jose": "^1.0.0", 83 | "uuid": "^3.2.1", 84 | "webpack-dev-middleware": "^3.1.3" 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /test/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "mocha": true 4 | }, 5 | "extends": "../.eslintrc.json", 6 | "parserOptions": { 7 | "ecmaFeatures": { 8 | "experimentalObjectRestSpread": true 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /test/datastore-test.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. 5 | */ 6 | 7 | const assert = require("./setup/assert"); 8 | 9 | const UUID = require("uuid"), 10 | jose = require("node-jose"), 11 | jsonmergepatch = require("json-merge-patch"); 12 | 13 | const DataStore = require("../lib/datastore"), 14 | localdatabase = require("../lib/localdatabase"), 15 | DataStoreError = require("../lib/util/errors"); 16 | 17 | function failOnSuccess() { 18 | assert.ok(false, "unexpected success"); 19 | } 20 | 21 | function loadAppKey(bundle) { 22 | // master key contains secret 23 | if (!bundle) { 24 | bundle = require("./setup/key-bundle.json"); 25 | } 26 | let appKey = bundle.appKey; 27 | return appKey; 28 | } 29 | 30 | async function setupAppKey(appKey = "r_w9dG02dPnF-c7N3et7Rg1Fa5yiNB06hwvhMOpgSRo") { 31 | if (appKey) { 32 | return jose.JWK.asKey({ 33 | kty: "oct", 34 | k: appKey 35 | }); 36 | } 37 | return null; 38 | } 39 | 40 | function loadEncryptedKeys() { 41 | // keys is encrypted (using master password) as a Compact JWE 42 | let keys = require("./setup/encrypted-empty.json"); 43 | keys = keys.encrypted; 44 | return keys; 45 | } 46 | 47 | function checkList(stored, cached) { 48 | assert.equal(stored.size, cached.size); 49 | for (let i of cached.keys()) { 50 | let actual = stored.get(i), 51 | expected = cached.get(i); 52 | assert.deepEqual(actual, expected); 53 | } 54 | } 55 | 56 | describe("datastore", () => { 57 | describe("ctor", () => { 58 | it("constructs an instance without any options", () => { 59 | let ds = new DataStore(); 60 | assert.ok(!ds.initialized); 61 | assert.ok(ds.locked); 62 | }); 63 | it("constructs with the specified configuration", () => { 64 | let cfg = { 65 | keys: loadEncryptedKeys() 66 | }; 67 | let ds = new DataStore(cfg); 68 | assert.ok(ds.initialized); 69 | assert.ok(ds.locked); 70 | }); 71 | }); 72 | 73 | describe("initialization & reset", () => { 74 | beforeEach(localdatabase.startup); 75 | afterEach(localdatabase.teardown); 76 | function setupTest(appKey) { 77 | return async () => { 78 | appKey = await setupAppKey(appKey); 79 | let ds = new DataStore(); 80 | 81 | let result = await ds.initialize({ appKey }); 82 | assert.strictEqual(result, ds); 83 | assert(!ds.locked); 84 | assert(ds.initialized); 85 | 86 | await ds.lock(); 87 | await ds.unlock(appKey); 88 | assert(!ds.locked); 89 | 90 | return ds; 91 | }; 92 | } 93 | async function populateDataStore(ds) { 94 | let cache = new Map(); 95 | 96 | for (let idx = 0; idx < 4; idx++) { 97 | let item = await ds.add({ 98 | title: `entry #${idx + 1}`, 99 | entry: { 100 | kind: "login", 101 | username: "the user", 102 | password: "the password" 103 | } 104 | }); 105 | cache.set(item.id, item); 106 | } 107 | 108 | return cache; 109 | } 110 | 111 | it("initializes with given app key", setupTest()); 112 | it("fails to initialize without app key", async () => { 113 | const init = setupTest(""); 114 | return init().then(failOnSuccess, (err) => { 115 | assert.strictEqual(err.reason, DataStoreError.MISSING_APP_KEY); 116 | }); 117 | }); 118 | it("fails on the second initialization", async () => { 119 | let first = setupTest(); 120 | let ds = await first(); 121 | try { 122 | let appKey = await jose.JWK.createKeyStore().generate("oct", 256); 123 | await ds.initialize({ appKey }); 124 | } catch (err) { 125 | assert.strictEqual(err.reason, DataStoreError.INITIALIZED); 126 | } 127 | }); 128 | it("resets an initialized datastore", async () => { 129 | let ds = await setupTest()(); 130 | 131 | assert(ds.initialized); 132 | 133 | let result; 134 | result = await ds.reset(); 135 | assert(!ds.initialized); 136 | assert.strictEqual(result, ds); 137 | }); 138 | it("resets an uninitialized datastore", async () => { 139 | let ds = new DataStore(); 140 | 141 | assert(!ds.initialized); 142 | 143 | let result; 144 | result = await ds.reset(); 145 | assert(!ds.initialized); 146 | assert.strictEqual(result, ds); 147 | }); 148 | it("resets and reinitializes a datastore", async () => { 149 | let ds = await setupTest()(); 150 | 151 | assert(ds.initialized); 152 | 153 | let result; 154 | result = await ds.reset(); 155 | assert(!ds.initialized); 156 | assert.strictEqual(result, ds); 157 | 158 | let appKey = await setupAppKey(); 159 | result = await ds.initialize({ 160 | appKey 161 | }); 162 | assert(ds.initialized); 163 | assert.strictEqual(result, ds); 164 | }); 165 | it("rebases a datastore to a new password", async () => { 166 | let ds = await setupTest()(); 167 | let cache = await populateDataStore(ds); 168 | 169 | assert(ds.initialized); 170 | assert(!ds.locked); 171 | 172 | let result, appKey, salt; 173 | appKey = await setupAppKey(); 174 | salt = UUID(); 175 | result = await ds.initialize({ 176 | appKey, 177 | salt, 178 | rebase: true 179 | }); 180 | assert(ds.initialized); 181 | assert(!ds.locked); 182 | assert.strictEqual(result, ds); 183 | 184 | await ds.lock(); 185 | assert(ds.locked); 186 | result = await ds.unlock(appKey); 187 | assert(!ds.locked); 188 | assert.strictEqual(result, ds); 189 | 190 | let all = await ds.list(); 191 | assert.deepEqual(all, cache); 192 | }); 193 | it("fails to rebase a datastore when locked", async () => { 194 | let ds = await setupTest()(); 195 | 196 | assert(ds.initialized); 197 | assert(!ds.locked); 198 | 199 | await ds.lock(); 200 | 201 | let appKey; 202 | appKey = await setupAppKey(); 203 | try { 204 | await ds.initialize({ 205 | appKey, 206 | rebase: true 207 | }); 208 | failOnSuccess(); 209 | } catch (err) { 210 | assert.strictEqual(err.reason, DataStoreError.LOCKED); 211 | } 212 | }); 213 | }); 214 | 215 | describe("CRUD", () => { 216 | let main, appKey, salt, metrics; 217 | 218 | function checkMetrics(expected) { 219 | let actual = metrics; 220 | metrics = []; 221 | 222 | assert.equal(actual.length, expected.length); 223 | for (let idx = 0; idx < expected.length; idx++) { 224 | assert.deepEqual(actual[idx], expected[idx]); 225 | } 226 | } 227 | 228 | before(async () => { 229 | await localdatabase.startup(); 230 | 231 | let bundle = require("./setup/key-bundle.json"); 232 | appKey = loadAppKey(bundle); 233 | salt = bundle.salt; 234 | metrics = []; 235 | main = new DataStore({ 236 | salt, 237 | keys: loadEncryptedKeys(), 238 | recordMetric: async (method, id, fields) => { 239 | metrics.push({method, id, fields}); 240 | } 241 | }); 242 | main = await main.prepare(); 243 | }); 244 | after(async () => { 245 | // cleanup databases 246 | await localdatabase.teardown(); 247 | }); 248 | 249 | it("locks and unlocks", async () => { 250 | let result; 251 | 252 | assert.ok(main.locked); 253 | result = await main.unlock(appKey); 254 | assert.strictEqual(result, main); 255 | assert.ok(!main.locked); 256 | result = await main.unlock(appKey); 257 | assert.strictEqual(result, main); 258 | assert.ok(!main.locked); 259 | result = await main.lock(); 260 | assert.strictEqual(result, main); 261 | assert.ok(main.locked); 262 | result = await main.lock(); 263 | assert.strictEqual(result, main); 264 | assert.ok(main.locked); 265 | }); 266 | 267 | it("does basic CRUD ops", async () => { 268 | // start by unlocking 269 | await main.unlock(appKey); 270 | let cached = new Map(), 271 | stored; 272 | stored = await main.list(); 273 | checkList(stored, cached); 274 | 275 | let something = { 276 | title: "My Item", 277 | entry: { 278 | kind: "login", 279 | username: "foo", 280 | password: "bar" 281 | } 282 | }; 283 | let result, expected, history = []; 284 | result = await main.add(something); 285 | assert.itemMatches(result, Object.assign({}, something, { 286 | modified: new Date().toISOString(), 287 | history 288 | })); 289 | cached.set(result.id, result); 290 | stored = await main.list(); 291 | checkList(stored, cached); 292 | checkMetrics([ 293 | { 294 | method: "added", 295 | id: result.id, 296 | fields: undefined 297 | } 298 | ]); 299 | 300 | // result is the full item 301 | expected = result; 302 | result = await main.get(expected.id); 303 | assert(expected !== result); 304 | assert.deepEqual(result, expected); 305 | 306 | something = JSON.parse(JSON.stringify(result)); 307 | something.entry = Object.assign(something.entry, { 308 | password: "baz" 309 | }); 310 | history.unshift({ 311 | created: new Date().toISOString(), 312 | patch: jsonmergepatch.generate(something.entry, expected.entry) 313 | }); 314 | result = await main.update(something); 315 | 316 | assert.itemMatches(result, Object.assign({}, something, { 317 | modified: new Date().toISOString(), 318 | history 319 | })); 320 | cached.set(result.id, result); 321 | stored = await main.list(); 322 | checkList(stored, cached); 323 | checkMetrics([ 324 | { 325 | method: "updated", 326 | id: result.id, 327 | fields: "entry.password" 328 | } 329 | ]); 330 | 331 | expected = result; 332 | result = await main.get(expected.id); 333 | assert(expected !== result); 334 | assert.deepEqual(result, expected); 335 | 336 | something = JSON.parse(JSON.stringify(result)); 337 | something = Object.assign(something, { 338 | title: "MY Item" 339 | }); 340 | something.entry = Object.assign(something.entry, { 341 | username: "another-user", 342 | password: "zab" 343 | }); 344 | history.unshift({ 345 | created: new Date().toISOString(), 346 | patch: jsonmergepatch.generate(something.entry, expected.entry) 347 | }); 348 | result = await main.update(something); 349 | 350 | assert.itemMatches(result, Object.assign({}, something, { 351 | modified: new Date().toISOString(), 352 | history 353 | })); 354 | cached.set(result.id, result); 355 | stored = await main.list(); 356 | checkList(stored, cached); 357 | checkMetrics([ 358 | { 359 | method: "updated", 360 | id: result.id, 361 | fields: "title,entry.username,entry.password" 362 | } 363 | ]); 364 | 365 | expected = result; 366 | result = await main.get(expected.id); 367 | assert(expected !== result); 368 | assert.deepEqual(result, expected); 369 | 370 | something = JSON.parse(JSON.stringify(result)); 371 | something = Object.assign(something, { 372 | title: "My Someplace Item", 373 | origins: ["someplace.example"] 374 | }); 375 | result = await main.update(something); 376 | 377 | assert.itemMatches(result, Object.assign({}, something, { 378 | modified: new Date().toISOString(), 379 | history 380 | })); 381 | cached.set(result.id, result); 382 | stored = await main.list(); 383 | checkList(stored, cached); 384 | checkMetrics([ 385 | { 386 | method: "updated", 387 | id: result.id, 388 | fields: "title,origins" 389 | } 390 | ]); 391 | 392 | expected = result; 393 | result = await main.get(expected.id); 394 | assert(expected !== result); 395 | assert.deepEqual(result, expected); 396 | 397 | something = result; 398 | result = await main.remove(something.id); 399 | assert.deepEqual(result, something); 400 | cached.delete(result.id); 401 | stored = await main.list(); 402 | checkList(stored, cached); 403 | checkMetrics([ 404 | { 405 | method: "deleted", 406 | id: result.id, 407 | fields: undefined 408 | } 409 | ]); 410 | 411 | result = await main.get(result.id); 412 | assert(!result); 413 | }); 414 | it("touches", async () => { 415 | await main.unlock(appKey); 416 | let cached = new Map(), 417 | stored; 418 | stored = await main.list(); 419 | checkList(stored, cached); 420 | 421 | let something = { 422 | title: "My Item", 423 | entry: { 424 | kind: "login", 425 | username: "foo", 426 | password: "bar" 427 | } 428 | }; 429 | 430 | let result, expected = []; 431 | result = await main.add(something); 432 | checkMetrics([ 433 | { 434 | method: "added", 435 | id: result.id, 436 | fields: undefined 437 | } 438 | ]); 439 | 440 | expected = result; 441 | result = await main.touch(expected); 442 | let time = new Date().toISOString(); 443 | assert.dateInRange(result.last_used, time); 444 | cached.set(result.id, result); 445 | stored = await main.list(); 446 | checkList(stored, cached); 447 | checkMetrics([ 448 | { 449 | method: "touched", 450 | id: result.id, 451 | fields: undefined 452 | } 453 | ]); 454 | }); 455 | it("fails to add nothing", async () => { 456 | await main.unlock(appKey); 457 | 458 | try { 459 | await main.add(); 460 | failOnSuccess(); 461 | } catch (err) { 462 | assert.strictEqual(err.reason, DataStoreError.INVALID_ITEM); 463 | } 464 | }); 465 | it("fails to update nothing", async () => { 466 | await main.unlock(); 467 | 468 | try { 469 | await main.update(); 470 | failOnSuccess(); 471 | } catch (err) { 472 | assert.strictEqual(err.reason, DataStoreError.INVALID_ITEM); 473 | } 474 | }); 475 | it("fails to update missing item", async () => { 476 | await main.unlock(); 477 | let something = { 478 | id: "d50fd808-8c0f-47f8-99bc-896750a2cc0e", 479 | title: "Some other item", 480 | entry: { 481 | kind: "login", 482 | username: "bilbo.baggins", 483 | password: "hidden treasure" 484 | } 485 | }; 486 | 487 | try { 488 | await main.update(something); 489 | failOnSuccess(); 490 | } catch (err) { 491 | assert.strictEqual(err.reason, DataStoreError.MISSING_ITEM); 492 | } 493 | }); 494 | it("fails to touch missing item", async () => { 495 | await main.unlock(); 496 | let something = { 497 | id: "d50fd808-8c0f-47f8-99bc-896750a2cc0e", 498 | title: "Some other item", 499 | entry: { 500 | kind: "login", 501 | username: "bilbo.baggins", 502 | password: "hidden treasure" 503 | } 504 | }; 505 | 506 | try { 507 | await main.touch(something); 508 | failOnSuccess(); 509 | } catch (err) { 510 | assert.strictEqual(err.reason, DataStoreError.MISSING_ITEM); 511 | } 512 | }); 513 | 514 | describe("locked failures", () => { 515 | const item = { 516 | id: UUID(), 517 | title: "foobar", 518 | entry: { 519 | kind: "login", 520 | username: "blah", 521 | password: "dublah" 522 | } 523 | }; 524 | 525 | beforeEach(async () => { 526 | await main.lock(); 527 | }); 528 | 529 | it("fails list if locked", async () => { 530 | try { 531 | await main.list(); 532 | } catch (err) { 533 | assert.strictEqual(err.reason, DataStoreError.LOCKED); 534 | } 535 | }); 536 | it("fails get if locked", async () => { 537 | try { 538 | await main.get(item.id); 539 | } catch (err) { 540 | assert.strictEqual(err.reason, DataStoreError.LOCKED); 541 | } 542 | }); 543 | it("fails add if locked", async () => { 544 | try { 545 | await main.add(item); 546 | } catch (err) { 547 | assert.strictEqual(err.reason, DataStoreError.LOCKED); 548 | } 549 | }); 550 | it("fails update if locked", async () => { 551 | try { 552 | await main.update(item); 553 | } catch (err) { 554 | assert.strictEqual(err.reason, DataStoreError.LOCKED); 555 | } 556 | }); 557 | it("fails touch if locked", async () => { 558 | try { 559 | await main.touch(item); 560 | } catch (err) { 561 | assert.strictEqual(err.reason, DataStoreError.LOCKED); 562 | } 563 | }); 564 | it("fails remove if locked", async () => { 565 | try { 566 | await main.remove(item); 567 | } catch (err) { 568 | assert.strictEqual(err.reason, DataStoreError.LOCKED); 569 | } 570 | }); 571 | }); 572 | 573 | describe("uninitialized failures", () => { 574 | function checkNotInitialized(err) { 575 | assert.strictEqual(err.reason, DataStoreError.NOT_INITIALIZED); 576 | } 577 | 578 | before(async () => { 579 | return main.reset(); 580 | }); 581 | 582 | it("fails to list when uninitialized", async () => { 583 | return main.list().then(failOnSuccess, checkNotInitialized); 584 | }); 585 | it("fails to get when uninitialized", async () => { 586 | const id = "f96eb083-6103-41f8-9cbc-231efa2957af"; 587 | return main.get(id).then(failOnSuccess, checkNotInitialized); 588 | }); 589 | it("fails to add when uninitialized", async () => { 590 | const item = { 591 | entry: { 592 | kind: "login", 593 | username: "frodo.baggins", 594 | password: "keepitsecretkeepitsafe" 595 | } 596 | }; 597 | return main.add(item).then(failOnSuccess, checkNotInitialized); 598 | }); 599 | it("fails to update when uninitialized", async () => { 600 | const item = { 601 | id: "f96eb083-6103-41f8-9cbc-231efa2957af", 602 | entry: { 603 | kind: "login", 604 | username: "frodo.baggins", 605 | password: "keepitsecretkeepitsafe" 606 | } 607 | }; 608 | return main.update(item).then(failOnSuccess, checkNotInitialized); 609 | }); 610 | it("fails to touch when uninitialized", async () => { 611 | const item = { 612 | id: "f96eb083-6103-41f8-9cbc-231efa2957af", 613 | entry: { 614 | kind: "login", 615 | username: "frodo.baggins", 616 | password: "keepitsecretkeepitsafe" 617 | } 618 | }; 619 | return main.touch(item).then(failOnSuccess, checkNotInitialized); 620 | }); 621 | it("fails to remove when uninitialized", async () => { 622 | const id = "f96eb083-6103-41f8-9cbc-231efa2957af"; 623 | return main.remove(id).then(failOnSuccess, checkNotInitialized); 624 | }); 625 | }); 626 | }); 627 | 628 | describe("defaults", () => { 629 | before(async () => { 630 | await localdatabase.startup(); 631 | }); 632 | after(async () => { 633 | await localdatabase.teardown(); 634 | }); 635 | }); 636 | 637 | describe("persistence", () => { 638 | let cached = new Map(); 639 | let expectedID; 640 | let something = { 641 | title: "Sa Tuna2", 642 | entry: { 643 | kind: "login", 644 | username: "foo", 645 | password: "bar" 646 | } 647 | }; 648 | 649 | before(async () => { 650 | localdatabase.startup(); 651 | }); 652 | after(async () => { 653 | // cleanup databases 654 | await localdatabase.teardown(); 655 | }); 656 | 657 | it("add a value to first datastore", async () => { 658 | const appKey = await setupAppKey(); 659 | let main = new DataStore(); 660 | main = await main.prepare(); 661 | await main.initialize({ 662 | appKey 663 | }); 664 | 665 | let result = await main.add(something); 666 | assert.itemMatches(result, something); 667 | cached.set(result.id, result); 668 | let stored = await main.list(); 669 | checkList(stored, cached); 670 | 671 | // result is the full item 672 | let expected = result; 673 | result = await main.get(expected.id); 674 | assert(expected !== result); 675 | assert.deepEqual(result, expected); 676 | 677 | expectedID = expected.id; 678 | }); 679 | 680 | it("data persists into second datastore", async () => { 681 | const appKey = await setupAppKey(); 682 | let secondDatastore = new DataStore(); 683 | secondDatastore = await secondDatastore.prepare(); 684 | await secondDatastore.unlock(appKey); 685 | 686 | let stored = await secondDatastore.list(); 687 | checkList(stored, cached); 688 | 689 | // result is the full item 690 | let result = await secondDatastore.get(expectedID); 691 | assert.itemMatches(result, something); 692 | }); 693 | }); 694 | }); 695 | -------------------------------------------------------------------------------- /test/index-test.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. 5 | */ 6 | 7 | const assert = require("chai").assert; 8 | 9 | const index = require("../lib"), 10 | DataStore = require("../lib/datastore"), 11 | DataStoreError = require("../lib/util/errors"); 12 | 13 | describe("index", () => { 14 | it("has expected symbols", () => { 15 | assert.strictEqual(index.DataStoreError, DataStoreError); 16 | assert.strictEqual(index.DataStore, DataStore); 17 | assert.typeOf(index.open, "function"); 18 | }); 19 | it("opens a DataStore instance", async () => { 20 | let data = index.open(); 21 | // make sure it quacks enough like a duck 22 | assert.typeOf(data.then, "function"); 23 | assert.typeOf(data.catch, "function"); 24 | 25 | data = await data; 26 | assert.instanceOf(data, DataStore); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /test/itemkeystore-test.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. 5 | */ 6 | 7 | const assert = require("chai").assert, 8 | jose = require("node-jose"), 9 | UUID = require("uuid"); 10 | 11 | const ItemKeyStore = require("../lib/itemkeystore"), 12 | DataStoreError = require("../lib/util/errors"), 13 | constants = require("../lib/constants"); 14 | 15 | async function loadMasterKey() { 16 | let bundle = require("./setup/key-bundle.json"); 17 | let encryptKey = await jose.JWK.asKey(bundle.encryptKey); 18 | return encryptKey; 19 | } 20 | async function setupContext(context) { 21 | context = { 22 | encryptKey: await loadMasterKey(), 23 | ...context 24 | }; 25 | 26 | return context; 27 | } 28 | 29 | describe("ItemKeyStore", () => { 30 | describe("ctor", () => { 31 | it("creates an ItemKeyStore", () => { 32 | let iks = new ItemKeyStore(); 33 | assert.strictEqual(iks.id, constants.DEFAULT_KEYSTORE_ID); 34 | assert.isEmpty(iks.group); 35 | assert.isUndefined(iks.encrypted); 36 | 37 | assert.deepEqual(iks.toJSON(), { 38 | id: constants.DEFAULT_KEYSTORE_ID, 39 | group: "", 40 | encrypted: undefined, 41 | last_modified: undefined, 42 | }); 43 | }); 44 | it("creates an ItemKeyStore with the given (empty) configuration", () => { 45 | let context = {}; 46 | let iks = new ItemKeyStore(context); 47 | assert.strictEqual(iks.id, constants.DEFAULT_KEYSTORE_ID); 48 | assert.isEmpty(iks.group); 49 | assert.isUndefined(iks.encrypted); 50 | assert.deepEqual(iks.toJSON(), { 51 | id: constants.DEFAULT_KEYSTORE_ID, 52 | group: "", 53 | encrypted: undefined, 54 | last_modified: undefined, 55 | }); 56 | }); 57 | it("creates an ItemKeyStore with the given configuration", async () => { 58 | const id = await jose.JWA.digest("SHA-256", jose.util.asBuffer("my-group")). 59 | then((r) => r.toString("hex")); 60 | let context = { 61 | id, 62 | group: "my-group", 63 | encrypted: "not-real-data", 64 | last_modified: undefined, 65 | }; 66 | let expected = { ...context }; 67 | 68 | let iks = new ItemKeyStore(context); 69 | assert.strictEqual(iks.id, expected.id); 70 | assert.strictEqual(iks.group, expected.group); 71 | assert.strictEqual(iks.encrypted, expected.encrypted); 72 | assert.deepEqual(iks.toJSON(), expected); 73 | }); 74 | it("creates an ItemKeyStore with prepopulated keys", async () => { 75 | let cache = new Map(); 76 | for (let idx = 0; idx < 4; idx++) { 77 | let kid = UUID(); 78 | let key = await jose.JWK.createKeyStore().generate("oct", 256, {kid}); 79 | cache.set(kid, key); 80 | } 81 | 82 | let iks = new ItemKeyStore({ 83 | listing: new Map(cache) 84 | }); 85 | let all = await iks.all(); 86 | assert(all !== cache); 87 | assert.deepEqual(all, cache); 88 | assert.strictEqual(iks.size, cache.size); 89 | }); 90 | }); 91 | 92 | describe("loading", () => { 93 | it("loads empty keys from encrypted", async () => { 94 | let context = await setupContext(require("./setup/encrypted-empty.json")); 95 | let iks = new ItemKeyStore(context); 96 | 97 | let result = await iks.load(); 98 | assert.strictEqual(result, iks); 99 | }); 100 | it("loads real keys from encrypted", async () => { 101 | let context = await setupContext(require("./setup/encrypted-4items.json")); 102 | let iks = new ItemKeyStore(context); 103 | 104 | let result = await iks.load(); 105 | assert.strictEqual(result, iks); 106 | }); 107 | it("loads with the given master key", async () => { 108 | let context = await setupContext(require("./setup/encrypted-4items.json")); 109 | let realKey = context.encryptKey; 110 | delete context.encryptKey; 111 | 112 | let iks = new ItemKeyStore(context); 113 | assert.isUndefined(iks.encryptKey); 114 | 115 | let result = await iks.load(realKey); 116 | assert.strictEqual(result, iks); 117 | assert.strictEqual(iks.encryptKey, realKey); 118 | assert.strictEqual(iks.size, 4); 119 | }); 120 | it("fails with no encryptKey key", async () => { 121 | let context = await setupContext(require("./setup/encrypted-empty.json")); 122 | delete context.encryptKey; 123 | 124 | let iks = new ItemKeyStore(context); 125 | try { 126 | await iks.load(); 127 | assert(false, "unexpected success"); 128 | } catch (err) { 129 | assert.strictEqual(err.reason, DataStoreError.MISSING_APP_KEY); 130 | } 131 | }); 132 | it("fails with no encrypted data", async () => { 133 | let context = await setupContext(); 134 | 135 | let iks = new ItemKeyStore(context); 136 | try { 137 | await iks.load(); 138 | assert(false, "unexpected success"); 139 | } catch (err) { 140 | assert.strictEqual(err.reason, DataStoreError.CRYPTO); 141 | assert.strictEqual(err.message, `${DataStoreError.CRYPTO}: keystore not encrypted`); 142 | } 143 | }); 144 | }); 145 | describe("get/add/delete", () => { 146 | let iks, 147 | cache = new Map(); 148 | 149 | before(async () => { 150 | iks = new ItemKeyStore(); 151 | }); 152 | 153 | it("adds a key", async () => { 154 | for (let idx = 0; 4 > idx; idx++) { 155 | let key, id = UUID(); 156 | key = await iks.get(id); 157 | assert.isUndefined(key); 158 | key = await iks.add(id); 159 | assert.ok(jose.JWK.isKey(key)); 160 | assert.strictEqual(key.kty, "oct"); 161 | assert.strictEqual(key.kid, id); 162 | assert.strictEqual(key.alg, "A256GCM"); 163 | cache.set(id, key); 164 | } 165 | assert.strictEqual(iks.size, 4); 166 | let all = await iks.all(); 167 | assert.deepEqual(all, cache); 168 | }); 169 | it("gets the same key", async () => { 170 | for (let c of cache.entries()) { 171 | let [ id, expected ] = c; 172 | let actual; 173 | 174 | actual = await iks.get(id); 175 | assert.strictEqual(actual, expected); 176 | 177 | actual = await iks.add(id); 178 | assert.strictEqual(actual, expected); 179 | } 180 | let all = await iks.all(); 181 | assert.deepEqual(all, cache); 182 | }); 183 | it("removes a key", async () => { 184 | for (let c of cache.entries()) { 185 | let [ id, expected ] = c; 186 | let actual; 187 | 188 | actual = await iks.get(id); 189 | assert.strictEqual(actual, expected); 190 | await iks.delete(id); 191 | actual = await iks.get(id); 192 | assert.isUndefined(actual); 193 | } 194 | assert.strictEqual(iks.size, 0); 195 | 196 | let all = await iks.all(); 197 | assert.strictEqual(all.size, 0); 198 | }); 199 | }); 200 | describe("saving", () => { 201 | it("saves an empty ItemKeyStore", async () => { 202 | let context = { 203 | encryptKey: await loadMasterKey() 204 | }; 205 | let iks = new ItemKeyStore(context); 206 | assert.isUndefined(iks.encrypted); 207 | 208 | let result = await iks.save(); 209 | assert.strictEqual(result, iks); 210 | assert.isNotEmpty(iks.encrypted); 211 | }); 212 | it("fails if there is no master key", async () => { 213 | let iks = new ItemKeyStore(); 214 | assert.isUndefined(iks.encrypted); 215 | assert.isUndefined(iks.encryptKey); 216 | 217 | try { 218 | await iks.save(); 219 | assert.ok(false, "unexpected success"); 220 | } catch (err) { 221 | assert.strictEqual(err.reason, DataStoreError.MISSING_APP_KEY); 222 | } 223 | }); 224 | }); 225 | describe("clearing", () => { 226 | it("clears a populated ItemKeyStore", async () => { 227 | let { encrypted } = require("./setup/encrypted-4items.json"); 228 | let context = await setupContext({ encrypted }); 229 | let iks = new ItemKeyStore(context); 230 | 231 | await iks.load(); 232 | assert.strictEqual(iks.size, 4); 233 | 234 | let result = await iks.clear(); 235 | assert.strictEqual(result, iks); 236 | assert.strictEqual(iks.size, 0); 237 | assert.isUndefined(iks.encryptKey); 238 | assert.strictEqual(iks.encrypted, encrypted); 239 | }); 240 | it ("clears a populated ItemKeyStore of *everything*", async () => { 241 | let { encrypted } = require("./setup/encrypted-4items.json"); 242 | let context = await setupContext({ encrypted }); 243 | let iks = new ItemKeyStore(context); 244 | 245 | await iks.load(); 246 | assert.strictEqual(iks.size, 4); 247 | 248 | let result = await iks.clear(true); 249 | assert.strictEqual(result, iks); 250 | assert.strictEqual(iks.size, 0); 251 | assert.isUndefined(iks.encryptKey); 252 | assert.isUndefined(iks.encrypted); 253 | }); 254 | }); 255 | 256 | describe("roundtrip", () => { 257 | let cache = [], 258 | encrypted; 259 | 260 | it("encrypts an new ItemKeyStore", async () => { 261 | let context = await setupContext(require("./setup/encrypted-empty.json")); 262 | let iks = new ItemKeyStore(context); 263 | 264 | for (let idx = 0; 4 > idx; idx++) { 265 | let id = UUID(), 266 | key = await iks.add(id); 267 | 268 | cache.push({ 269 | id, 270 | key: key.toJSON(true) 271 | }); 272 | } 273 | 274 | let result = await iks.save(); 275 | assert.strictEqual(result, iks); 276 | encrypted = iks.encrypted; 277 | assert.isNotEmpty(encrypted); 278 | }); 279 | it("decrypts a revived ItemKeyStore", async () => { 280 | let context = await setupContext({ encrypted }); 281 | let iks = new ItemKeyStore(context); 282 | 283 | let result = await iks.load(); 284 | assert.strictEqual(result, iks); 285 | for (let c of cache) { 286 | let {id, key: expected } = c; 287 | 288 | let actual = await iks.get(id); 289 | assert.deepEqual(actual.toJSON(true), expected); 290 | } 291 | }); 292 | }); 293 | 294 | describe("encrypt/decrypt items", () => { 295 | let cache, iks; 296 | 297 | function cacheEntry(id, item, encrypted) { 298 | cache.set(id, { 299 | id, 300 | item, 301 | encrypted 302 | }); 303 | } 304 | 305 | before(async () => { 306 | let context = await setupContext(require("./setup/encrypted-empty.json")); 307 | iks = new ItemKeyStore(context); 308 | iks = await iks.load(); 309 | cache = new Map(); 310 | }); 311 | 312 | it("encrypts an item with a new key", async () => { 313 | let item = { 314 | id: UUID(), 315 | title: "some item" 316 | }; 317 | 318 | assert.strictEqual(iks.size, 0); 319 | let result = await iks.protect(item); 320 | assert.isNotEmpty(result); 321 | 322 | cacheEntry(item.id, item, result); 323 | }); 324 | it("encrypts an item with a known key", async () => { 325 | let item = { 326 | id: UUID(), 327 | title: "another item" 328 | }; 329 | let key = await iks.add(item.id); 330 | assert.isUndefined(cache.get(item.id)); 331 | assert.isDefined(key); 332 | 333 | let result = await iks.protect(item); 334 | assert.isNotEmpty(result); 335 | 336 | cacheEntry(item.id, item, result); 337 | }); 338 | it("decrypts items", async () => { 339 | assert.strictEqual(iks.size, cache.size); 340 | for (let c of cache.entries()) { 341 | let [ id, entry ] = c; 342 | let { item, encrypted } = entry; 343 | 344 | let result = await iks.unprotect(id, encrypted); 345 | assert.deepEqual(result, item); 346 | } 347 | }); 348 | }); 349 | }); 350 | -------------------------------------------------------------------------------- /test/items-test.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. 5 | */ 6 | 7 | const assert = require("./setup/assert"); 8 | 9 | 10 | const jsonmergepatch = require("json-merge-patch"), 11 | UUID = require("uuid"); 12 | 13 | const DataStoreError = require("../lib/util/errors"), 14 | Items = require("../lib/items"); 15 | 16 | const ITEM_MEMBERS = [ "id", "title", "origins", "tags", "entry", "disabled", "created", "modified", "history", "last_used" ]; 17 | const ENTRY_MEMBERS = ["kind", "username", "password", "notes"]; 18 | describe("items", () => { 19 | describe("happy", () => { 20 | it("prepares an item with all mutables and no source", () => { 21 | let item = { 22 | title: "some title", 23 | origins: ["example.com"], 24 | tags: ["personal"], 25 | entry: { 26 | kind: "login", 27 | username: "someone", 28 | password: "secret", 29 | notes: "some notes for the entry" 30 | } 31 | }; 32 | 33 | let result = Items.prepare(item); 34 | assert(item !== result); 35 | assert.hasAllKeys(result, ITEM_MEMBERS); 36 | assert.hasAllKeys(result.entry, ENTRY_MEMBERS); 37 | assert.itemMatches(result, Object.assign({}, item, { 38 | disabled: false, 39 | created: new Date().toISOString(), 40 | modified: new Date().toISOString(), 41 | last_used: null, 42 | history: [] 43 | })); 44 | }); 45 | it("prepares an item with all mutables and a source", () => { 46 | let history = []; 47 | let source = { 48 | id: UUID(), 49 | title: "some title", 50 | origins: ["example.com"], 51 | tags: ["personal"], 52 | entry: { 53 | kind: "login", 54 | username: "someone", 55 | password: "secret" 56 | }, 57 | disabled: false, 58 | created: new Date().toISOString(), 59 | modified: new Date().toISOString(), 60 | last_used: null, 61 | history: [] 62 | }; 63 | let item = { 64 | title: "some title", 65 | origins: ["example.net"], 66 | tags: ["personal"], 67 | entry: { 68 | kind: "login", 69 | username: "someone", 70 | password: "another-secret", 71 | notes: "my personal account details" 72 | } 73 | }; 74 | history.unshift({ 75 | created: new Date().toISOString(), 76 | patch: jsonmergepatch.generate(item.entry, source.entry) 77 | }); 78 | 79 | let result = Items.prepare(item, source); 80 | assert(result !== item); 81 | assert(result !== source); 82 | assert.itemMatches(result, Object.assign({}, source, item, { 83 | modified: new Date().toISOString(), 84 | history 85 | })); 86 | }); 87 | it("prepares an item with some mutables and no source", () => { 88 | let item = { 89 | title: "some title", 90 | origins: ["example.com"], 91 | entry: { 92 | kind: "login", 93 | username: "someone", 94 | password: "secret" 95 | } 96 | }; 97 | 98 | let result = Items.prepare(item); 99 | assert(result !== item); 100 | assert.hasAllKeys(result, ITEM_MEMBERS); 101 | assert.itemMatches(result, Object.assign({}, item, { 102 | disabled: false, 103 | created: new Date().toISOString(), 104 | modified: new Date().toISOString(), 105 | last_used: null, 106 | history: [] 107 | })); 108 | }); 109 | it("prepares an item with some mutables and a source", () => { 110 | let history = []; 111 | let source = { 112 | id: UUID(), 113 | title: "some title", 114 | origins: ["example.net"], 115 | tags: ["personal"], 116 | entry: { 117 | kind: "login", 118 | username: "someone", 119 | password: "secret" 120 | } 121 | }; 122 | let item = { 123 | title: "some title", 124 | origins: ["example.com"], 125 | entry: { 126 | kind: "login", 127 | username: "someone@example.com", 128 | password: "secret" 129 | } 130 | }; 131 | history.unshift({ 132 | created: new Date().toISOString(), 133 | patch: jsonmergepatch.generate(item.entry, source.entry) 134 | }); 135 | 136 | let result = Items.prepare(item, source); 137 | assert(result !== item); 138 | assert.hasAllKeys(result, ITEM_MEMBERS); 139 | assert.itemMatches(result, Object.assign({}, source, item, { 140 | tags: [], 141 | disabled: false, 142 | created: new Date().toISOString(), 143 | modified: new Date().toISOString(), 144 | last_used: null, 145 | history 146 | })); 147 | }); 148 | it("prepares an item without extras", () => { 149 | let item = { 150 | title: "some title", 151 | extra: ["EXTRA!", "READ", "ALL", "ABOUT", "IT!"], 152 | entry: { 153 | kind: "login", 154 | username: "someone" 155 | } 156 | }; 157 | 158 | let result = Items.prepare(item); 159 | assert(result !== item); 160 | assert.hasAllKeys(result, ITEM_MEMBERS); 161 | assert.hasAllKeys(result.entry, ENTRY_MEMBERS); 162 | }); 163 | }); 164 | 165 | describe("sad", () => { 166 | function packString(max) { 167 | const ALPHA = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789- "; 168 | let out = []; 169 | let len = Math.ceil(max + 1.5); 170 | for (let idx = 0; idx < len; idx++) { 171 | let c = Math.floor(Math.random() * ALPHA.length); 172 | c = ALPHA.charAt(c); 173 | out.push(c); 174 | } 175 | return out.join(""); 176 | } 177 | function packOrigins(item) { 178 | for (let idx = 0; idx < 17; idx++) { 179 | item.origins.push(`domain-${idx}.example`); 180 | } 181 | return item; 182 | } 183 | function packTags(item) { 184 | for (let idx = 0; idx < 17; idx++) { 185 | item.tags.push(`personal-${idx}`); 186 | } 187 | return item; 188 | } 189 | 190 | it("fails to prepare an item without an entry", () => { 191 | let item = { 192 | title: "some title", 193 | origins: ["example.com"], 194 | tags: ["personal"], 195 | }; 196 | 197 | try { 198 | Items.prepare(item); 199 | } catch (err) { 200 | assert.strictEqual(err.reason, DataStoreError.INVALID_ITEM); 201 | assert.deepEqual(err.details, { 202 | entry: "any.required" 203 | }); 204 | } 205 | }); 206 | it("fails to prepare an item with an entry without a kind", () => { 207 | let item = { 208 | title: "some title", 209 | origins: ["example.com"], 210 | tags: ["personal"], 211 | entry: { 212 | username: "someone", 213 | password: "secret" 214 | } 215 | }; 216 | 217 | try { 218 | Items.prepare(item); 219 | } catch (err) { 220 | assert.strictEqual(err.reason, DataStoreError.INVALID_ITEM); 221 | assert.deepEqual(err.details, { 222 | entry: "any.required" 223 | }); 224 | } 225 | }); 226 | it("fails to prepare an item with excessive title", () => { 227 | let item = { 228 | title: packString(500), 229 | origins: ["example.com"], 230 | tags: ["personal"], 231 | entry: { 232 | kind: "login", 233 | username: "someone", 234 | password: "secret" 235 | } 236 | }; 237 | 238 | try { 239 | Items.prepare(item); 240 | assert(false, "expected failure"); 241 | } catch (err) { 242 | assert.strictEqual(err.reason, DataStoreError.INVALID_ITEM); 243 | assert.deepEqual(err.details, { 244 | title: "string.max" 245 | }); 246 | } 247 | }); 248 | it("fails to prepare an item with excessive origin item", () => { 249 | let item = { 250 | title: "some title", 251 | origins: [packString(500)], 252 | tags: ["personal"], 253 | entry: { 254 | kind: "login", 255 | username: "someone", 256 | password: "secret" 257 | } 258 | }; 259 | 260 | try { 261 | Items.prepare(item); 262 | } catch (err) { 263 | assert.strictEqual(err.reason, DataStoreError.INVALID_ITEM); 264 | assert.deepEqual(err.details, { 265 | origins: "string.max" 266 | }); 267 | } 268 | }); 269 | it("fails to prepare an item with too many origins", () => { 270 | let item = { 271 | title: "some title", 272 | origins: ["example.com"], 273 | tags: ["personal"], 274 | entry: { 275 | kind: "login", 276 | username: "someone", 277 | password: "secret" 278 | } 279 | }; 280 | item = packOrigins(item); 281 | 282 | try { 283 | Items.prepare(item); 284 | } catch (err) { 285 | assert.strictEqual(err.reason, DataStoreError.INVALID_ITEM); 286 | assert.deepEqual(err.details, { 287 | origins: "array.max" 288 | }); 289 | } 290 | }); 291 | it("fails to prepare an item with excessive tag item", () => { 292 | let item = { 293 | title: "some title", 294 | origin: ["example.com"], 295 | tags: [packString(500)], 296 | entry: { 297 | kind: "login", 298 | username: "someone", 299 | password: "secret" 300 | } 301 | }; 302 | 303 | try { 304 | Items.prepare(item); 305 | } catch (err) { 306 | assert.strictEqual(err.reason, DataStoreError.INVALID_ITEM); 307 | assert.deepEqual(err.details, { 308 | tags: "string.max" 309 | }); 310 | } 311 | }); 312 | it("fails to prepare an item with too many tags", () => { 313 | let item = { 314 | title: "some title", 315 | origins: ["example.com"], 316 | tags: ["personal"], 317 | entry: { 318 | kind: "login", 319 | username: "someone", 320 | password: "secret", 321 | notes: "some notes" 322 | } 323 | }; 324 | item = packTags(item); 325 | 326 | try { 327 | Items.prepare(item); 328 | } catch (err) { 329 | assert.strictEqual(err.reason, DataStoreError.INVALID_ITEM); 330 | assert.deepEqual(err.details, { 331 | tags: "array.max" 332 | }); 333 | } 334 | }); 335 | it("fails to prepare an item with excessive entry.username", () => { 336 | let item = { 337 | title: "some title", 338 | origins: ["example.com"], 339 | tags: ["personal"], 340 | entry: { 341 | kind: "login", 342 | username: packString(500), 343 | password: "secret", 344 | notes: "some notes" 345 | } 346 | }; 347 | 348 | try { 349 | Items.prepare(item); 350 | } catch (err) { 351 | assert.strictEqual(err.reason, DataStoreError.INVALID_ITEM); 352 | assert.deepEqual(err.details, { 353 | "entry.username": "string.max" 354 | }); 355 | } 356 | }); 357 | it("fails to prepare an item with excessive entry.password", () => { 358 | let item = { 359 | title: "some title", 360 | origins: ["example.com"], 361 | tags: ["personal"], 362 | entry: { 363 | kind: "login", 364 | username: "someone", 365 | password: packString(500), 366 | notes: "some notes" 367 | } 368 | }; 369 | 370 | try { 371 | Items.prepare(item); 372 | } catch (err) { 373 | assert.strictEqual(err.reason, DataStoreError.INVALID_ITEM); 374 | assert.deepEqual(err.details, { 375 | "entry.password": "string.max" 376 | }); 377 | } 378 | }); 379 | it("fails to prepare an item with excessive entry.notes", () => { 380 | let item = { 381 | title: "some title", 382 | origins: ["example.com"], 383 | tags: ["personal"], 384 | entry: { 385 | kind: "login", 386 | username: "someone", 387 | password: "secret", 388 | notes: packString(10000) 389 | } 390 | }; 391 | 392 | try { 393 | Items.prepare(item); 394 | assert(false, "expected failure"); 395 | } catch (err) { 396 | assert.strictEqual(err.reason, DataStoreError.INVALID_ITEM); 397 | assert.deepEqual(err.details, { 398 | "entry.notes": "string.max" 399 | }); 400 | } 401 | }); 402 | it("fails to prepare an item with multiple problems", () => { 403 | let item = { 404 | title: packString(500), 405 | origins: ["example.com"], 406 | tags: ["personal"], 407 | entry: { 408 | kind: "login", 409 | username: packString(500), 410 | password: "secret", 411 | notes: packString(10000) 412 | } 413 | }; 414 | item = packOrigins(item); 415 | 416 | try { 417 | Items.prepare(item); 418 | assert(false, "expected failure"); 419 | } catch (err) { 420 | assert.strictEqual(err.reason, DataStoreError.INVALID_ITEM); 421 | assert.deepEqual(err.details, { 422 | "title": "string.max", 423 | "origins": "array.max", 424 | "entry.username": "string.max", 425 | "entry.notes": "string.max" 426 | }); 427 | } 428 | }); 429 | }); 430 | }); 431 | -------------------------------------------------------------------------------- /test/localdatabase-test.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. 5 | */ 6 | 7 | const assert = require("chai").assert; 8 | 9 | const DataStoreError = require("../lib/util/errors"), 10 | localdatabase = require("../lib/localdatabase"), 11 | constants = require("../lib/constants"); 12 | 13 | describe("localdatabase", () => { 14 | let ldb; 15 | 16 | beforeEach(async () => { 17 | ldb = null; 18 | await localdatabase.startup(); 19 | }); 20 | afterEach(async () => { 21 | if (ldb) { 22 | await ldb.close(); 23 | } 24 | await localdatabase.teardown(); 25 | }); 26 | 27 | it("opens a default instance", async () => { 28 | ldb = await localdatabase.open(); 29 | assert.strictEqual(ldb.name, localdatabase.DEFAULT_BUCKET); 30 | }); 31 | it("opens the named instance", async () => { 32 | const name = "lockbox-devel"; 33 | ldb = await localdatabase.open(name); 34 | assert.strictEqual(ldb.name, name); 35 | }); 36 | it("fails to open an unexpected instance", async () => { 37 | const name = "lockbox-bad"; 38 | let prep = await localdatabase.open(name); 39 | await prep.close(); 40 | await prep.version(2000); 41 | await prep.open(); 42 | prep.close(); 43 | 44 | try { 45 | ldb = await localdatabase.open(name); 46 | assert.ok(false, "unexpected success"); 47 | } catch (err) { 48 | assert.strictEqual(err.reason, DataStoreError.LOCALDB_VERSION); 49 | } 50 | }); 51 | 52 | describe("upgrades", () => { 53 | it("upgrades 0.1 ==> 0.2", async () => { 54 | const bucket = localdatabase.DEFAULT_BUCKET; 55 | 56 | // prepare "old" database 57 | const oldDB = new localdatabase.Dexie(bucket); 58 | localdatabase.VERSIONS["0.1"](oldDB); 59 | 60 | const record = { 61 | encrypted: require("./setup/encrypted-empty.json").encrypted, 62 | group: "" 63 | }; 64 | await oldDB.open(); 65 | await oldDB.keystores.add(record); 66 | await oldDB.close(); 67 | 68 | const currDB = await localdatabase.open(bucket); 69 | const actual = await currDB.keystores.get(constants.DEFAULT_KEYSTORE_GROUP); 70 | const expected = { 71 | id: constants.DEFAULT_KEYSTORE_ID, 72 | group: constants.DEFAULT_KEYSTORE_GROUP, 73 | encrypted: record.encrypted 74 | }; 75 | 76 | assert.deepEqual(actual, expected); 77 | }); 78 | }); 79 | }); 80 | -------------------------------------------------------------------------------- /test/setup/assert.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. 5 | */ 6 | 7 | const assert = require("chai").assert; 8 | 9 | const ACCEPTED_DELTA_MS = 250; 10 | 11 | function dateInRange(actual, expected, message) { 12 | actual = new Date(actual); 13 | expected = new Date(expected); 14 | let delta = Math.abs(actual.getTime() - expected.getTime()); 15 | message = `${message || "date out of range"}: ${actual} vs ${expected}`; 16 | assert(delta < ACCEPTED_DELTA_MS, message); 17 | } 18 | 19 | const DATE_MEMBERS = ["created", "modified", "last_used"]; 20 | function itemMatches(actual, expected, message, parent) { 21 | let prefix = parent ? `${parent}: ` : ""; 22 | if (!expected) { 23 | assert(actual === expected, prefix + `${message || "actual item exists"}`); 24 | return; 25 | } 26 | 27 | assert(actual, prefix + `${message || "actual item does not exist"}`); 28 | Object.keys(expected).forEach((m) => { 29 | let mPrefix = `${parent ? parent + "." : ""}${m}: `; 30 | let actVal = actual[m], 31 | expVal = expected[m]; 32 | 33 | if (DATE_MEMBERS.indexOf(m) !== -1) { 34 | dateInRange(actVal, expVal, mPrefix + `${m} out of range`); 35 | } else if (Array.isArray(expVal)) { 36 | assert(Array.isArray(actVal), mPrefix + `${message || "expected actual to be an array"}`); 37 | assert.strictEqual(actVal.length, expVal.length, mPrefix + `${message || "array length mismatch"}`); 38 | for (let idx = 0; idx < expVal.length; idx++) { 39 | itemMatches(actVal[idx], expVal[idx], message, `${m}[${idx}]`); 40 | } 41 | } else if ("object" === typeof expVal) { 42 | itemMatches(actVal, expVal, message, m); 43 | } else { 44 | assert.strictEqual(actVal, expVal, mPrefix + `${message || "value mismatch"}`); 45 | } 46 | }); 47 | } 48 | 49 | Object.assign(assert, { 50 | dateInRange, 51 | itemMatches 52 | }); 53 | module.exports = assert; 54 | -------------------------------------------------------------------------------- /test/setup/encrypted-4items.json: -------------------------------------------------------------------------------- 1 | { 2 | "encrypted": "eyJhbGciOiJkaXIiLCJraWQiOiI1ZEI2dy1pdGdiNmNKb1N6eEFkamVWWHd3TlM0TUJWVUF5dVFBZlNjM3lBIiwiZW5jIjoiQTI1NkdDTSJ9..RpESHj3pd-PecdJN.3mWCDn1qy81zIIMCpHUzIMjHN2_BPjooIigq-HHuvSpgIwC1Jrq-ajA2MaSvTwM5DRZEEstQE2kymqwIOmDf2D18rNKp2Q1n_vkOy9_y1n4L4WHATXOyDeTdxQ1pnPxobvYPggdDOXXmYljq0qv3quhU5UVcTZEVpykU-MOnpyVOiHr-pXjlaL8zCxyqV_GUBpljGMjeRB2kdAhr9xSjBmGdB_Vi3fqvn16wx53SoheDgRwrdrmcm7CWcNFUfO7K2FpY5aP3lOl5cX0HfS_3PbW4m2Yiec5cWocRcMDJCy0g1ufnPcClilGIZRdKZJyMNICQP_rUecfSF2F4JXDCoPRu9jqN4NrKJiljLBX1bTRUTxRQHlG4pl1rF__HMtAs07yzmW5OzUlSTCrkGea-TI1P0nZ0eG9eh1H4cLD3mCD4V9w5m1sfz_fGn7oZWEbJ04CRIhiGi2KsZcbcU0e4tsZ75ROCYIW2ZsFlBuSe45e8iDAEloKMZXZAb6sr9rZyMOIsNca_Ok0-khkeo4fI4crZKczX_2gOuHul3-Vf8l5xXxFRwKmvDAw2ZSSZho4QBOcsRjdZHgdHHEy3nZwK7IpTWmUV-CEh44nScoJstLjgPk3-t-xJ-eBr_yGFZL19R929jnIIqb15eiuaYlr2spcpoD9yP5blz3tlb5bRO3VUoL1VbfLMuYbTP8VkIfDtUN5EqJIPwGvLfqtiEaV1gypEvlbP5DPMmI9qBOpWJOF_-Ao4nwF4wI5UoDYsRwpo8ZBuuxsX_Dzv5KoeqXnvNXbzRKImHBb7m0hHoZWSbc_LmSQr2PDhRD9WmP3Mi2aC1UnTQaC2wKMqB_M7LHUWVMvWiZahcwPN4w-ILJoQmbBy.3optJBfWAh2c4oUcxsu5Lw", 3 | "salt": "" 4 | } 5 | -------------------------------------------------------------------------------- /test/setup/encrypted-empty.json: -------------------------------------------------------------------------------- 1 | { 2 | "encrypted": "eyJhbGciOiJkaXIiLCJraWQiOiI1ZEI2dy1pdGdiNmNKb1N6eEFkamVWWHd3TlM0TUJWVUF5dVFBZlNjM3lBIiwiZW5jIjoiQTI1NkdDTSJ9..vlE8R1PKZtBpl_Pw.L_w.zaWoxMTtxw9WKX9TkXMgVQ", 3 | "salt": "" 4 | } 5 | -------------------------------------------------------------------------------- /test/setup/key-bundle.json: -------------------------------------------------------------------------------- 1 | { 2 | "appKey": { 3 | "kty": "oct", 4 | "kid": "L9-eBkDrYHdPdXV_ymuzy_u9n3drkQcSw5pskrNl4pg", 5 | "k": "WsTdZ2tjji2W36JN9vk9s2AYsvp8eYy1pBbKPgcSLL4" 6 | }, 7 | "encryptKey": { 8 | "kty": "oct", 9 | "kid": "5dB6w-itgb6cJoSzxAdjeVXwwNS4MBVUAyuQAfSc3yA", 10 | "alg": "A256GCM", 11 | "k": "BFTwbrKks4kZHzzJZ6ZnHBewRhYhCEM1IGEKDySPtm0" 12 | }, 13 | "hashingKey": { 14 | "kty": "oct", 15 | "kid": "rlzSaeiZm-tbsSbu63LUVCkdoBC7396MSg3vze49ttY", 16 | "alg": "HS256", 17 | "k": "sl6MbNGboQ9Te4isxcSRzxAYzWjO6w9jqBGqpm5uQcA" 18 | }, 19 | "salt": "" 20 | } 21 | -------------------------------------------------------------------------------- /test/util/errors-test.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. 5 | */ 6 | 7 | const assert = require("chai").assert; 8 | 9 | const DataStoreError = require("../../lib/util/errors"); 10 | 11 | describe("util/errors", () => { 12 | describe("Reasons", () => { 13 | it("checks for expected reasons", () => { 14 | assert.strictEqual(DataStoreError.LOCALDB_VERSION, "LOCALDB_VERSION"); 15 | assert.strictEqual(DataStoreError.NOT_INITIALIZED, "NOT_INITIALIZED"); 16 | assert.strictEqual(DataStoreError.INITIALIZED, "INITIALIZED"); 17 | assert.strictEqual(DataStoreError.MISSING_APP_KEY, "MISSING_APP_KEY"); 18 | assert.strictEqual(DataStoreError.WRONG_APP_KEY, "WRONG_APP_KEY"); 19 | assert.strictEqual(DataStoreError.CRYPTO, "CRYPTO"); 20 | assert.strictEqual(DataStoreError.LOCKED, "LOCKED"); 21 | assert.strictEqual(DataStoreError.INVALID_ITEM, "INVALID_ITEM"); 22 | assert.strictEqual(DataStoreError.MISSING_ITEM, "MISSING_ITEM"); 23 | assert.strictEqual(DataStoreError.OFFLINE, "OFFLINE"); 24 | assert.strictEqual(DataStoreError.AUTH, "AUTH"); 25 | assert.strictEqual(DataStoreError.NETWORK, "NETWORK"); 26 | assert.strictEqual(DataStoreError.SYNC_LOCKED, "SYNC_LOCKED"); 27 | assert.strictEqual(DataStoreError.GENERIC_ERROR, "GENERIC_ERROR"); 28 | }); 29 | }); 30 | describe("DataStoreError", () => { 31 | it("creates with all arguments", () => { 32 | let err; 33 | err = new DataStoreError(DataStoreError.GENERIC_ERROR, "some generic error"); 34 | assert.strictEqual(err.message, "GENERIC_ERROR: some generic error"); 35 | assert.strictEqual(err.reason, DataStoreError.GENERIC_ERROR); 36 | assert.notEmpty(err.stack); 37 | }); 38 | it("creates with missing reason", () => { 39 | let err; 40 | err = new DataStoreError("some generic error"); 41 | assert.strictEqual(err.reason, DataStoreError.GENERIC_ERROR); 42 | assert.strictEqual(err.message, `${DataStoreError.GENERIC_ERROR}: some generic error`); 43 | assert.notEmpty(err.stack); 44 | }); 45 | it("creates with missing message", () => { 46 | let err; 47 | err = new DataStoreError(DataStoreError.GENERIC_ERROR); 48 | assert.strictEqual(err.reason, DataStoreError.GENERIC_ERROR); 49 | assert.strictEqual(err.message, DataStoreError.GENERIC_ERROR); 50 | assert.notEmpty(err.stack); 51 | }); 52 | }); 53 | }); 54 | -------------------------------------------------------------------------------- /test/util/instance-test.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. 5 | */ 6 | 7 | const assert = require("chai").assert; 8 | 9 | const instance = require("../../lib/util/instance"); 10 | 11 | describe("util/instance", () => { 12 | let thing1 = new Object(), 13 | thing2 = new Object(); 14 | 15 | it("starts with no instance data", () => { 16 | assert.notExists(instance.get(thing1)); 17 | assert.notExists(instance.get(thing2)); 18 | }); 19 | it("stages instance data", () => { 20 | let data1; 21 | data1 = instance.stage(thing1); 22 | assert.deepEqual(data1, {}); 23 | assert.strictEqual(instance.get(thing1), data1); 24 | assert.strictEqual(instance.stage(thing1), data1); 25 | 26 | data1.name = "thing 1"; 27 | assert.deepEqual(instance.get(thing1), { name: "thing 1" }); 28 | 29 | assert.notExists(instance.get(thing2)); 30 | }); 31 | it("still has instance data", () => { 32 | let data1 = instance.get(thing1); 33 | assert.deepEqual(data1, { name: "thing 1" }); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. 5 | */ 6 | 7 | const PATH = require("path"); 8 | 9 | module.exports = { 10 | devtool: "inline-source-map", 11 | module: { 12 | rules: [ 13 | { 14 | test: /\.js$/, 15 | use: { loader: "babel-loader" }, 16 | include: [PATH.resolve("lib"), PATH.resolve("test")] 17 | }, 18 | { 19 | test: /\.js$/, 20 | use: { loader: "istanbul-instrumenter-loader" }, 21 | include: PATH.resolve("lib") 22 | } 23 | ] 24 | } 25 | }; 26 | --------------------------------------------------------------------------------