Refactor codebase

This commit is contained in:
proteriax 2021-07-08 02:05:41 -04:00
parent 490d289330
commit 0230f32aab
19 changed files with 1010 additions and 119 deletions

View File

@ -1,2 +1,2 @@
onepassword_data *.opvault
lib lib

131
.eslintrc
View File

@ -1,7 +1,7 @@
{ {
"root": true, "root": true,
"parser": "@typescript-eslint/parser", "parser": "@typescript-eslint/parser",
"plugins": ["@typescript-eslint"], "plugins": ["@typescript-eslint", "import"],
"env": { "env": {
"node": true, "node": true,
"browser": true "browser": true
@ -9,11 +9,57 @@
"extends": [ "extends": [
"eslint:recommended", "eslint:recommended",
"plugin:@typescript-eslint/recommended", "plugin:@typescript-eslint/recommended",
"plugin:react/recommended" "plugin:react/recommended",
"plugin:import/errors",
"plugin:import/typescript",
"plugin:react-hooks/recommended",
"prettier"
], ],
"settings": {
"react": {
"version": "detect"
},
"import/parsers": {
"@typescript-eslint/parser": [".ts", ".tsx"]
},
"import/resolver": {
"typescript": {
"alwaysTryTypes": true
}
}
},
"rules": { "rules": {
"@typescript-eslint/ban-ts-comment": "off", "@typescript-eslint/ban-ts-comment": "off",
"@typescript-eslint/ban-types": "off", "@typescript-eslint/consistent-type-imports": [
"error",
{
"disallowTypeAnnotations": false
}
],
"@typescript-eslint/ban-types": [
"error",
{
"extendDefaults": false,
"types": {
"String": {
"message": "Use string instead",
"fixWith": "string"
},
"Number": {
"message": "Use number instead",
"fixWith": "number"
},
"Boolean": {
"message": "Use boolean instead",
"fixWith": "boolean"
},
"Symbol": {
"message": "Use symbol instead",
"fixWith": "symbol"
}
}
}
],
"@typescript-eslint/explicit-function-return-type": "off", "@typescript-eslint/explicit-function-return-type": "off",
"@typescript-eslint/explicit-module-boundary-types": "off", "@typescript-eslint/explicit-module-boundary-types": "off",
"@typescript-eslint/no-empty-function": "off", "@typescript-eslint/no-empty-function": "off",
@ -23,22 +69,70 @@
"@typescript-eslint/no-use-before-define": "off", "@typescript-eslint/no-use-before-define": "off",
"@typescript-eslint/no-var-requires": "off", "@typescript-eslint/no-var-requires": "off",
"@typescript-eslint/triple-slash-reference": "off", "@typescript-eslint/triple-slash-reference": "off",
"@typescript-eslint/no-empty-interface": "off",
"arrow-body-style": ["error", "as-needed"], "arrow-body-style": ["error", "as-needed"],
"class-methods-use-this": ["warn", { "exceptMethods": ["toString"] }], "class-methods-use-this": [
"complexity": ["warn", { "max": 100 }], "warn",
{
"exceptMethods": ["toString", "shouldComponentUpdate"]
}
],
"complexity": [
"warn",
{
"max": 100
}
],
"curly": ["error", "multi-line", "consistent"], "curly": ["error", "multi-line", "consistent"],
"eqeqeq": ["error", "smart"], "eqeqeq": ["error", "smart"],
"no-async-promise-executor": "off", "no-async-promise-executor": "off",
"no-case-declarations": "off", "no-case-declarations": "off",
"no-constant-condition": ["error", { "checkLoops": false }], "no-constant-condition": [
"no-empty": ["error", { "allowEmptyCatch": true }], "error",
{
"checkLoops": false
}
],
"no-debugger": "off",
"no-empty": [
"error",
{
"allowEmptyCatch": true
}
],
"no-inner-declarations": "off", "no-inner-declarations": "off",
"no-lonely-if": "error", "no-lonely-if": "error",
"no-template-curly-in-string": "error",
"no-var": "error", "no-var": "error",
"object-shorthand": "error", "import/export": "off",
"one-var": ["error", { "var": "never", "let": "never" }], "import/order": [
"error",
{
"groups": ["builtin", "external"]
}
],
"object-shorthand": [
"error",
"always",
{
"ignoreConstructors": true
}
],
"one-var": [
"error",
{
"var": "never",
"let": "never"
}
],
"prefer-arrow-callback": "error", "prefer-arrow-callback": "error",
"prefer-const": ["error", { "destructuring": "all" }], "prefer-const": [
"error",
{
"destructuring": "all"
}
],
"prefer-destructuring": "warn",
"prefer-object-spread": "error", "prefer-object-spread": "error",
"prefer-rest-params": "warn", "prefer-rest-params": "warn",
"prefer-spread": "warn", "prefer-spread": "warn",
@ -46,7 +140,20 @@
"react/display-name": "off", "react/display-name": "off",
"react/no-children-prop": "off", "react/no-children-prop": "off",
"react/prop-types": "off", "react/prop-types": "off",
"spaced-comment": "error", "react/react-in-jsx-scope": "off",
"yoda": ["error", "never", { "exceptRange": true }] "spaced-comment": [
"error",
"always",
{
"markers": ["/"]
}
],
"yoda": [
"error",
"never",
{
"exceptRange": true
}
]
} }
} }

1
.gitignore vendored
View File

@ -1,4 +1,5 @@
node_modules node_modules
mochawesome-report
lib lib
docs docs
ref ref

7
.mocharc.json Normal file
View File

@ -0,0 +1,7 @@
{
"$schema": "https://json.schemastore.org/mocharc",
"reporter": "mochawesome",
"extension": [".ts"],
"require": ["ts-node/register", "tsconfig-paths/register", "./test/before.ts"],
"timeout": 10000
}

25
.vscode/launch.json vendored Normal file
View File

@ -0,0 +1,25 @@
{
// Utilisez IntelliSense pour en savoir plus sur les attributs possibles.
// Pointez pour afficher la description des attributs existants.
// Pour plus d'informations, visitez : https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "pwa-node",
"request": "launch",
"name": "REPL",
"skipFiles": ["<node_internals>/**"],
"program": "repl.ts"
},
{
"type": "node",
"request": "launch",
"name": "Mocha",
"program": "${workspaceFolder}/node_modules/mocha/bin/_mocha",
"args": ["test/**/*.test.ts"],
"console": "integratedTerminal",
"internalConsoleOptions": "neverOpen",
"protocol": "inspector"
}
]
}

11
.vscode/settings.json vendored
View File

@ -1,3 +1,12 @@
{ {
"editor.formatOnSave": true "editor.formatOnSave": true,
"cSpell.ignorePaths": [
"**/package-lock.json",
"**/node_modules/**",
"**/vscode-extension/**",
"**/.git/objects/**",
".vscode",
".vscode-insiders",
"i18n.json"
]
} }

View File

@ -1,5 +1,13 @@
# opvault.js # opvault.js
## Testing
```sh
wget -qO- https://cache.agilebits.com/security-kb/freddy-2013-12-04.tar.gz | tar xvz -
mv onepassword_data freddy-2013-12-04.opvault
pnpm run test
```
## Security ## Security
### Reporting Security Issues ### Reporting Security Issues

View File

@ -7,27 +7,38 @@
"scripts": { "scripts": {
"build": "rollup -c; prettier --write lib >/dev/null", "build": "rollup -c; prettier --write lib >/dev/null",
"build:docs": "typedoc --out docs src/index.ts --excludePrivate", "build:docs": "typedoc --out docs src/index.ts --excludePrivate",
"test": "mocha test/**/*.test.ts",
"repl": "node -r ts-node/register/transpile-only src/repl.ts" "repl": "node -r ts-node/register/transpile-only src/repl.ts"
}, },
"devDependencies": { "devDependencies": {
"@rollup/plugin-json": "^4.1.0",
"@types/chai": "^4.2.19", "@types/chai": "^4.2.19",
"@types/chai-as-promised": "^7.1.4",
"@types/fs-extra": "^9.0.11", "@types/fs-extra": "^9.0.11",
"@types/mocha": "github:whitecolor/mocha-types#da22474cf43f48a56c86f8c23a5a0ea36e295768", "@types/mocha": "github:whitecolor/mocha-types#da22474cf43f48a56c86f8c23a5a0ea36e295768",
"@types/node": "^16.0.0", "@types/node": "^16.0.0",
"@types/prompts": "^2.0.13", "@types/prompts": "^2.0.13",
"@typescript-eslint/eslint-plugin": "4.28.1", "@typescript-eslint/eslint-plugin": "4.28.2",
"@typescript-eslint/parser": "4.28.1", "@typescript-eslint/parser": "4.28.2",
"chai": "^4.3.4", "chai": "^4.3.4",
"chai-as-promised": "^7.1.1",
"chalk": "^4.1.1", "chalk": "^4.1.1",
"eslint": "7.30.0", "eslint": "7.30.0",
"eslint-config-prettier": "8.3.0",
"eslint-import-resolver-typescript": "2.4.0",
"eslint-plugin-import": "2.23.4",
"eslint-plugin-react": "7.24.0", "eslint-plugin-react": "7.24.0",
"eslint-plugin-react-hooks": "4.2.0",
"fs-extra": "^10.0.0", "fs-extra": "^10.0.0",
"memfs": "^3.2.2",
"mocha": "^9.0.2", "mocha": "^9.0.2",
"mochawesome": "^6.2.2",
"prettier": "^2.3.2", "prettier": "^2.3.2",
"prompts": "^2.4.1", "prompts": "^2.4.1",
"rollup": "^2.52.7", "rollup": "^2.52.7",
"rollup-plugin-ts": "^1.4.0", "rollup-plugin-ts": "^1.4.0",
"ts-node": "^10.0.0", "ts-node": "^10.0.0",
"tsconfig-paths": "^3.9.0",
"typedoc": "^0.21.2", "typedoc": "^0.21.2",
"typescript": "^4.3.5" "typescript": "^4.3.5"
}, },

731
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -24,6 +24,7 @@ async function main(args: string[]) {
}) })
vault.unlock(password) vault.unlock(password)
console.log(vault.overviews.values())
} }
main(process.argv.slice(2)) main(process.argv.slice(2))

View File

@ -1,5 +1,6 @@
import ts from "rollup-plugin-ts"
import { builtinModules } from "module" import { builtinModules } from "module"
import ts from "rollup-plugin-ts"
import json from "@rollup/plugin-json"
import { dependencies } from "./package.json" import { dependencies } from "./package.json"
/** @returns {import("rollup").RollupOptions} */ /** @returns {import("rollup").RollupOptions} */
@ -10,5 +11,5 @@ export default () => ({
file: "lib/index.js", file: "lib/index.js",
format: "cjs", format: "cjs",
}, },
plugins: [ts()], plugins: [ts(), json()],
}) })

26
src/i18n/index.ts Normal file
View File

@ -0,0 +1,26 @@
import json from "./res.json"
const locale =
process.env.LOCALE || Intl.DateTimeFormat().resolvedOptions().locale.split("-")[0]
const mapValue = <T, R>(
object: Record<string, T>,
fn: (value: T, key: string) => R
): Record<string, R> => {
const res = Object.create(null)
Object.entries(object).forEach(([key, value]) => {
res[key] = fn(value, key)
})
return res
}
type json = typeof json
type i18n = {
[K in keyof json]: {
[L in keyof json[K]]: string
}
}
export const i18n: i18n = mapValue(json, dict =>
mapValue(dict, (value: any) => value[locale] ?? value.en)
) as any

16
src/i18n/res.json Normal file
View File

@ -0,0 +1,16 @@
{
"error": {
"invalidPassword": {
"en": "Invalid password",
"fr": "Mot de passe invalide"
},
"vaultIsLocked": {
"en": "This vault is locked",
"fr": "Ce coffre est verrouillé."
},
"cannotDecryptOverviewItem": {
"en": "Failed to decrypt overview item",
"fr": "Impossible de déchiffrer cet aperçu"
}
}
}

View File

@ -1,5 +1,6 @@
import { resolve } from "path" import { resolve } from "path"
import { getDefaultFileSystem, IFileSystem } from "./fs" import type { IFileSystem } from "./fs"
import { getDefaultFileSystem } from "./fs"
import { Vault } from "./vault" import { Vault } from "./vault"
export type { Vault } from "./vault" export type { Vault } from "./vault"

View File

@ -17,7 +17,7 @@ export enum Category {
Rewards = 107, Rewards = 107,
SSN = 108, SSN = 108,
Router = 109, Router = 109,
Server = 100, Server = 110,
Email = 111, Email = 111,
} }

View File

@ -1,5 +1,7 @@
import * as crypto from "crypto" import * as crypto from "crypto"
import { IFileSystem, OnePasswordFileManager } from "./fs" import type { IFileSystem } from "./fs"
import { OnePasswordFileManager } from "./fs"
import { i18n } from "./i18n"
import type { Profile, Band, Overview, EncryptedItem, Item } from "./types" import type { Profile, Band, Overview, EncryptedItem, Item } from "./types"
@ -10,8 +12,8 @@ type FoldersMap = { [uuid: string]: Band }
*/ */
export class Vault { export class Vault {
// Ciphers // Ciphers
#master: Cipher #master?: Cipher
#overview: Cipher #overview?: Cipher
// File system interface // File system interface
#files: OnePasswordFileManager #files: OnePasswordFileManager
@ -35,10 +37,6 @@ export class Vault {
this.#profile = profile this.#profile = profile
this.#folders = folders this.#folders = folders
this.#bands = bands this.#bands = bands
this.#files
this.#folders
this.#items
} }
/** /**
@ -59,6 +57,24 @@ export class Vault {
return new Vault(files, profile, folders, bands) return new Vault(files, profile, folders, bands)
} }
readonly overviews = Object.freeze({
get: (condition: string | ((overview: Overview) => boolean)) => {
this.#assertUnlocked()
if (typeof condition === "string") {
const title = condition
condition = overview => overview.title === title
}
for (const value of this.#overviews.values()) {
if (condition(value)) {
return value
}
}
},
values: () => Array.from(this.#overviews.values()),
})
/** /**
* Unlock this OnePassword vault. * Unlock this OnePassword vault.
* @param masterPassword User provided master password. Only the derived * @param masterPassword User provided master password. Only the derived
@ -82,10 +98,13 @@ export class Vault {
this.#overview = decryptKeys(profile.overviewKey, cipher) this.#overview = decryptKeys(profile.overviewKey, cipher)
} catch (e) { } catch (e) {
if (e instanceof HMACAssertionError) { if (e instanceof HMACAssertionError) {
throw new Error("Invalid password.") throw new Error(i18n.error.invalidPassword)
} }
throw e throw e
} }
this.#readOverviews()
return this
} }
/** /**
@ -95,10 +114,17 @@ export class Vault {
this.#master = null! this.#master = null!
this.#overview = null! this.#overview = null!
this.#overviews.clear() this.#overviews.clear()
return this
} }
get isLocked() { get isLocked() {
return Boolean(this.#master.key) return this.#master?.key == null
}
#assertUnlocked() {
if (this.isLocked) {
throw new Error(i18n.error.vaultIsLocked)
}
} }
getOverview(title: string): Overview getOverview(title: string): Overview
@ -117,36 +143,38 @@ export class Vault {
} }
getItem(uuid: string) { getItem(uuid: string) {
this.#assertUnlocked()
const encrypted = uuid ? this.#bands.get(uuid[0])![uuid] : undefined const encrypted = uuid ? this.#bands.get(uuid[0])![uuid] : undefined
return encrypted && this.decryptItem(encrypted) return encrypted && decryptItem(encrypted, this.#master!)
} }
readItems() { #readOverviews() {
this.#assertUnlocked()
this.#bands.forEach(value => { this.#bands.forEach(value => {
for (const [uuid, item] of Object.entries(value)) { for (const [uuid, item] of Object.entries(value)) {
const overview = this.decryptOverview(item) const overview = decryptOverview(item, this.#overview!)
overview.uuid = uuid overview.uuid = uuid
this.#overviews.set(uuid, overview) this.#overviews.set(uuid, overview)
} }
}) })
return this }
} }
private decryptOverview(item: EncryptedItem) { function decryptOverview(item: EncryptedItem, overviewCipher: Cipher) {
try { try {
const overview = decryptOPData(toBuffer(item.o), this.#overview) const overview = decryptOPData(toBuffer(item.o), overviewCipher)
return JSON.parse(overview.toString("utf8")) as Overview return JSON.parse(overview.toString("utf8")) as Overview
} catch (e) { } catch (e) {
console.error("Failed to decrypt overview item.") console.error(i18n.error.cannotDecryptOverviewItem)
throw e throw e
} }
} }
private decryptItem(item: EncryptedItem): Item { function decryptItem(item: EncryptedItem, master: Cipher): Item {
const k = toBuffer(item.k) const k = toBuffer(item.k)
const data = k.slice(0, -32) const data = k.slice(0, -32)
assertHMac(data, this.#master.hmac, k.slice(-32)) assertHMac(data, master.hmac, k.slice(-32))
const derivedKey = decryptData(this.#master.key, data.slice(0, 16), data.slice(16)) const derivedKey = decryptData(master.key, data.slice(0, 16), data.slice(16))
const detail = decryptOPData( const detail = decryptOPData(
/* cipherText */ toBuffer(item.d), /* cipherText */ toBuffer(item.d),
@ -154,7 +182,6 @@ export class Vault {
) )
return JSON.parse(detail.toString("utf-8")) return JSON.parse(detail.toString("utf-8"))
} }
}
/** Encryption and MAC */ /** Encryption and MAC */
interface Cipher { interface Cipher {
@ -189,7 +216,7 @@ function decryptOPData(cipherText: Buffer, cipher: Cipher) {
function assertHMac(data: Buffer, key: Buffer, expected: Buffer) { function assertHMac(data: Buffer, key: Buffer, expected: Buffer) {
const actual = crypto.createHmac("sha256", key).update(data).digest() const actual = crypto.createHmac("sha256", key).update(data).digest()
if (!actual.equals(expected)) { if (!actual.equals(expected)) {
throw new HMACAssertionError("HMAC assertion failed.") throw new HMACAssertionError()
} }
} }

3
test/.prettierrc Normal file
View File

@ -0,0 +1,3 @@
{
"semi": true
}

5
test/before.ts Normal file
View File

@ -0,0 +1,5 @@
import chai from "chai";
import chaiAsPromised from "chai-as-promised";
process.env.LOCALE = "en";
chai.use(chaiAsPromised);

42
test/profile.test.ts Normal file
View File

@ -0,0 +1,42 @@
import { resolve } from "path";
import { describe, it, beforeEach } from "mocha";
import { expect } from "chai";
// import { fs, vol } from "memfs"
import type { Vault } from "../src/index";
import { OnePassword } from "../src/index";
describe("OnePassword", () => {
const freddy = resolve(__dirname, "../freddy-2013-12-04.opvault");
describe("getProfileNames", () => {
it("freddy", async () => {
const instance = new OnePassword({ path: freddy });
expect(await instance.getProfileNames()).to.deep.equal(["default"]);
});
it.skip("ignores faulty folders", async () => {});
});
describe("unlock", () => {
let vault: Vault;
beforeEach(async () => {
vault = await new OnePassword({ path: freddy }).getProfile("default");
});
it("accepts correct password", () => {
expect(() => vault.unlock("freddy")).to.not.throw();
expect(vault.isLocked).to.be.false;
});
it("rejects wrong password", () => {
["Freddy", "_freddy", ""].forEach((password) => {
expect(() => vault.unlock(password)).to.throw("Invalid password");
expect(vault.isLocked).to.be.true;
});
});
});
describe("lock", () => {});
});