├── .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 | [](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 |
--------------------------------------------------------------------------------