diff --git a/packages/opvault.js/adapters/browser.ts b/packages/opvault.js/adapters/browser.ts new file mode 100644 index 0000000..e38f70f --- /dev/null +++ b/packages/opvault.js/adapters/browser.ts @@ -0,0 +1,124 @@ +import { Buffer } from "buffer" +import createHmac from "create-hmac" +import type { IAdapter, IFileSystem } from "./index" + +function normalize(path: string) { + return path.replace(/^\//, "") +} + +function splitPath(path: string) { + const segments = normalize(path).split("/") + const filename = segments.pop()! + return [segments, filename] as const +} + +export class FileSystem implements IFileSystem { + constructor(private handle: FileSystemDirectoryHandle) {} + + static async create() { + const handle = await showDirectoryPicker() + return new FileSystem(handle) + } + + private async getDirectoryHandle(segments: string[]) { + if (!segments.length || (segments.length === 1 && !segments[0])) { + return this.handle + } + const [first, ...pathSegments] = segments + const handle = await pathSegments.reduce( + async (accum, next) => (await accum).getDirectoryHandle(next), + this.handle.getDirectoryHandle(first) + ) + return handle + } + + private async getFileHandle(path: string) { + const [segments, filename] = splitPath(path) + const dirHandle = await this.getDirectoryHandle(segments) + const fileHandle = await dirHandle.getFileHandle(filename) + return fileHandle + } + + async readFile(path: string) { + const handle = await this.getFileHandle(path) + const file = await handle.getFile() + return file.text() + } + + async exists(path: string): Promise { + if (path === "/") { + return true + } + + const [segments, filename] = splitPath(path) + let { handle } = this + for (const segment of segments) { + try { + handle = await handle.getDirectoryHandle(segment) + } catch { + return false + } + } + + return ( + (await success(() => handle.getFileHandle(filename))) || + (await success(() => handle.getDirectoryHandle(filename))) + ) + } + + async readBuffer(path: string): Promise { + const handle = await this.getFileHandle(path) + const file = await handle.getFile() + return Buffer.from(await file.arrayBuffer()) + } + + async writeFile(path: string, data: string): Promise { + const handle = await this.getFileHandle(path) + const writable = await handle.createWritable() + await writable.write(data) + await writable.close() + } + + async readdir(path: string): Promise { + const segments = normalize(path).split("/") + const dirHandle = await this.getDirectoryHandle(segments) + const keys: string[] = [] + for await (const key of dirHandle.keys()) { + keys.push(key) + } + return keys + } + + async isDirectory(path: string) { + const [segments, filename] = splitPath(path) + const dirHandle = await this.getDirectoryHandle(segments) + for await (const [key, handle] of dirHandle.entries()) { + if (key !== filename) continue + + if (handle instanceof FileSystemDirectoryHandle) { + return true + } else { + return false + } + } + return false + } +} + +async function success(fn: () => Promise) { + try { + await fn() + return true + } catch { + return false + } +} + +/** + * Default Browser adapter. + */ +export const getBrowserAdapter = (handle: FileSystemDirectoryHandle): IAdapter => ({ + fs: new FileSystem(handle), + subtle: crypto.subtle, + hmacSHA256: (key, data) => createHmac("sha256", key).update(data).digest(), +}) diff --git a/packages/opvault.js/adapters/decipher.ts b/packages/opvault.js/adapters/decipher.ts new file mode 100644 index 0000000..422edd6 --- /dev/null +++ b/packages/opvault.js/adapters/decipher.ts @@ -0,0 +1,324 @@ +function bufferXor(a: Buffer, b: Buffer) { + const length = Math.min(a.length, b.length) + const buffer = Buffer.alloc(length) + + for (let i = 0; i < length; ++i) { + buffer[i] = a[i] ^ b[i] + } + + return buffer +} + +function asUInt32Array(buf: Buffer) { + const len = (buf.length / 4) | 0 + const out: number[] = new Array(len) + + for (let i = 0; i < len; i++) { + out[i] = buf.readUInt32BE(i * 4) + } + + return out +} + +function cryptBlock( + M: number[], + keySchedule: number[], + SUB_MIX: number[][], + SBOX: number[], + nRounds: number +) { + const SUB_MIX0 = SUB_MIX[0] + const SUB_MIX1 = SUB_MIX[1] + const SUB_MIX2 = SUB_MIX[2] + const SUB_MIX3 = SUB_MIX[3] + + let s0 = M[0] ^ keySchedule[0] + let s1 = M[1] ^ keySchedule[1] + let s2 = M[2] ^ keySchedule[2] + let s3 = M[3] ^ keySchedule[3] + let t0: number + let t1: number + let t2: number + let t3: number + let ksRow = 4 + + for (let round = 1; round < nRounds; round++) { + t0 = + SUB_MIX0[s0 >>> 24] ^ + SUB_MIX1[(s1 >>> 16) & 0xff] ^ + SUB_MIX2[(s2 >>> 8) & 0xff] ^ + SUB_MIX3[s3 & 0xff] ^ + keySchedule[ksRow++] + t1 = + SUB_MIX0[s1 >>> 24] ^ + SUB_MIX1[(s2 >>> 16) & 0xff] ^ + SUB_MIX2[(s3 >>> 8) & 0xff] ^ + SUB_MIX3[s0 & 0xff] ^ + keySchedule[ksRow++] + t2 = + SUB_MIX0[s2 >>> 24] ^ + SUB_MIX1[(s3 >>> 16) & 0xff] ^ + SUB_MIX2[(s0 >>> 8) & 0xff] ^ + SUB_MIX3[s1 & 0xff] ^ + keySchedule[ksRow++] + t3 = + SUB_MIX0[s3 >>> 24] ^ + SUB_MIX1[(s0 >>> 16) & 0xff] ^ + SUB_MIX2[(s1 >>> 8) & 0xff] ^ + SUB_MIX3[s2 & 0xff] ^ + keySchedule[ksRow++] + s0 = t0 + s1 = t1 + s2 = t2 + s3 = t3 + } + + t0 = + ((SBOX[s0 >>> 24] << 24) | + (SBOX[(s1 >>> 16) & 0xff] << 16) | + (SBOX[(s2 >>> 8) & 0xff] << 8) | + SBOX[s3 & 0xff]) ^ + keySchedule[ksRow++] + t1 = + ((SBOX[s1 >>> 24] << 24) | + (SBOX[(s2 >>> 16) & 0xff] << 16) | + (SBOX[(s3 >>> 8) & 0xff] << 8) | + SBOX[s0 & 0xff]) ^ + keySchedule[ksRow++] + t2 = + ((SBOX[s2 >>> 24] << 24) | + (SBOX[(s3 >>> 16) & 0xff] << 16) | + (SBOX[(s0 >>> 8) & 0xff] << 8) | + SBOX[s1 & 0xff]) ^ + keySchedule[ksRow++] + t3 = + ((SBOX[s3 >>> 24] << 24) | + (SBOX[(s0 >>> 16) & 0xff] << 16) | + (SBOX[(s1 >>> 8) & 0xff] << 8) | + SBOX[s2 & 0xff]) ^ + keySchedule[ksRow++] + t0 = t0 >>> 0 + t1 = t1 >>> 0 + t2 = t2 >>> 0 + t3 = t3 >>> 0 + + return [t0, t1, t2, t3] +} + +// AES constants +const RCON = [0x00, 0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80, 0x1b, 0x36] +const G = (function () { + // Compute double table + const d = new Array(256) + for (let j = 0; j < 256; j++) { + if (j < 128) { + d[j] = j << 1 + } else { + d[j] = (j << 1) ^ 0x11b + } + } + + const SBOX: number[] = [] + const INV_SBOX: number[] = [] + const SUB_MIX: number[][] = [[], [], [], []] + const INV_SUB_MIX: number[][] = [[], [], [], []] + + // Walk GF(2^8) + let x = 0 + let xi = 0 + for (let i = 0; i < 256; ++i) { + // Compute sbox + let sx = xi ^ (xi << 1) ^ (xi << 2) ^ (xi << 3) ^ (xi << 4) + sx = (sx >>> 8) ^ (sx & 0xff) ^ 0x63 + SBOX[x] = sx + INV_SBOX[sx] = x + + // Compute multiplication + const x2 = d[x] + const x4 = d[x2] + const x8 = d[x4] + + // Compute sub bytes, mix columns tables + let t = (d[sx] * 0x101) ^ (sx * 0x1010100) + SUB_MIX[0][x] = (t << 24) | (t >>> 8) + SUB_MIX[1][x] = (t << 16) | (t >>> 16) + SUB_MIX[2][x] = (t << 8) | (t >>> 24) + SUB_MIX[3][x] = t + + // Compute inv sub bytes, inv mix columns tables + t = (x8 * 0x1010101) ^ (x4 * 0x10001) ^ (x2 * 0x101) ^ (x * 0x1010100) + INV_SUB_MIX[0][sx] = (t << 24) | (t >>> 8) + INV_SUB_MIX[1][sx] = (t << 16) | (t >>> 16) + INV_SUB_MIX[2][sx] = (t << 8) | (t >>> 24) + INV_SUB_MIX[3][sx] = t + + if (x === 0) { + x = xi = 1 + } else { + x = x2 ^ d[d[d[x8 ^ x2]]] + xi ^= d[d[xi]] + } + } + + return { + SBOX, + INV_SBOX, + SUB_MIX, + INV_SUB_MIX, + } +})() + +class AES { + private _key: number[] + private _nRounds!: number + private _invKeySchedule!: number[] + + constructor(key: Buffer) { + this._key = asUInt32Array(key) + this._reset() + } + + _reset() { + const keyWords = this._key + const keySize = keyWords.length + const nRounds = keySize + 6 + const ksRows = (nRounds + 1) * 4 + + const keySchedule: number[] = [] + for (let k = 0; k < keySize; k++) { + keySchedule[k] = keyWords[k] + } + + for (let k = keySize; k < ksRows; k++) { + let t = keySchedule[k - 1] + + if (k % keySize === 0) { + t = (t << 8) | (t >>> 24) + t = + (G.SBOX[t >>> 24] << 24) | + (G.SBOX[(t >>> 16) & 0xff] << 16) | + (G.SBOX[(t >>> 8) & 0xff] << 8) | + G.SBOX[t & 0xff] + + t ^= RCON[(k / keySize) | 0] << 24 + } else if (keySize > 6 && k % keySize === 4) { + t = + (G.SBOX[t >>> 24] << 24) | + (G.SBOX[(t >>> 16) & 0xff] << 16) | + (G.SBOX[(t >>> 8) & 0xff] << 8) | + G.SBOX[t & 0xff] + } + + keySchedule[k] = keySchedule[k - keySize] ^ t + } + + const invKeySchedule = [] + for (let ik = 0; ik < ksRows; ik++) { + const ksR = ksRows - ik + const tt = keySchedule[ksR - (ik % 4 ? 0 : 4)] + + if (ik < 4 || ksR <= 4) { + invKeySchedule[ik] = tt + } else { + invKeySchedule[ik] = + G.INV_SUB_MIX[0][G.SBOX[tt >>> 24]] ^ + G.INV_SUB_MIX[1][G.SBOX[(tt >>> 16) & 0xff]] ^ + G.INV_SUB_MIX[2][G.SBOX[(tt >>> 8) & 0xff]] ^ + G.INV_SUB_MIX[3][G.SBOX[tt & 0xff]] + } + } + + this._nRounds = nRounds + this._invKeySchedule = invKeySchedule + } + + decryptBlock(buffer: Buffer) { + const M = asUInt32Array(buffer) + + // swap + const m1 = M[1] + M[1] = M[3] + M[3] = m1 + + const out = cryptBlock( + M, + this._invKeySchedule, + G.INV_SUB_MIX, + G.INV_SBOX, + this._nRounds + ) + + const buf = Buffer.allocUnsafe(16) + buf.writeUInt32BE(out[0], 0) + buf.writeUInt32BE(out[3], 4) + buf.writeUInt32BE(out[2], 8) + buf.writeUInt32BE(out[1], 12) + return buf + } + + static blockSize = 4 * 4 + static keySize = 256 / 8 +} + +class Decipher { + private _cipher: AES + private _prev: Buffer + private _cache = new Splitter() + + constructor(key: Buffer, iv: Buffer) { + this._cipher = new AES(key) + this._prev = Buffer.from(iv) + } + + update(data: Buffer) { + this._cache.add(data) + let chunk: Buffer | null + let thing: Buffer + const out: Buffer[] = [] + while ((chunk = this._cache.get())) { + thing = this.cbc_decrypt(chunk) + out.push(thing) + } + return Buffer.concat(out) + } + + setAutoPadding(setTo: boolean) { + return this + } + + cbc_decrypt(block: Buffer) { + const pad = this._prev + + this._prev = block + const out = this._cipher.decryptBlock(block) + + return bufferXor(out, pad) + } +} + +class Splitter { + cache = Buffer.allocUnsafe(0) + + add(data: Buffer) { + this.cache = Buffer.concat([this.cache, data]) + } + + get() { + if (this.cache.length >= 16) { + const out = this.cache.slice(0, 16) + this.cache = this.cache.slice(16) + return out + } + return null + } +} + +export function createDecipheriv(_suite: any, password: Buffer, iv: Buffer) { + if (iv.length !== 16) { + throw new TypeError(`invalid iv length ${iv.length}`) + } + if (password.length !== 256 / 8) { + throw new TypeError(`invalid key length ${password.length}`) + } + return new Decipher(password, iv) +} diff --git a/src/adapters/index.d.ts b/packages/opvault.js/adapters/index.d.ts similarity index 64% rename from src/adapters/index.d.ts rename to packages/opvault.js/adapters/index.d.ts index 90f2a4d..eb5eb14 100644 --- a/src/adapters/index.d.ts +++ b/packages/opvault.js/adapters/index.d.ts @@ -3,10 +3,10 @@ */ export interface IFileSystem { /** - * Synchronously tests whether or not the given path exists by checking with the file system. + * Asynchronously 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 + exists(path: string): Promise /** * Asynchronously reads the entire contents of a file. @@ -34,10 +34,9 @@ export interface IFileSystem { readdir(path: string): Promise /** - * Asynchronous stat(2) - Get file status. - * @param path A path to a file. + * Returns true if the path points to a directory. */ - stat(path: string): Promise<{ isDirectory(): boolean }> + isDirectory(path: string): Promise } export interface IAdapter { @@ -46,4 +45,16 @@ export interface IAdapter { * `memfs` or any object that implements `IFileSystem`. */ fs: IFileSystem + + /** + * `SubtleCrypto` implementation. On Node.js this is + * `require("crypto").webcrypto.subtle` and in the browser this is + * `window.crypto.subtle`. + */ + subtle: SubtleCrypto + + /** + * Equivalent to `createHmac("sha256", key).update(data).digest()` + */ + hmacSHA256(key: Buffer, data: Buffer): Buffer } diff --git a/src/adapters/node.ts b/packages/opvault.js/adapters/node.ts similarity index 53% rename from src/adapters/node.ts rename to packages/opvault.js/adapters/node.ts index 618a386..428afdd 100644 --- a/src/adapters/node.ts +++ b/packages/opvault.js/adapters/node.ts @@ -1,4 +1,5 @@ import { promises as fs, existsSync } from "fs" +import { webcrypto, createHmac } from "crypto" import type { IAdapter } from "./index" @@ -6,15 +7,15 @@ 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 = { +export const adapter: IAdapter = { fs: { readFile: path => fs.readFile(path, "utf-8"), readBuffer: path => fs.readFile(path), writeFile: fs.writeFile, readdir: fs.readdir, - stat: fs.stat, - existsSync, + isDirectory: async path => fs.stat(path).then(x => x.isDirectory()), + exists: async path => existsSync(path), }, + subtle: (webcrypto as any).subtle, + hmacSHA256: (key, data) => createHmac("sha256", key).update(data).digest(), } - -export default nodeAdapter diff --git a/packages/opvault.js/ee.ts b/packages/opvault.js/ee.ts new file mode 100644 index 0000000..c5a8cba --- /dev/null +++ b/packages/opvault.js/ee.ts @@ -0,0 +1,22 @@ +export function createEventEmitter() { + type EventListener = T extends void ? () => void : (value: T) => void + type Emitter = T extends void + ? { (): void; (listener: EventListener): IDisposable } + : { (value: T): void; (listener: EventListener): IDisposable } + + const listeners = new Set() + + function emitter(value: T | EventListener) { + if (typeof value === "function") { + listeners.add(value as EventListener) + return { + dispose() { + listeners.delete(value as EventListener) + }, + } + } else { + listeners.forEach(fn => fn(value)) + } + } + return emitter as Emitter +} diff --git a/src/errors.ts b/packages/opvault.js/errors.ts similarity index 100% rename from src/errors.ts rename to packages/opvault.js/errors.ts diff --git a/src/fs.ts b/packages/opvault.js/fs.ts similarity index 87% rename from src/fs.ts rename to packages/opvault.js/fs.ts index ef9288b..7e7320d 100644 --- a/src/fs.ts +++ b/packages/opvault.js/fs.ts @@ -5,14 +5,14 @@ import { once } from "./util" export type OnePasswordFileManager = ReturnType -export function OnePasswordFileManager( +export async 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.`) + invariant(await fs.exists(path), `Path ${path} does not exist.`) + invariant(await fs.exists(root), `Profile ${profileName} does not exist.`) const abs = (path: string) => resolve(root, path) @@ -46,7 +46,7 @@ export function OnePasswordFileManager( async getBand(name: string) { const path = abs(`band_${name}.js`) - if (fs.existsSync(path)) { + if (await fs.exists(path)) { return await fs.readFile(path) } }, diff --git a/src/global.d.ts b/packages/opvault.js/global.d.ts similarity index 100% rename from src/global.d.ts rename to packages/opvault.js/global.d.ts diff --git a/src/i18n/index.ts b/packages/opvault.js/i18n/index.ts similarity index 100% rename from src/i18n/index.ts rename to packages/opvault.js/i18n/index.ts diff --git a/src/i18n/res.json b/packages/opvault.js/i18n/res.json similarity index 100% rename from src/i18n/res.json rename to packages/opvault.js/i18n/res.json diff --git a/src/index.ts b/packages/opvault.js/index.ts similarity index 80% rename from src/index.ts rename to packages/opvault.js/index.ts index c376d58..2b2dbf4 100644 --- a/src/index.ts +++ b/packages/opvault.js/index.ts @@ -1,6 +1,5 @@ import { resolve } from "path" import { Vault } from "./models/vault" -import { invariant } from "./errors" import type { IAdapter } from "./adapters" import { asyncMap } from "./util" @@ -26,10 +25,9 @@ export class OnePassword { readonly #path: string readonly #adapter: IAdapter - constructor({ path, adapter = require("./adapters/node").default }: IOptions) { + constructor({ path, adapter = require("./adapters/node").adapter }: IOptions) { this.#adapter = adapter this.#path = path - invariant(path, "Path must not be empty") } /** @@ -41,8 +39,10 @@ export class OnePassword { const profiles: string[] = [] 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"))) { + if ( + (await fs.isDirectory(fullPath)) && + (await fs.exists(resolve(fullPath, "profile.js"))) + ) { profiles.push(child) } }) diff --git a/src/models.ts b/packages/opvault.js/models.ts similarity index 100% rename from src/models.ts rename to packages/opvault.js/models.ts diff --git a/src/models/attachment.ts b/packages/opvault.js/models/attachment.ts similarity index 83% rename from src/models/attachment.ts rename to packages/opvault.js/models/attachment.ts index 21478de..4e5916c 100644 --- a/src/models/attachment.ts +++ b/packages/opvault.js/models/attachment.ts @@ -1,5 +1,5 @@ +import { Buffer } from "buffer" import type { Crypto } from "./crypto" -import { decryptOPData } from "./crypto" import { invariant } from "../errors" type integer = number @@ -38,7 +38,7 @@ export class Attachment { this.metadataSize = buffer.readIntLE(8, 2) this.iconSize = buffer.readIntLE(12, 3) - crypto.on("lock", () => { + crypto.onLock(() => { this.#lock() }) } @@ -80,18 +80,21 @@ export class Attachment { } #decrypt() { - const cipher = this.#crypto.deriveConcreteKey({ k: this.#k }) + const crypto = this.#crypto + const cipher = crypto.deriveConcreteKey({ k: this.#k }) const { metadataSize, iconSize } = this const buffer = this.#buffer - this.#icon = decryptOPData( + this.#icon = crypto.decryptOPData( buffer.slice(16 + metadataSize, 16 + metadataSize + iconSize), cipher ) - this.#file = decryptOPData(buffer.slice(16 + metadataSize + iconSize), cipher) + this.#file = crypto.decryptOPData(buffer.slice(16 + metadataSize + iconSize), cipher) const metadata = JSON.parse(buffer.slice(16, 16 + metadataSize).toString("utf-8")) metadata.overview = JSON.parse( - decryptOPData(Buffer.from(metadata.overview, "base64"), this.#crypto.ov).toString() + crypto + .decryptOPData(Buffer.from(metadata.overview, "base64"), crypto.overview) + .toString() ) this.#metadata = metadata } diff --git a/packages/opvault.js/models/crypto.ts b/packages/opvault.js/models/crypto.ts new file mode 100644 index 0000000..da7cede --- /dev/null +++ b/packages/opvault.js/models/crypto.ts @@ -0,0 +1,173 @@ +import { Buffer } from "buffer" +import { createDecipheriv } from "../adapters/decipher" +import type { IAdapter } from "../adapters" +import { createEventEmitter } from "../ee" +import { HMACAssertionError } from "../errors" +import type { i18n } from "../i18n" +import type { ItemDetails, Overview, Profile } from "../types" +import { setIfAbsent } from "../util" +import type { EncryptedItem } from "./item" + +/** Encryption and MAC */ +export interface Cipher { + /** Encryption key */ + key: Buffer + /** HMAC key */ + hmac: Buffer +} + +export class Crypto implements IDisposable { + #disposables: IDisposable[] = [] + #locked = true + + #master!: Cipher + #overview!: Cipher + + private subtle: SubtleCrypto + private hmacSHA256: IAdapter["hmacSHA256"] + + readonly onLock = createEventEmitter() + + constructor(private readonly i18n: i18n, adapter: IAdapter) { + this.subtle = adapter.subtle + this.hmacSHA256 = adapter.hmacSHA256 + } + + async unlock(profile: Profile, masterPassword: string) { + const encoder = new TextEncoder() + const key = await this.subtle.importKey( + "raw", + encoder.encode(masterPassword), + { name: "PBKDF2" }, + false, + ["deriveBits"] + ) + const derivedKey = await this.subtle.deriveBits( + { + name: "PBKDF2", + salt: Buffer.from(profile.salt, "base64"), + iterations: profile.iterations, + hash: { + name: "SHA-512", + }, + }, + key, + 64 << 3 + ) + + const cipher = splitPlainText(Buffer.from(derivedKey)) + + // Derive master key and overview keys + this.#master = await this.decryptKeys(profile.masterKey, cipher) + this.#overview = await this.decryptKeys(profile.overviewKey, cipher) + this.#locked = false + } + + get locked() { + return this.#locked + } + + lock() { + this.#locked = true + this.#master = null! + this.#overview = null! + this.#disposables.forEach(fn => fn.dispose()) + this.onLock() + } + + dispose() { + this.lock() + } + + assertUnlocked() { + if (this.#locked) { + throw new Error(this.i18n.error.vaultIsLocked) + } + } + + #createCache = ( + deriveArg: (value: K) => K2, + implementation: (value: K2) => V + ) => { + const map = new Map() + this.#disposables.push({ + dispose: () => map.clear(), + }) + return (data: K) => setIfAbsent(map, deriveArg(data), implementation) + } + + #createWeakCache = (implementation: (value: K) => V) => { + let map = new WeakMap() + this.#disposables.push({ + dispose() { + map = new WeakMap() + }, + }) + return (data: K) => setIfAbsent(map, data, implementation) + } + + decryptItemDetails = this.#createWeakCache((item: EncryptedItem) => { + const cipher = this.deriveConcreteKey(item) + const detail = this.decryptOPData(Buffer.from(item.d, "base64"), cipher) + return JSON.parse(detail.toString("utf-8")) as ItemDetails + }) + + decryptItemOverview = this.#createCache( + (item: EncryptedItem) => item.o, + (o: string) => { + const overview = this.decryptOPData(Buffer.from(o, "base64"), this.#overview) + return JSON.parse(overview.toString("utf8")) as Overview + } + ) + + deriveConcreteKey = this.#createCache( + (data: { k: string }) => data.k, + ($k: string) => { + const k = Buffer.from($k, "base64") + const data = k.slice(0, -32) + this.assertHMac(data, this.#master.hmac, k.slice(-32)) + const derivedKey = decryptData(this.#master.key, data.slice(0, 16), data.slice(16)) + return splitPlainText(derivedKey) + } + ) + + assertHMac(data: Buffer, key: Buffer, expected: Buffer) { + const actual = this.hmacSHA256(key, data) + if (!actual.equals(expected)) { + throw new HMACAssertionError() + } + } + + decryptOPData(cipherText: Buffer, cipher: Cipher) { + const key = cipherText.slice(0, -32) + this.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) + } + + async decryptKeys(encryptedKey: string, derived: Cipher) { + const buffer = Buffer.from(encryptedKey, "base64") + const base = this.decryptOPData(buffer, derived) + const digest = await this.subtle.digest("SHA-512", base) + return splitPlainText(Buffer.from(digest)) + } + + get overview() { + return this.#overview + } +} + +export const splitPlainText = (derivedKey: Buffer): Cipher => ({ + key: derivedKey.slice(0, 32), + hmac: derivedKey.slice(32, 64), +}) + +function decryptData(key: Buffer, iv: Buffer, data: Buffer) { + return createDecipheriv("aes-256-cbc", key, iv).setAutoPadding(false).update(data) +} + +function readUint16({ buffer, byteOffset, length }: Buffer) { + return new DataView(buffer, byteOffset, length).getUint16(0, true) +} diff --git a/src/models/item.ts b/packages/opvault.js/models/item.ts similarity index 100% rename from src/models/item.ts rename to packages/opvault.js/models/item.ts diff --git a/src/models/vault.ts b/packages/opvault.js/models/vault.ts similarity index 69% rename from src/models/vault.ts rename to packages/opvault.js/models/vault.ts index 0e6dac0..92d3ae0 100644 --- a/src/models/vault.ts +++ b/packages/opvault.js/models/vault.ts @@ -5,41 +5,33 @@ import { i18n } from "../i18n" import type { EncryptedItem } from "./item" import { Crypto } from "./crypto" import { Item } from "./item" -import type { Profile, Overview } from "../types" +import type { Profile } from "../types" import { WeakValueMap } from "../weakMap" -import { EventEmitter } from "../ee" -import { asyncMap } from "../util" +import { createEventEmitter } from "../ee" -type Band = { [uuid: string]: Item } -type FoldersMap = { [uuid: string]: Band } - -interface VaultEvents { - lock: void -} +// type FoldersMap = { [uuid: string]: { [uuid: string]: Item } } /** * Main OnePassword Vault class */ -export class Vault extends EventEmitter { - // File system interface +export class Vault { #profile: Profile - #folders: FoldersMap - + // #folders: FoldersMap #items: Item[] = [] #itemsMap = new WeakValueMap() - #crypto: Crypto + readonly onLock = createEventEmitter() + private constructor( profile: Profile, - folders: FoldersMap, + // folders: FoldersMap, items: Item[], crypto: Crypto, itemsMap: WeakValueMap ) { - super() this.#profile = profile - this.#folders = folders + // this.#folders = folders this.#items = items this.#crypto = crypto this.#itemsMap = itemsMap @@ -50,12 +42,12 @@ export class Vault extends EventEmitter { * @internal */ static async of(path: string, profileName = "default", adapter: IAdapter) { - const crypto = new Crypto(i18n) - const files = OnePasswordFileManager(adapter.fs, path, profileName) + const crypto = new Crypto(i18n, adapter) + const files = await 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 folders = JSON.parse(stripText(await files.getFolders(), "loadFolders(", ");")) const itemsMap = new WeakValueMap() const bands: Item[] = [] @@ -75,34 +67,23 @@ export class Vault extends EventEmitter { } const attachments = await files.getAttachments() - await asyncMap(attachments, async att => { + for (const att of attachments) { const file = itemsMap.get(att.itemUUID) invariant(file, `Item ${att.itemUUID} of attachment does not exist`) file.addAttachment(await att.getFile()) - }) + } - return new Vault(profile, folders, bands, crypto, itemsMap) + return new Vault(profile, bands, crypto, itemsMap) } - readonly overviews = Object.freeze({ - get: (condition: string | ((overview: Overview) => boolean)) => { - this.#crypto.assertUnlocked() - if (typeof condition === "string") { - const title = condition - condition = overview => overview.title === title - } - for (const value of this.#items) { - if (condition(value.overview)) { - return value - } - } - }, + getOverview(uuid: string) { + this.#crypto.assertUnlocked() + return this.#items.find(x => x.uuid === uuid)?.overview + } - values: () => { - this.#crypto.assertUnlocked() - return Array.from(this.#items.map(x => x.overview)) - }, - }) + keys() { + return this.#items.map(x => x.uuid) + } /** * Unlock this OnePassword vault. @@ -111,7 +92,7 @@ export class Vault extends EventEmitter { */ async unlock(masterPassword: string) { try { - this.#crypto.unlock(this.#profile, masterPassword) + await this.#crypto.unlock(this.#profile, masterPassword) } catch (e) { if (e instanceof HMACAssertionError) { throw new Error(i18n.error.invalidPassword) @@ -126,7 +107,7 @@ export class Vault extends EventEmitter { */ lock() { this.#crypto.lock() - this.emit("lock") + this.onLock() return this } @@ -134,9 +115,20 @@ export class Vault extends EventEmitter { return this.#crypto.locked } - getItem(uuid: string) { + getItem(uuid: string): Item | undefined + getItem(filter: { title: string }): Item | undefined + + getItem(filter: any) { this.#crypto.assertUnlocked() - return this.#itemsMap.get(uuid) + if (typeof filter === "string") { + return this.#itemsMap.get(filter) + } else { + for (const value of this.#items) { + if (value.overview.title === filter.title) { + return value + } + } + } } } diff --git a/src/types.ts b/packages/opvault.js/types.ts similarity index 99% rename from src/types.ts rename to packages/opvault.js/types.ts index 632e4a3..00700d1 100644 --- a/src/types.ts +++ b/packages/opvault.js/types.ts @@ -151,7 +151,6 @@ export interface Overview { url?: string tags?: string[] URLs?: { u: string }[] - uuid?: string // Added manually appIds: { /** @example 'Firefox' | 'Email' */ name: string diff --git a/src/util.ts b/packages/opvault.js/util.ts similarity index 100% rename from src/util.ts rename to packages/opvault.js/util.ts diff --git a/src/weakMap.ts b/packages/opvault.js/weakMap.ts similarity index 100% rename from src/weakMap.ts rename to packages/opvault.js/weakMap.ts diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..b18b4d0 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +packages: + - "packages/**/" diff --git a/src/adapters/browser.ts b/src/adapters/browser.ts deleted file mode 100644 index 61a04cf..0000000 --- a/src/adapters/browser.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { promises as fs, existsSync } from "fs" -import { Buffer } from "buffer" - -import type { IAdapter, IFileSystem } from "./index" - -function splitPath(path: string) { - const segments = path.split("/") - const filename = segments.pop()! - return [segments, filename] as const -} - -export class BrowserAdapter implements IFileSystem { - constructor(private handle: FileSystemDirectoryHandle) {} - - static async create() { - const handle = await showDirectoryPicker() - return new BrowserAdapter(handle) - } - - private async getDirectoryHandle([first, ...pathSegments]: string[]) { - const handle = await pathSegments.reduce( - async (accum, next) => (await accum).getDirectoryHandle(next), - this.handle.getDirectoryHandle(first) - ) - return handle - } - - private async getFileHandle(path: string) { - const [segments, filename] = splitPath(path) - const dirHandle = await this.getDirectoryHandle(segments) - const fileHandle = await dirHandle.getFileHandle(filename) - return fileHandle - } - - async readFile(path: string) { - const handle = await this.getFileHandle(path) - const file = await handle.getFile() - return file.text() - } - - async existsSync(path: string): Promise { - const [segments, filename] = splitPath(path) - let handle = this.handle - for (const segment of segments) { - handle.values() - const next = await handle.getDirectoryHandle() - } - - throw new Error("Method not implemented.") - } - - async readBuffer(path: string): Promise { - const handle = await this.getFileHandle(path) - const file = await handle.getFile() - return Buffer.from(await file.arrayBuffer()) - } - - async writeFile(path: string, data: string): Promise { - const handle = await this.getFileHandle(path) - const writable = await handle.createWritable() - await writable.write(data) - await writable.close() - } - - readdir(path: string): Promise { - throw new Error("Method not implemented.") - } - - stat(path: string): Promise<{ isDirectory(): boolean }> { - throw new Error("Method not implemented.") - } -} - -/** - * 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"), - readBuffer: path => fs.readFile(path), - writeFile: fs.writeFile, - readdir: fs.readdir, - stat: fs.stat, - existsSync, - }, -} - -export default nodeAdapter diff --git a/src/ee.ts b/src/ee.ts deleted file mode 100644 index 95ac4e6..0000000 --- a/src/ee.ts +++ /dev/null @@ -1,49 +0,0 @@ -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/models/crypto.ts b/src/models/crypto.ts deleted file mode 100644 index 71d9121..0000000 --- a/src/models/crypto.ts +++ /dev/null @@ -1,179 +0,0 @@ -import * as crypto from "crypto" -import { createHmac, createDecipheriv, createHash } from "crypto" -import { EventEmitter } from "../ee" -import { HMACAssertionError } from "../errors" -import type { i18n } from "../i18n" -import type { ItemDetails, Overview, Profile } from "../types" -import { setIfAbsent } from "../util" -import type { EncryptedItem } from "./item" - -/** Encryption and MAC */ -export interface Cipher { - /** Encryption key */ - key: Buffer - /** HMAC key */ - hmac: Buffer -} - -export class Crypto extends EventEmitter<{ lock: void }> implements IDisposable { - #disposables: IDisposable[] = [] - #locked = true - - #master!: Cipher - #overview!: Cipher - - constructor(readonly i18n: i18n) { - super() - } - - unlock(profile: Profile, masterPassword: string) { - const derivedKey = crypto.pbkdf2Sync( - masterPassword, - Buffer.from(profile.salt, "base64"), - profile.iterations, - 64, - "sha512" - ) - - const cipher = splitPlainText(derivedKey) - - // Derive master key and overview keys - this.#master = decryptKeys(profile.masterKey, cipher) - this.#overview = decryptKeys(profile.overviewKey, cipher) - this.#locked = false - } - - get locked() { - return this.#locked - } - - lock() { - this.#locked = true - this.#master = null! - this.#overview = null! - this.#disposables.forEach(fn => fn.dispose()) - this.emit("lock") - } - - dispose() { - this.lock() - } - - assertUnlocked() { - if (this.#locked) { - throw new Error(this.i18n.error.vaultIsLocked) - } - } - - #createCache = ( - deriveArg: (value: K) => K2, - implementation: (value: K2) => V - ) => { - const map = new Map() - this.#disposables.push({ - dispose: () => map.clear(), - }) - return (data: K) => setIfAbsent(map, deriveArg(data), implementation) - } - - #createWeakCache = (implementation: (value: K) => V) => { - let map = new WeakMap() - this.#disposables.push({ - dispose() { - map = new WeakMap() - }, - }) - return (data: K) => setIfAbsent(map, data, implementation) - } - - decryptItemDetails = this.#createWeakCache((item: EncryptedItem) => { - const cipher = this.deriveConcreteKey(item) - const detail = decryptOPData(Buffer.from(item.d, "base64"), cipher) - return JSON.parse(detail.toString("utf-8")) as ItemDetails - }) - - decryptItemOverview = this.#createCache( - (item: EncryptedItem) => item.o, - (o: string) => { - const overview = decryptOPData(Buffer.from(o, "base64"), this.#overview) - return JSON.parse(overview.toString("utf8")) as Overview - } - ) - - deriveConcreteKey = this.#createCache( - (data: { k: string }) => data.k, - ($k: string) => { - const k = Buffer.from($k, "base64") - const data = k.slice(0, -32) - assertHMac(data, this.#master.hmac, k.slice(-32)) - const derivedKey = decryptData(this.#master.key, data.slice(0, 16), data.slice(16)) - return splitPlainText(derivedKey) - } - ) - - deriveGeneralKey = (input: Buffer) => {} - - get ov() { - return this.#overview - } -} - -// async function pbkdf2(password: string, salt: string, iterations = 1000, length = 256) { -// const encoder = new TextEncoder() -// const key = await webcrypto.subtle.importKey( -// "raw", -// encoder.encode(password), -// "PBKDF2", -// false, -// ["deriveBits"] -// ) -// const bits = await webcrypto.subtle.deriveBits( -// { -// name: "PBKDF2", -// hash: "SHA-512", -// salt: encoder.encode(salt), -// iterations, -// }, -// key, -// length -// ) -// return bits -// } - -export function assertHMac(data: Buffer, key: Buffer, expected: Buffer) { - const actual = createHmac("sha256", key).update(data).digest() - if (!actual.equals(expected)) { - console.error({ actual, 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) -}