From 98cc916432e1bec7953a3479f8424b9629412173 Mon Sep 17 00:00:00 2001 From: proteriax <8125011+proteriax@users.noreply.github.com> Date: Sun, 18 Jul 2021 16:12:04 -0400 Subject: [PATCH] Update --- .gitignore | 3 +- .vscode/launch.json | 12 ++- package.json | 11 ++- pnpm-lock.yaml | 110 +++++++++++++++++++++++++ repl.ts | 14 +++- rollup.config.js | 19 ++++- src/adapters/browser.ts | 0 src/adapters/index.d.ts | 43 ++++++++++ src/adapters/node.ts | 19 +++++ src/crypto.ts | 48 ++++++++++- src/ee.ts | 49 +++++++++++ src/fs.ts | 144 +++++++++++++-------------------- src/global.d.ts | 5 ++ src/index.ts | 43 +++++----- src/models/attachment.ts | 83 +++++++++++++++++++ src/models/crypto.ts | 1 + src/models/item.ts | 67 +++++++++++++++ src/{ => models}/vault.ts | 166 ++++++++++++-------------------------- src/types.ts | 32 +------- src/util.ts | 39 +++++++++ src/weakMap.ts | 40 +++++++++ test/before.ts | 3 + test/profile.test.ts | 1 + test/weakMap.test.ts | 43 ++++++++++ 24 files changed, 726 insertions(+), 269 deletions(-) create mode 100644 src/adapters/browser.ts create mode 100644 src/adapters/index.d.ts create mode 100644 src/adapters/node.ts create mode 100644 src/ee.ts create mode 100644 src/global.d.ts create mode 100644 src/models/attachment.ts create mode 100644 src/models/crypto.ts create mode 100644 src/models/item.ts rename src/{ => models}/vault.ts (53%) create mode 100644 src/util.ts create mode 100644 src/weakMap.ts create mode 100644 test/weakMap.test.ts diff --git a/.gitignore b/.gitignore index 2552f9f..c1fd1b5 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,5 @@ mochawesome-report lib docs ref -*.opvault \ No newline at end of file +*.opvault +freddy \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json index 2ac57e5..541f539 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -5,11 +5,15 @@ "version": "0.2.0", "configurations": [ { - "type": "pwa-node", - "request": "launch", "name": "REPL", - "skipFiles": ["/**"], - "program": "repl.ts" + "type": "node", + "request": "launch", + "runtimeExecutable": "node", + "runtimeArgs": ["--nolazy", "-r", "ts-node/register/transpile-only"], + "args": ["repl.ts"], + "cwd": "${workspaceRoot}", + "internalConsoleOptions": "openOnSessionStart", + "skipFiles": ["/**"] }, { "type": "node", diff --git a/package.json b/package.json index f81af51..08ebe21 100644 --- a/package.json +++ b/package.json @@ -5,19 +5,22 @@ "repository": "https://git.aet.ac/aet/opvault.js.git", "private": true, "scripts": { - "build": "rollup -c; prettier --write lib >/dev/null", + "build": "rollup -c; cp src/adapters/index.d.ts lib/adapters/; prettier --write lib >/dev/null", "build:docs": "typedoc --out docs src/index.ts --excludePrivate", - "test": "mocha test/**/*.test.ts", + "test": "node --expose-gc node_modules/mocha/bin/_mocha test/**/*.test.ts", "repl": "node -r ts-node/register/transpile-only src/repl.ts" }, "devDependencies": { "@rollup/plugin-json": "^4.1.0", + "@rollup/plugin-replace": "^3.0.0", "@types/chai": "^4.2.19", "@types/chai-as-promised": "^7.1.4", "@types/fs-extra": "^9.0.11", "@types/mocha": "github:whitecolor/mocha-types#da22474cf43f48a56c86f8c23a5a0ea36e295768", "@types/node": "^16.0.0", "@types/prompts": "^2.0.13", + "@types/sinon": "^10.0.2", + "@types/sinon-chai": "^3.2.5", "@typescript-eslint/eslint-plugin": "4.28.2", "@typescript-eslint/parser": "4.28.2", "chai": "^4.3.4", @@ -37,6 +40,8 @@ "prompts": "^2.4.1", "rollup": "^2.52.7", "rollup-plugin-ts": "^1.4.0", + "sinon": "^11.1.1", + "sinon-chai": "^3.7.0", "ts-node": "^10.0.0", "tsconfig-paths": "^3.9.0", "typedoc": "^0.21.2", @@ -54,4 +59,4 @@ "tiny-invariant": "1.1.0", "tslib": "2.3.0" } -} \ No newline at end of file +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6e26eb2..60a2e3f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2,12 +2,15 @@ lockfileVersion: 5.3 specifiers: '@rollup/plugin-json': ^4.1.0 + '@rollup/plugin-replace': ^3.0.0 '@types/chai': ^4.2.19 '@types/chai-as-promised': ^7.1.4 '@types/fs-extra': ^9.0.11 '@types/mocha': github:whitecolor/mocha-types#da22474cf43f48a56c86f8c23a5a0ea36e295768 '@types/node': ^16.0.0 '@types/prompts': ^2.0.13 + '@types/sinon': ^10.0.2 + '@types/sinon-chai': ^3.2.5 '@typescript-eslint/eslint-plugin': 4.28.2 '@typescript-eslint/parser': 4.28.2 chai: ^4.3.4 @@ -27,6 +30,8 @@ specifiers: prompts: ^2.4.1 rollup: ^2.52.7 rollup-plugin-ts: ^1.4.0 + sinon: ^11.1.1 + sinon-chai: ^3.7.0 tiny-invariant: 1.1.0 ts-node: ^10.0.0 tsconfig-paths: ^3.9.0 @@ -40,12 +45,15 @@ dependencies: devDependencies: '@rollup/plugin-json': 4.1.0_rollup@2.52.7 + '@rollup/plugin-replace': 3.0.0_rollup@2.52.7 '@types/chai': 4.2.19 '@types/chai-as-promised': 7.1.4 '@types/fs-extra': 9.0.11 '@types/mocha': github.com/whitecolor/mocha-types/da22474cf43f48a56c86f8c23a5a0ea36e295768 '@types/node': 16.0.0 '@types/prompts': 2.0.13 + '@types/sinon': 10.0.2 + '@types/sinon-chai': 3.2.5 '@typescript-eslint/eslint-plugin': 4.28.2_5031fffb45dfb7117e61c1d8ea1ef3ff '@typescript-eslint/parser': 4.28.2_eslint@7.30.0+typescript@4.3.5 chai: 4.3.4 @@ -65,6 +73,8 @@ devDependencies: prompts: 2.4.1 rollup: 2.52.7 rollup-plugin-ts: 1.4.0_rollup@2.52.7+typescript@4.3.5 + sinon: 11.1.1 + sinon-chai: 3.7.0_chai@4.3.4+sinon@11.1.1 ts-node: 10.0.0_488376d43314e2606bceb2872a37d0ef tsconfig-paths: 3.9.0 typedoc: 0.21.2_typescript@4.3.5 @@ -1275,6 +1285,16 @@ packages: rollup: 2.52.7 dev: true + /@rollup/plugin-replace/3.0.0_rollup@2.52.7: + resolution: {integrity: sha512-3c7JCbMuYXM4PbPWT4+m/4Y6U60SgsnDT/cCyAyUKwFHg7pTSfsSQzIpETha3a3ig6OdOKzZz87D9ZXIK3qsDg==} + peerDependencies: + rollup: ^1.20.0 || ^2.0.0 + dependencies: + '@rollup/pluginutils': 3.1.0_rollup@2.52.7 + magic-string: 0.25.7 + rollup: 2.52.7 + dev: true + /@rollup/pluginutils/3.1.0_rollup@2.52.7: resolution: {integrity: sha512-GksZ6pr6TpIjHm8h9lSQ8pi8BE9VeubNT0OMJ3B5uZJ8pz73NPiqOtCog/x2/QzM1ENChPKxMDhiQuRHsqc+lg==} engines: {node: '>= 8.0.0'} @@ -1298,6 +1318,30 @@ packages: rollup: 2.52.7 dev: true + /@sinonjs/commons/1.8.3: + resolution: {integrity: sha512-xkNcLAn/wZaX14RPlwizcKicDk9G3F8m2nU3L7Ukm5zBgTwiT0wsoFAHx9Jq56fJA1z/7uKGtCRu16sOUCLIHQ==} + dependencies: + type-detect: 4.0.8 + dev: true + + /@sinonjs/fake-timers/7.1.2: + resolution: {integrity: sha512-iQADsW4LBMISqZ6Ci1dupJL9pprqwcVFTcOsEmQOEhW+KLCVn/Y4Jrvg2k19fIHCp+iFprriYPTdRcQR8NbUPg==} + dependencies: + '@sinonjs/commons': 1.8.3 + dev: true + + /@sinonjs/samsam/6.0.2: + resolution: {integrity: sha512-jxPRPp9n93ci7b8hMfJOFDPRLFYadN6FSpeROFTR4UNF4i5b+EK6m4QXPO46BDhFgRy1JuS87zAnFOzCUwMJcQ==} + dependencies: + '@sinonjs/commons': 1.8.3 + lodash.get: 4.4.2 + type-detect: 4.0.8 + dev: true + + /@sinonjs/text-encoding/0.7.1: + resolution: {integrity: sha512-+iTbntw2IZPb/anVDbypzfQa+ay64MW0Zo8aJ8gZPWMMK6/OubMVb6lUPMagqjOPnmtauXnFCACVl3O7ogjeqQ==} + dev: true + /@tsconfig/node10/1.0.8: resolution: {integrity: sha512-6XFfSQmMgq0CFLY1MslA/CPUfhIL919M1rMsa5lP2P097N2Wd1sSX0tx1u4olM16fLNhtHZpRhedZJphNJqmZg==} dev: true @@ -1357,6 +1401,10 @@ packages: resolution: {integrity: sha512-E121rHk/4BlcEwANZOwcHl8L/Sl0zyIFXJoyggXkl7FCT/4MTf5u25f+qiphe0V5ELaFIkCptgvbf4whCJUVMA==} dev: true + /@types/chai/4.2.21: + resolution: {integrity: sha512-yd+9qKmJxm496BOV9CMNaey8TWsikaZOwMRwPHQIjcOJM9oV+fi9ZMNw3JsVnbEEbo2gRTDnGEBv8pjyn67hNg==} + dev: true + /@types/estree/0.0.39: resolution: {integrity: sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==} dev: true @@ -1397,6 +1445,19 @@ packages: resolution: {integrity: sha512-0caWDWmpCp0uifxFh+FaqK3CuZ2SkRR/ZRxAV5+zNdC3QVUi6wyOJnefhPvtNt8NQWXB5OA93BUvZsXpWat2Xw==} dev: true + /@types/sinon-chai/3.2.5: + resolution: {integrity: sha512-bKQqIpew7mmIGNRlxW6Zli/QVyc3zikpGzCa797B/tRnD9OtHvZ/ts8sYXV+Ilj9u3QRaUEM8xrjgd1gwm1BpQ==} + dependencies: + '@types/chai': 4.2.21 + '@types/sinon': 10.0.2 + dev: true + + /@types/sinon/10.0.2: + resolution: {integrity: sha512-BHn8Bpkapj8Wdfxvh2jWIUoaYB/9/XhsL0oOvBfRagJtKlSl9NWPcFOz2lRukI9szwGxFtYZCTejJSqsGDbdmw==} + dependencies: + '@sinonjs/fake-timers': 7.1.2 + dev: true + /@types/ua-parser-js/0.7.36: resolution: {integrity: sha512-N1rW+njavs70y2cApeIw1vLMYXRwfBy+7trgavGuuTfOd7j1Yh7QTRc/yqsPl6ncokt72ZXuxEU0PiCp9bSwNQ==} dev: true @@ -2820,6 +2881,10 @@ packages: engines: {node: '>=10'} dev: true + /isarray/0.0.1: + resolution: {integrity: sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=} + dev: true + /isbot/3.0.26: resolution: {integrity: sha512-y1IwTPP6pRGDmQUTrCz1bZ9ZPSmij3eWruBBIiCOARX5ueyLv58xuFxvUGg6uI0k9u1swnOmJR8DKYZbcDXLqQ==} engines: {node: '>=10'} @@ -2916,6 +2981,10 @@ packages: object.assign: 4.1.1 dev: true + /just-extend/4.2.1: + resolution: {integrity: sha512-g3UB796vUFIY90VIv/WX3L2c8CS2MdWUww3CNrYmqza1Fg0DURc2K/O4YrnklBdQarSJ/y8JnJYDGc+1iumQjg==} + dev: true + /kleur/3.0.3: resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} engines: {node: '>=6'} @@ -2970,6 +3039,10 @@ packages: resolution: {integrity: sha1-gteb/zCmfEAF/9XiUVMArZyk168=} dev: true + /lodash.get/4.4.2: + resolution: {integrity: sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk=} + dev: true + /lodash.isempty/4.4.0: resolution: {integrity: sha1-b4bL7di+TsmHvpqvM8loTbGzHn4=} dev: true @@ -3170,6 +3243,16 @@ packages: resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} dev: true + /nise/5.1.0: + resolution: {integrity: sha512-W5WlHu+wvo3PaKLsJJkgPup2LrsXCcm7AWwyNZkUnn5rwPkuPBi3Iwk5SQtN0mv+K65k7nKKjwNQ30wg3wLAQQ==} + dependencies: + '@sinonjs/commons': 1.8.3 + '@sinonjs/fake-timers': 7.1.2 + '@sinonjs/text-encoding': 0.7.1 + just-extend: 4.2.1 + path-to-regexp: 1.8.0 + dev: true + /node-releases/1.1.73: resolution: {integrity: sha512-uW7fodD6pyW2FZNZnp/Z3hvWKeEW1Y8R1+1CnErE8cXFXzl5blBOoVB41CvMer6P6Q0S5FXDwcHgFd1Wj0U9zg==} dev: true @@ -3383,6 +3466,12 @@ packages: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} dev: true + /path-to-regexp/1.8.0: + resolution: {integrity: sha512-n43JRhlUKUAlibEJhPeir1ncUID16QnEjNpwzNdO3Lm4ywrBpBZ5oLD0I6br9evr1Y9JTqwRtAh7JLoOzAQdVA==} + dependencies: + isarray: 0.0.1 + dev: true + /path-type/3.0.0: resolution: {integrity: sha512-T2ZUsdZFHgA3u4e5PfPbjd7HDDpxPnQb5jN0SrDsjNSuVXHJqtwTnWqG0B1jZrgmJ/7lj1EmVIByWt1gxGkWvg==} engines: {node: '>=4'} @@ -3717,6 +3806,27 @@ packages: object-inspect: 1.10.3 dev: true + /sinon-chai/3.7.0_chai@4.3.4+sinon@11.1.1: + resolution: {integrity: sha512-mf5NURdUaSdnatJx3uhoBOrY9dtL19fiOtAdT1Azxg3+lNJFiuN0uzaU3xX1LeAfL17kHQhTAJgpsfhbMJMY2g==} + peerDependencies: + chai: ^4.0.0 + sinon: '>=4.0.0' + dependencies: + chai: 4.3.4 + sinon: 11.1.1 + dev: true + + /sinon/11.1.1: + resolution: {integrity: sha512-ZSSmlkSyhUWbkF01Z9tEbxZLF/5tRC9eojCdFh33gtQaP7ITQVaMWQHGuFM7Cuf/KEfihuh1tTl3/ABju3AQMg==} + dependencies: + '@sinonjs/commons': 1.8.3 + '@sinonjs/fake-timers': 7.1.2 + '@sinonjs/samsam': 6.0.2 + diff: 5.0.0 + nise: 5.1.0 + supports-color: 7.2.0 + dev: true + /sisteransi/1.0.5: resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} dev: true diff --git a/repl.ts b/repl.ts index ac1c8bd..c5bd63f 100755 --- a/repl.ts +++ b/repl.ts @@ -1,11 +1,13 @@ -#!/usr/bin/env ts-node-transpile-only +#!/usr/bin/env node -r ts-node/register/transpile-only import fs from "fs" import chalk from "chalk" import prompts from "prompts" import { OnePassword } from "./src/index" +const path = "./freddy" + async function main(args: string[]) { - const instance = new OnePassword({ path: args[0] }) + const instance = new OnePassword({ path }) const profiles = await instance.getProfileNames() // const { profile } = await prompts({ @@ -28,13 +30,17 @@ async function main(args: string[]) { vault.unlock(password) + const find = vault.overviews.get("A note with some attachments")! + const item = vault.getItem(find.uuid!) + const d = vault.decryptAttachment( + item!.original, fs.readFileSync( - "./freddy-2013-12-04.opvault/default/1C7D72EFA19A4EE98DB7A9661D2F5732_3B94A1F475014E27BFB00C99A42214DF.attachment" + "./freddy/default/F2DB5DA3FCA64372A751E0E85C67A538_AFBDA49A5F684179A78161E40CA2AAD3.attachment" ) ) - fs.writeFileSync("./test", d) + fs.writeFileSync("./test2.png", d.file) // console.log(vault.overviews.values()) } diff --git a/rollup.config.js b/rollup.config.js index 1778179..39f3185 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -1,15 +1,28 @@ import { builtinModules } from "module" import ts from "rollup-plugin-ts" import json from "@rollup/plugin-json" +import replace from "@rollup/plugin-replace" import { dependencies } from "./package.json" /** @returns {import("rollup").RollupOptions} */ export default () => ({ - input: "./src/index.ts", + input: { + index: "./src/index.ts", + "adapters/node": "./src/adapters/node.ts", + }, external: builtinModules.concat(Object.keys(dependencies)), output: { - file: "lib/index.js", + dir: "lib", format: "cjs", }, - plugins: [ts(), json()], + plugins: [ + ts({ transpileOnly: true }), + json(), + replace({ + preventAssignment: true, + values: { + "process.env.NODE_ENV": '"production"', + }, + }), + ], }) diff --git a/src/adapters/browser.ts b/src/adapters/browser.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/adapters/index.d.ts b/src/adapters/index.d.ts new file mode 100644 index 0000000..c8a5686 --- /dev/null +++ b/src/adapters/index.d.ts @@ -0,0 +1,43 @@ +/** + * An object that implements basic file system functionalities. + */ +export interface IFileSystem { + /** + * Synchronously tests whether or not the given path exists by checking with the file system. + * @param path A path to a file or directory. + */ + existsSync(path: string): boolean + + /** + * Asynchronously reads the entire contents of a file. + * @param path A path to a file. + */ + readFile(path: string): Promise + + /** + * Asynchronously writes data to a file, replacing the file if it already exists. + * @param path A path to a file. + * @param data The data to write. + */ + writeFile(path: string, data: string): Promise + + /** + * Asynchronous readdir(3) - read a directory. + * @param path A path to a directory. + */ + readdir(path: string): Promise + + /** + * Asynchronous stat(2) - Get file status. + * @param path A path to a file. + */ + stat(path: string): Promise<{ isDirectory(): boolean }> +} + +export interface IAdapter { + /** + * Underlying `fs` module. You can replace it with a wrapper of + * `memfs` or any object that implements `IFileSystem`. + */ + fs: IFileSystem +} diff --git a/src/adapters/node.ts b/src/adapters/node.ts new file mode 100644 index 0000000..a9e691c --- /dev/null +++ b/src/adapters/node.ts @@ -0,0 +1,19 @@ +import { promises as fs, existsSync } from "fs" + +import type { IAdapter } from "./index" + +/** + * Default Node.js adapter. This can be used while using `opvault.js` + * in a Node.js environment. + */ +const nodeAdapter: IAdapter = { + fs: { + readFile: path => fs.readFile(path, "utf-8"), + writeFile: fs.writeFile, + readdir: fs.readdir, + stat: fs.stat, + existsSync, + }, +} + +export default nodeAdapter diff --git a/src/crypto.ts b/src/crypto.ts index cdeedb9..d1d2eef 100644 --- a/src/crypto.ts +++ b/src/crypto.ts @@ -1,9 +1,18 @@ -import { webcrypto } from "crypto" +import { webcrypto, createHmac, createDecipheriv, createHash } from "crypto" +import { HMACAssertionError } from "./errors" declare module "crypto" { export const webcrypto: Crypto } +/** Encryption and MAC */ +export interface Cipher { + /** Encryption key */ + key: Buffer + /** HMAC key */ + hmac: Buffer +} + async function pbkdf2(password: string, salt: string, iterations = 1000, length = 256) { const encoder = new TextEncoder() const key = await webcrypto.subtle.importKey( @@ -25,3 +34,40 @@ async function pbkdf2(password: string, salt: string, iterations = 1000, length ) return bits } + +export function assertHMac(data: Buffer, key: Buffer, expected: Buffer) { + const actual = createHmac("sha256", key).update(data).digest() + if (!actual.equals(expected)) { + throw new HMACAssertionError() + } +} + +export const splitPlainText = (derivedKey: Buffer): Cipher => ({ + key: derivedKey.slice(0, 32), + hmac: derivedKey.slice(32, 64), +}) + +export function decryptKeys(encryptedKey: string, derived: Cipher) { + const buffer = Buffer.from(encryptedKey, "base64") + const base = decryptOPData(buffer, derived) + const digest = createHash("sha512").update(base).digest() + return splitPlainText(digest) +} + +export function decryptData(key: Buffer, iv: Buffer, data: Buffer) { + const cipher = createDecipheriv("aes-256-cbc", key, iv).setAutoPadding(false) + return Buffer.concat([cipher.update(data), cipher.final()]) +} + +export function decryptOPData(cipherText: Buffer, cipher: Cipher) { + const key = cipherText.slice(0, -32) + assertHMac(key, cipher.hmac, cipherText.slice(-32)) + + const plaintext = decryptData(cipher.key, key.slice(16, 32), key.slice(32)) + const size = readUint16(key.slice(8, 16)) + return plaintext.slice(-size) +} + +function readUint16({ buffer, byteOffset, length }: Buffer) { + return new DataView(buffer, byteOffset, length).getUint16(0, true) +} diff --git a/src/ee.ts b/src/ee.ts new file mode 100644 index 0000000..95ac4e6 --- /dev/null +++ b/src/ee.ts @@ -0,0 +1,49 @@ +type EventKeyWithNoArg = { + [K in keyof T]: T[K] extends void ? K : never +}[keyof T] + +type CallbackSignature = K extends EventKeyWithNoArg + ? () => void + : (value: T[K]) => void + +export class EventEmitter> { + #listeners = new Map any>>() + + #getList(key: keyof T) { + if (!this.#listeners.has(key)) { + this.#listeners.set(key, new Set()) + } + return this.#listeners.get(key)! + } + + on(key: K, fn: CallbackSignature) { + this.#getList(key).add(fn) + return this + } + + off(key: K, fn: CallbackSignature) { + this.#getList(key).delete(fn) + return this + } + + once(key: K, fn: CallbackSignature) { + const wrapped = (...arg: any[]) => { + ;(fn as any)(...arg) + this.off(key, wrapped) + } + return this.on(key, wrapped) + } + + emit>(key: K): this + emit(key: K, value: T[K]): this + + emit(key: K, value?: T[K]) { + const listeners = this.#getList(key) + if (arguments.length === 1) { + listeners.forEach(fn => fn()) + } else { + listeners.forEach(fn => fn(value!)) + } + return this + } +} diff --git a/src/fs.ts b/src/fs.ts index 9ea5a4e..a812bf7 100644 --- a/src/fs.ts +++ b/src/fs.ts @@ -1,94 +1,64 @@ -import { resolve } from "path" +import { resolve, extname } from "path" import invariant from "tiny-invariant" -import type { Stats } from "fs" +import type { IFileSystem } from "./adapters" +import { once } from "./util" -/** - * An object that implements basic file system functionalities. - * - */ -export interface IFileSystem { - /** - * Synchronously tests whether or not the given path exists by checking with the file system. - * @param path A path to a file or directory. - */ - existsSync(path: string): boolean +export type OnePasswordFileManager = ReturnType - /** - * Asynchronously reads the entire contents of a file. - * @param path A path to a file. - */ - readFile(path: string, encoding: BufferEncoding): Promise +export function OnePasswordFileManager( + fs: IFileSystem, + path: string, + profileName: string +) { + const root = resolve(path, profileName) + invariant(fs.existsSync(path), `Path ${path} does not exist.`) + invariant(fs.existsSync(root), `Profile ${profileName} does not exist.`) - /** - * Asynchronously writes data to a file, replacing the file if it already exists. - * @param path A path to a file. - * @param data The data to write. - */ - writeFile(path: string, data: string): Promise + const abs = (path: string) => resolve(root, path) - /** - * Asynchronous readdir(3) - read a directory. - * @param path A path to a directory. - */ - readdir(path: string): Promise + const result = { + getProfile() { + return fs.readFile(abs("profile.js")) + }, - /** - * Asynchronous stat(2) - Get file status. - * @param path A path to a file. - */ - stat(path: string): Promise -} - -export function getDefaultFileSystem(): IFileSystem { - const fs: typeof import("fs") = require("fs") - return { ...fs.promises, existsSync: fs.existsSync } -} - -export class OnePasswordFileManager { - private root: string - - constructor(private fs: IFileSystem, path: string, profileName: string) { - this.root = resolve(path, profileName) - invariant(fs.existsSync(path), `Path ${path} does not exist.`) - invariant(fs.existsSync(this.root), `Profile ${profileName} does not exist.`) - } - - #hasFile(path: string) { - return this.fs.existsSync(resolve(this.root, path)) - } - - async #readFile(path: string) { - return await this.fs.readFile(resolve(this.root, path), "utf-8") - } - - async #writeFile(path: string, value: string) { - return await this.fs.writeFile(resolve(this.root, path), value) - } - - getProfile() { - return this.#readFile("profile.js") - } - - getFolders() { - return this.#readFile("folders.js") - } - - async getBand(name: string) { - const fileName = `band_${name.toUpperCase()}.js` - if (this.#hasFile(fileName)) { - return await this.#readFile(fileName) - } - } - - setProfile(profile: string) { - this.#writeFile("profile.js", profile) - } - - setFolders(folders: string) { - this.#writeFile("folders.js", folders) - } - - setBand(name: string, band: string) { - this.#writeFile(`band_${name}.js`, band) - } + getFolders() { + return fs.readFile(abs("folders.js")) + }, + + async getAttachments() { + const files = await fs.readdir(root) + files + .filter(name => extname(name) === ".attachment") + .forEach(name => { + const sep = name.indexOf("_") + const path = resolve(root, name) + const [itemUUID, fileUUID] = [name.slice(0, sep), name.slice(sep + 1)] + return { + itemUUID, + fileUUID, + getFile: once(() => fs.readFile(path)), + } + }) + }, + + async getBand(name: string) { + const path = abs(`band_${name}.js`) + if (fs.existsSync(path)) { + return await fs.readFile(path) + } + }, + + async setProfile(profile: string) { + await fs.writeFile("profile.js", profile) + }, + + async setFolders(folders: string) { + await fs.writeFile("folders.js", folders) + }, + + async setBand(name: string, band: string) { + await fs.writeFile(`band_${name}.js`, band) + }, + } + return result } diff --git a/src/global.d.ts b/src/global.d.ts new file mode 100644 index 0000000..7a96dcc --- /dev/null +++ b/src/global.d.ts @@ -0,0 +1,5 @@ +type integer = number + +interface Disposable { + dispose(): void +} diff --git a/src/index.ts b/src/index.ts index 5cae43a..c376d58 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,22 +1,22 @@ import { resolve } from "path" -import type { IFileSystem } from "./fs" -import { getDefaultFileSystem } from "./fs" -import { Vault } from "./vault" +import { Vault } from "./models/vault" +import { invariant } from "./errors" +import type { IAdapter } from "./adapters" +import { asyncMap } from "./util" -export type { Vault } from "./vault" +export type { Vault } from "./models/vault" export { Category, FieldType } from "./models" -interface Options { +interface IOptions { /** * Path to `.opvault` directory */ path: string /** - * Underlying `fs` module. You can replace it with a wrapper of - * `memfs` or any object that implements `IFileSystem`. + * Adapter used to interact with the file system and cryptography modules */ - fs?: IFileSystem + adapter?: IAdapter } /** @@ -24,29 +24,28 @@ interface Options { */ export class OnePassword { readonly #path: string - readonly #fs: IFileSystem + readonly #adapter: IAdapter - constructor({ path, fs = getDefaultFileSystem() }: Options) { - this.#fs = fs + constructor({ path, adapter = require("./adapters/node").default }: IOptions) { + this.#adapter = adapter this.#path = path + invariant(path, "Path must not be empty") } /** * @returns A list of names of profiles of the current vault. */ async getProfileNames() { - const [fs, path] = [this.#fs, this.#path] + const [fs, path] = [this.#adapter.fs, this.#path] const children = await fs.readdir(path) const profiles: string[] = [] - await Promise.all( - children.map(async child => { - const fullPath = resolve(path, child) - const stats = await fs.stat(fullPath) - if (stats.isDirectory() && fs.existsSync(resolve(fullPath, "profile.js"))) { - profiles.push(child) - } - }) - ) + await asyncMap(children, async child => { + const fullPath = resolve(path, child) + const stats = await fs.stat(fullPath) + if (stats.isDirectory() && fs.existsSync(resolve(fullPath, "profile.js"))) { + profiles.push(child) + } + }) return profiles } @@ -54,6 +53,6 @@ export class OnePassword { * @returns A OnePassword Vault instance. */ async getProfile(profileName: string) { - return await Vault.of(this.#path, profileName, this.#fs) + return await Vault.of(this.#path, profileName, this.#adapter) } } diff --git a/src/models/attachment.ts b/src/models/attachment.ts new file mode 100644 index 0000000..c5bfbe8 --- /dev/null +++ b/src/models/attachment.ts @@ -0,0 +1,83 @@ +import type { Cipher } from "../crypto" +import { decryptOPData } from "../crypto" +import { invariant } from "../errors" +import { cache } from "../util" +import type { Item } from "./item" + +type integer = number + +export interface AttachmentMetadata { + itemUUID: string + contentSize: integer + external: boolean + updatedAt: integer + txTimestamp: integer + /** Base64 encoded OPData */ + overview: string + createdAt: integer + uuid: string +} + +export class Attachment implements Disposable { + #item: WeakRef + + private metadataSize: number + private iconSize: number + + constructor(item: Item, private buffer: Buffer) { + this.#validate() + this.#item = new WeakRef(item) + this.metadataSize = buffer.readIntLE(8, 2) + this.iconSize = buffer.readIntLE(12, 3) + } + + /** + * Validate attachment file. + */ + #validate() { + const file = this.buffer + invariant( + file.slice(0, 6).toString("utf-8") === "OPCLDA", + "Attachment must start with OPCLDA" + ) + invariant( + file.readIntLE(7, 1) === 1, + "The version for this attachment file format is not supported." + ) + } + + @cache() + get metadata(): AttachmentMetadata { + return JSON.parse(this.buffer.slice(16, 16 + this.metadataSize).toString("utf-8")) + } + + @cache() + get icon() { + const { buffer, metadataSize, iconSize } = this + const iconData = buffer.slice(16 + metadataSize, 16 + metadataSize + iconSize) + return decryptOPData(iconData, cipher) + } + + /** + * Decrypt the content of this attachment. This function + * shall be called by an `Item` where `cipher` is `deriveConcreteKey(item, master)`. + * @internal + */ + decrypt(cipher: Cipher) { + const { buffer, metadataSize, iconSize } = this + const metadata: AttachmentMetadata = JSON.parse( + buffer.slice(16, 16 + metadataSize).toString("utf-8") + ) + const iconData = buffer.slice(16 + metadataSize, 16 + metadataSize + iconSize) + + return { + metadata, + icon: decryptOPData(iconData, cipher), + file: decryptOPData(buffer.slice(16 + metadataSize + iconSize), cipher), + } + } + + dispose() { + this.buffer = null! + } +} diff --git a/src/models/crypto.ts b/src/models/crypto.ts new file mode 100644 index 0000000..77d4861 --- /dev/null +++ b/src/models/crypto.ts @@ -0,0 +1 @@ +export class Crypto {} diff --git a/src/models/item.ts b/src/models/item.ts new file mode 100644 index 0000000..5b914d8 --- /dev/null +++ b/src/models/item.ts @@ -0,0 +1,67 @@ +import { assertHMac, decryptData, decryptOPData, splitPlainText } from "../crypto" +import type { ItemDetails } from "../types" +import type { Cipher } from "../crypto" +import type { Attachment } from "./attachment" + +export interface EncryptedItem { + category: string // "001" + /** Unix seconds */ + created: integer + d: string // "b3BkYXRhMbt" + folder: string // 32 chars + hmac: string // base64 + k: string // base64 + o: string // base64 + tx: integer // Unix seconds + updated: integer // Unix seconds + uuid: string // 32 chars +} + +export class ItemAttachmentBridge { + constructor(public cipher?: Cipher) {} +} + +export class Item implements Disposable { + #data: EncryptedItem + + itemDetails!: ItemDetails + attachments: Attachment[] = [] + + get uuid() { + return this.#data.uuid + } + + constructor(data: EncryptedItem) { + this.#data = data + } + + dispose() { + this.attachments.forEach(a => a.dispose()) + this.itemDetails = null! + } + + #deriveConcreteKey(master: Cipher) { + const k = Buffer.from(this.#data.k, "base64") + const data = k.slice(0, -32) + assertHMac(data, master.hmac, k.slice(-32)) + const derivedKey = decryptData(master.key, data.slice(0, 16), data.slice(16)) + return splitPlainText(derivedKey) + } + + /** @internal */ + decryptItemDetail(master: Cipher): ItemDetails { + const cipher = this.#deriveConcreteKey(master) + const detail = decryptOPData( + /* cipherText */ Buffer.from(this.#data.d, "base64"), + /* cipher */ cipher + ) + this.itemDetails = JSON.parse(detail.toString("utf-8")) + return this.itemDetails + } + + /** @internal */ + decryptAttachments(master: Cipher) { + const cipher = this.#deriveConcreteKey(master) + this.attachments.forEach(a => a.decrypt(cipher)) + } +} diff --git a/src/vault.ts b/src/models/vault.ts similarity index 53% rename from src/vault.ts rename to src/models/vault.ts index 97d976f..dcc6151 100644 --- a/src/vault.ts +++ b/src/models/vault.ts @@ -1,24 +1,27 @@ import * as crypto from "crypto" -import { HMACAssertionError, invariant } from "./errors" -import type { IFileSystem } from "./fs" -import { OnePasswordFileManager } from "./fs" -import { i18n } from "./i18n" - -import type { - Profile, - Band, - Overview, - EncryptedItem, - Item, - AttachmentMetadata, -} from "./types" +import type { IAdapter } from "../adapters" +import type { Cipher } from "../crypto" +import { decryptKeys, splitPlainText, decryptOPData } from "../crypto" +import { HMACAssertionError, invariant } from "../errors" +import { OnePasswordFileManager } from "../fs" +import { i18n } from "../i18n" +import type { EncryptedItem } from "./item" +import { Item } from "./item" +import type { Profile, Overview } from "../types" +import { WeakValueMap } from "../weakMap" +import { EventEmitter } from "../ee" +type Band = { [uuid: string]: Item } type FoldersMap = { [uuid: string]: Band } +interface VaultEvents { + lock: void +} + /** * Main OnePassword Vault class */ -export class Vault { +export class Vault extends EventEmitter { // Ciphers #master?: Cipher #overview?: Cipher @@ -26,42 +29,50 @@ export class Vault { // File system interface #files: OnePasswordFileManager - // Encrypted contents #profile: Profile #folders: FoldersMap - #bands = new Map() - // Decrypted contents with plain-texts #overviews = new Map() - #items = new Map() + #items: Item[] = [] + #itemsMap = new WeakValueMap() private constructor( files: OnePasswordFileManager, profile: Profile, folders: FoldersMap, - bands: Map + items: Item[] ) { + super() this.#files = files this.#profile = profile this.#folders = folders - this.#bands = bands + this.#items = items + + items.forEach(item => { + this.#itemsMap.set(item.uuid, item) + }) } /** * Create a new OnePassword Vault instance and read all bands. + * @internal */ - static async of(path: string, profileName = "default", fs: IFileSystem) { - const files = new OnePasswordFileManager(fs, path, profileName) + static async of(path: string, profileName = "default", adapter: IAdapter) { + const files = OnePasswordFileManager(adapter.fs, path, profileName) const profile = JSON.parse( stripText(await files.getProfile(), /^var profile\s*=/, ";") ) const folders = JSON.parse(stripText(await files.getFolders(), "loadFolders(", ");")) - const bands = new Map() + const bands: Item[] = [] for (let i = 0; i < 36; i++) { - const letter = i.toString(36) + const letter = i.toString(36).toUpperCase() const source = await files.getBand(letter) - bands.set(letter, source ? JSON.parse(stripText(source, "ld(", ");")) : {}) + if (!source) continue + const object = JSON.parse(stripText(source, "ld(", ");")) + for (const value of Object.values(object)) { + bands.push(new Item(value as EncryptedItem)) + } } return new Vault(files, profile, folders, bands) @@ -120,10 +131,6 @@ export class Vault { return this } - decryptAttachment(buffer: Buffer) { - return decryptAttachment(buffer, this.#master!) - } - /** * Remove derived keys stored within the class instance. */ @@ -131,6 +138,7 @@ export class Vault { this.#master = null! this.#overview = null! this.#overviews.clear() + this.emit("lock") return this } @@ -146,13 +154,25 @@ export class Vault { getItem(uuid: string) { this.#assertUnlocked() - const encrypted = uuid ? this.#bands.get(uuid[0])![uuid] : undefined - return encrypted && decryptItem(encrypted, this.#master!) + const item = this.#itemsMap.get(uuid) + if (!item) return + + if (!item.itemDetails) { + item.decryptItemDetail(this.#master!) + } + + const encrypted = uuid ? this.#encryptedItems.get(uuid[0])![uuid] : undefined + if (!encrypted) return + + return { + original: encrypted, + details: decryptItemDetail(encrypted, this.#master!), + } } #readOverviews() { this.#assertUnlocked() - this.#bands.forEach(value => { + this.#encryptedItems.forEach(value => { for (const [uuid, item] of Object.entries(value)) { const overview = decryptOverview(item, this.#overview!) overview.uuid = uuid @@ -162,7 +182,7 @@ export class Vault { } } -function decryptOverview(item: EncryptedItem, overviewCipher: Cipher) { +function decryptOverview(item: EncryptedItem, overviewCipher: Cipher): Overview { try { const overview = decryptOPData(toBuffer(item.o), overviewCipher) return JSON.parse(overview.toString("utf8")) as Overview @@ -172,49 +192,6 @@ function decryptOverview(item: EncryptedItem, overviewCipher: Cipher) { } } -function decryptItem(item: EncryptedItem, master: Cipher): Item { - const k = toBuffer(item.k) - const data = k.slice(0, -32) - assertHMac(data, master.hmac, k.slice(-32)) - const derivedKey = decryptData(master.key, data.slice(0, 16), data.slice(16)) - - const detail = decryptOPData( - /* cipherText */ toBuffer(item.d), - /* cipher */ splitPlainText(derivedKey) - ) - return JSON.parse(detail.toString("utf-8")) -} - -function decryptAttachment(item: Buffer, master: Cipher) { - invariant( - item.slice(0, 6).toString("utf-8") === "OPCLDA", - "Attachment must start with OPCLDA" - ) - invariant( - item.readIntLE(7, 1) === 1, - "The version for this attachment file format is not supported." - ) - - const metadataSize = item.readIntLE(8, 2) - const iconSize = item.readIntLE(12, 3) - - const metadata: AttachmentMetadata = JSON.parse( - item.slice(16, 16 + metadataSize).toString("utf-8") - ) - const icondata = item.slice(16 + metadataSize, 16 + metadataSize + iconSize) - console.log(icondata.slice(0, 8).toString()) - const iconData = decryptOPData(icondata, master) - return iconData -} - -/** Encryption and MAC */ -interface Cipher { - /** Encryption key */ - key: Buffer - /** HMAC key */ - hmac: Buffer -} - function stripText(text: string, prefix: string | RegExp, suffix: string | RegExp) { if (typeof prefix === "string") { if (text.startsWith(prefix)) { @@ -239,43 +216,6 @@ function stripText(text: string, prefix: string | RegExp, suffix: string | RegEx return text } -const splitPlainText = (derivedKey: Buffer): Cipher => ({ - key: derivedKey.slice(0, 32), - hmac: derivedKey.slice(32, 64), -}) - -function decryptOPData(cipherText: Buffer, cipher: Cipher) { - const key = cipherText.slice(0, -32) - assertHMac(key, cipher.hmac, cipherText.slice(-32)) - - const plaintext = decryptData(cipher.key, key.slice(16, 32), key.slice(32)) - const size = readUint16(key.slice(8, 16)) - return plaintext.slice(-size) -} - -function assertHMac(data: Buffer, key: Buffer, expected: Buffer) { - const actual = crypto.createHmac("sha256", key).update(data).digest() - if (!actual.equals(expected)) { - throw new HMACAssertionError() - } -} - -function decryptKeys(encryptedKey: string, derived: Cipher) { - const buffer = toBuffer(encryptedKey) - const base = decryptOPData(buffer, derived) - const digest = crypto.createHash("sha512").update(base).digest() - return splitPlainText(digest) -} - -function decryptData(key: Buffer, iv: Buffer, data: Buffer) { - const cipher = crypto.createDecipheriv("aes-256-cbc", key, iv).setAutoPadding(false) - return Buffer.concat([cipher.update(data), cipher.final()]) -} - -function readUint16({ buffer, byteOffset, length }: Buffer) { - return new DataView(buffer, byteOffset, length).getUint16(0, true) -} - function toBuffer(data: string) { return Buffer.from(data, "base64") } diff --git a/src/types.ts b/src/types.ts index d1ee3b6..42f2183 100644 --- a/src/types.ts +++ b/src/types.ts @@ -15,32 +15,6 @@ export interface Profile { createdAt: integer // Unix seconds } -export interface EncryptedItem { - category: string // "001" - /** Unix seconds */ - created: integer - d: string // "b3BkYXRhMbt" - folder: string // 32 chars - hmac: string // base64 - k: string // base64 - o: string // base64 - tx: integer // Unix seconds - updated: integer // Unix seconds - uuid: string // 32 chars -} - -export interface AttachmentMetadata { - itemUUID: string - contentSize: integer - external: boolean - updatedAt: integer - txTimestamp: integer - /** Base64 encoded OPData */ - overview: string - createdAt: integer - uuid: string -} - export type TextField = { type: FieldType.Text value: string @@ -119,7 +93,7 @@ declare namespace ItemSection { } // One of them is empty?, 0C4F27910A64488BB339AED63565D148 -export interface Item { +export interface ItemDetails { htmlForm?: { htmlAction: string // "/login/" htmlMethod: "post" | "get" @@ -134,10 +108,6 @@ export interface Item { fields?: ItemField[] } -export interface Band { - [uuid: string]: EncryptedItem -} - export interface Folder { created: number // 1373754128 overview: string // "b3BkYXRhT/../KBM=" diff --git a/src/util.ts b/src/util.ts new file mode 100644 index 0000000..0466b1f --- /dev/null +++ b/src/util.ts @@ -0,0 +1,39 @@ +import { invariant } from "./errors" + +export function asyncMap( + list: T[], + fn: (value: T, index: number, list: T[]) => Promise +) { + return Promise.all(list.map(fn)) +} + +export function once any>(fn: T): T { + let result: ReturnType + let executed = false + const res = function (this: ThisParameterType) { + if (executed) { + return result + } else { + result = fn.apply(this, arguments as any as Parameters) + executed = true + return result + } + } + return res as any +} + +export const cache = (): MethodDecorator => (_, key, descriptor: any) => { + if (process.env.NODE_ENV !== "production") { + invariant(typeof key === "string") + invariant(descriptor.get != null) + } + + const cacheMap = new WeakMap() + const fn = descriptor.get + descriptor.get = function () { + if (!cacheMap.has(this)) { + cacheMap.set(this, fn.call(this)) + } + return cacheMap.get(this)! + } +} diff --git a/src/weakMap.ts b/src/weakMap.ts new file mode 100644 index 0000000..611d091 --- /dev/null +++ b/src/weakMap.ts @@ -0,0 +1,40 @@ +export class WeakValueMap { + #map = new Map>() + + #delete = (key: K) => { + const value = this.#map.get(key)! + this.#finalizers.unregister(value) + this.#map.delete(key) + return false + } + + #finalizers = new FinalizationRegistry((key: K) => { + this.#map.delete(key) + }) + + delete(key: K) { + return this.#map.has(key) && !this.#delete(key) + } + + has(key: K) { + const has = this.#map.has(key) + const value = has && !this.#map.get(key)!.deref() + return value ? this.#delete(key) : has + } + + get(key: K) { + return this.#map.get(key)?.deref() + } + + set(key: K, value: V) { + this.delete(key) + const ref = new WeakRef(value) + this.#finalizers.register(value, key, ref) + this.#map.set(key, ref) + return this + } + + clear(): void { + this.#map.clear() + } +} diff --git a/test/before.ts b/test/before.ts index cfc0184..3885d25 100644 --- a/test/before.ts +++ b/test/before.ts @@ -1,5 +1,8 @@ import chai from "chai"; import chaiAsPromised from "chai-as-promised"; +import sinonChai from "sinon-chai"; process.env.LOCALE = "en"; + chai.use(chaiAsPromised); +chai.use(sinonChai); diff --git a/test/profile.test.ts b/test/profile.test.ts index 6cca3f6..e47b051 100644 --- a/test/profile.test.ts +++ b/test/profile.test.ts @@ -5,6 +5,7 @@ import { expect } from "chai"; import type { Vault } from "../src/index"; import { OnePassword } from "../src/index"; +// import adapter from "../src/adapters/node"; describe("OnePassword", () => { const freddy = resolve(__dirname, "../freddy-2013-12-04.opvault"); diff --git a/test/weakMap.test.ts b/test/weakMap.test.ts new file mode 100644 index 0000000..de98d1c --- /dev/null +++ b/test/weakMap.test.ts @@ -0,0 +1,43 @@ +import { describe, it } from "mocha"; +import { expect } from "chai"; + +import { WeakValueMap } from "../src/weakMap"; + +declare const gc: () => void; + +describe("WeakValueMap", () => { + interface Value { + value: number; + } + + it("covers base use cases", () => { + const map = new WeakValueMap(); + const object = { value: 1 }; + map.set("key", object); + expect(map.get("key")!.value).to.equal(1); + expect(map.delete("key")).to.be.true; + expect(!map.delete("key")).to.be.true; + }); + + it("overrides previous value", () => { + const map = new WeakValueMap(); + map.set("key", { value: 2 }); + map.set("key", { value: 3 }); + expect(map.get("key")!.value).to.equal(3); + }); + + it("deletes garbage collected values", (done) => { + const map = new WeakValueMap(); + map.set("key", { value: 1 }); + setTimeout(() => { + gc(); + expect(map.has("key")).to.be.false; + map.set("key", { value: 2 }); + + setTimeout(() => { + gc(); + done(); + }); + }); + }); +});