This commit is contained in:
proteriax
2021-07-18 16:12:04 -04:00
parent 99fa963fc0
commit 98cc916432
24 changed files with 726 additions and 269 deletions

1
.gitignore vendored
View File

@ -4,3 +4,4 @@ lib
docs
ref
*.opvault
freddy

12
.vscode/launch.json vendored
View File

@ -5,11 +5,15 @@
"version": "0.2.0",
"configurations": [
{
"type": "pwa-node",
"request": "launch",
"name": "REPL",
"skipFiles": ["<node_internals>/**"],
"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": ["<node_internals>/**"]
},
{
"type": "node",

View File

@ -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",

110
pnpm-lock.yaml generated
View File

@ -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

14
repl.ts
View File

@ -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())
}

View File

@ -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"',
},
}),
],
})

0
src/adapters/browser.ts Normal file
View File

43
src/adapters/index.d.ts vendored Normal file
View File

@ -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<string>
/**
* 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<void>
/**
* Asynchronous readdir(3) - read a directory.
* @param path A path to a directory.
*/
readdir(path: string): Promise<string[]>
/**
* 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
}

19
src/adapters/node.ts Normal file
View File

@ -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

View File

@ -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)
}

49
src/ee.ts Normal file
View File

@ -0,0 +1,49 @@
type EventKeyWithNoArg<T> = {
[K in keyof T]: T[K] extends void ? K : never
}[keyof T]
type CallbackSignature<T, K extends keyof T> = K extends EventKeyWithNoArg<T>
? () => void
: (value: T[K]) => void
export class EventEmitter<T extends Record</* eventName */ string, /* eventArg */ any>> {
#listeners = new Map<keyof T, Set<(...args: any[]) => any>>()
#getList(key: keyof T) {
if (!this.#listeners.has(key)) {
this.#listeners.set(key, new Set())
}
return this.#listeners.get(key)!
}
on<K extends keyof T>(key: K, fn: CallbackSignature<T, K>) {
this.#getList(key).add(fn)
return this
}
off<K extends keyof T>(key: K, fn: CallbackSignature<T, K>) {
this.#getList(key).delete(fn)
return this
}
once<K extends keyof T>(key: K, fn: CallbackSignature<T, K>) {
const wrapped = (...arg: any[]) => {
;(fn as any)(...arg)
this.off(key, wrapped)
}
return this.on(key, wrapped)
}
emit<K extends EventKeyWithNoArg<T>>(key: K): this
emit<K extends keyof T>(key: K, value: T[K]): this
emit<K extends keyof T>(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
}
}

144
src/fs.ts
View File

@ -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<typeof OnePasswordFileManager>
/**
* Asynchronously reads the entire contents of a file.
* @param path A path to a file.
*/
readFile(path: string, encoding: BufferEncoding): Promise<string>
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<void>
const abs = (path: string) => resolve(root, path)
/**
* Asynchronous readdir(3) - read a directory.
* @param path A path to a directory.
*/
readdir(path: string): Promise<string[]>
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<Stats>
}
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
}

5
src/global.d.ts vendored Normal file
View File

@ -0,0 +1,5 @@
type integer = number
interface Disposable {
dispose(): void
}

View File

@ -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)
}
}

83
src/models/attachment.ts Normal file
View File

@ -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<Item>
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!
}
}

1
src/models/crypto.ts Normal file
View File

@ -0,0 +1 @@
export class Crypto {}

67
src/models/item.ts Normal file
View File

@ -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))
}
}

View File

@ -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<VaultEvents> {
// 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<string, Band>()
// Decrypted contents with plain-texts
#overviews = new Map<string, Overview>()
#items = new Map<string, EncryptedItem>()
#items: Item[] = []
#itemsMap = new WeakValueMap<string, Item>()
private constructor(
files: OnePasswordFileManager,
profile: Profile,
folders: FoldersMap,
bands: Map<string, Band>
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<string, Band>()
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")
}

View File

@ -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="

39
src/util.ts Normal file
View File

@ -0,0 +1,39 @@
import { invariant } from "./errors"
export function asyncMap<T, R>(
list: T[],
fn: (value: T, index: number, list: T[]) => Promise<R>
) {
return Promise.all(list.map(fn))
}
export function once<T extends (...args: any[]) => any>(fn: T): T {
let result: ReturnType<T>
let executed = false
const res = function (this: ThisParameterType<T>) {
if (executed) {
return result
} else {
result = fn.apply(this, arguments as any as Parameters<T>)
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)!
}
}

40
src/weakMap.ts Normal file
View File

@ -0,0 +1,40 @@
export class WeakValueMap<K, V extends object> {
#map = new Map<K, WeakRef<V>>()
#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()
}
}

View File

@ -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);

View File

@ -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");

43
test/weakMap.test.ts Normal file
View File

@ -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<string, Value>();
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<string, Value>();
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<string, Value>();
map.set("key", { value: 1 });
setTimeout(() => {
gc();
expect(map.has("key")).to.be.false;
map.set("key", { value: 2 });
setTimeout(() => {
gc();
done();
});
});
});
});