From 7c12f499f2e9698555715eebfb29ad90178bb6b9 Mon Sep 17 00:00:00 2001 From: aet Date: Tue, 20 Jul 2021 01:40:03 -0400 Subject: [PATCH] Update --- repl.ts | 21 +++++----- src/adapters/index.d.ts | 6 +++ src/adapters/node.ts | 1 + src/crypto.ts | 73 -------------------------------- src/fs.ts | 13 +++--- src/models/attachment.ts | 90 ++++++++++++++++++++++++---------------- src/models/crypto.ts | 12 ++++++ src/models/item.ts | 7 +--- src/models/vault.ts | 32 ++++++++------ src/types.ts | 41 +++++++++++------- src/util.ts | 2 +- 11 files changed, 142 insertions(+), 156 deletions(-) delete mode 100644 src/crypto.ts diff --git a/repl.ts b/repl.ts index c5bd63f..062e9ff 100755 --- a/repl.ts +++ b/repl.ts @@ -1,10 +1,10 @@ -#!/usr/bin/env node -r ts-node/register/transpile-only +#!/usr/bin/env -S 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" +const path = "./freddy-2013-12-04.opvault" async function main(args: string[]) { const instance = new OnePassword({ path }) @@ -31,16 +31,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 item = vault.getItem(find.uuid!)! + console.log(item.attachments[0].metadata) - const d = vault.decryptAttachment( - item!.original, - fs.readFileSync( - "./freddy/default/F2DB5DA3FCA64372A751E0E85C67A538_AFBDA49A5F684179A78161E40CA2AAD3.attachment" - ) - ) + // const d = vault.decryptAttachment( + // item!.original, + // fs.readFileSync( + // "./freddy/default/F2DB5DA3FCA64372A751E0E85C67A538_AFBDA49A5F684179A78161E40CA2AAD3.attachment" + // ) + // ) - fs.writeFileSync("./test2.png", d.file) + // fs.writeFileSync("./test2.png", d.file) // console.log(vault.overviews.values()) } diff --git a/src/adapters/index.d.ts b/src/adapters/index.d.ts index c8a5686..90f2a4d 100644 --- a/src/adapters/index.d.ts +++ b/src/adapters/index.d.ts @@ -8,6 +8,12 @@ export interface IFileSystem { */ existsSync(path: string): boolean + /** + * Asynchronously reads the entire contents of a file. + * @param path A path to a file. + */ + readBuffer(path: string): Promise + /** * Asynchronously reads the entire contents of a file. * @param path A path to a file. diff --git a/src/adapters/node.ts b/src/adapters/node.ts index a9e691c..618a386 100644 --- a/src/adapters/node.ts +++ b/src/adapters/node.ts @@ -9,6 +9,7 @@ import type { IAdapter } from "./index" 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, diff --git a/src/crypto.ts b/src/crypto.ts deleted file mode 100644 index d1d2eef..0000000 --- a/src/crypto.ts +++ /dev/null @@ -1,73 +0,0 @@ -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( - "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)) { - 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/fs.ts b/src/fs.ts index a812bf7..ef9288b 100644 --- a/src/fs.ts +++ b/src/fs.ts @@ -1,4 +1,4 @@ -import { resolve, extname } from "path" +import { resolve, extname, basename } from "path" import invariant from "tiny-invariant" import type { IFileSystem } from "./adapters" import { once } from "./util" @@ -27,16 +27,19 @@ export function OnePasswordFileManager( async getAttachments() { const files = await fs.readdir(root) - files + return files .filter(name => extname(name) === ".attachment") - .forEach(name => { + .map(name => { const sep = name.indexOf("_") const path = resolve(root, name) - const [itemUUID, fileUUID] = [name.slice(0, sep), name.slice(sep + 1)] + const [itemUUID, fileUUID] = [ + name.slice(0, sep), + basename(name.slice(sep + 1), extname(name)), + ] return { itemUUID, fileUUID, - getFile: once(() => fs.readFile(path)), + getFile: once(() => fs.readBuffer(path)), } }) }, diff --git a/src/models/attachment.ts b/src/models/attachment.ts index c90fae1..340f698 100644 --- a/src/models/attachment.ts +++ b/src/models/attachment.ts @@ -1,9 +1,5 @@ -import type { Cipher } from "../crypto" -import { decryptOPData } from "../crypto" +import { Crypto, decryptOPData } from "./crypto" import { invariant } from "../errors" -import { cache } from "../util" -import { Crypto } from "./crypto" -import type { Item } from "./item" type integer = number @@ -14,7 +10,7 @@ export interface AttachmentMetadata { updatedAt: integer txTimestamp: integer /** Base64 encoded OPData */ - overview: string + overview: Buffer createdAt: integer uuid: string } @@ -22,23 +18,33 @@ export interface AttachmentMetadata { export class Attachment { #k: string #crypto: Crypto + #buffer: Buffer + + #icon?: Buffer + #file?: Buffer + #metadata?: AttachmentMetadata private metadataSize: number private iconSize: number - constructor(crypto: Crypto, k: string, private buffer: Buffer) { + constructor(crypto: Crypto, k: string, buffer: Buffer) { + this.#buffer = buffer this.#validate() this.#crypto = crypto this.#k = k this.metadataSize = buffer.readIntLE(8, 2) this.iconSize = buffer.readIntLE(12, 3) + + crypto.on("lock", () => { + this.#lock() + }) } /** * Validate attachment file. */ #validate() { - const file = this.buffer + const file = this.#buffer invariant( file.slice(0, 6).toString("utf-8") === "OPCLDA", "Attachment must start with OPCLDA" @@ -49,39 +55,53 @@ export class Attachment { ) } - @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) + if (this.#icon == null) { + this.#decrypt() + } + return this.#icon! } - /** - * Decrypt the content of this attachment. This function - * shall be called by an `Item` where `cipher` is `deriveConcreteKey(item, master)`. - * @internal - */ - decrypt() { - const cipher = this.#deriveConcreteKey(master) - 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), + get file() { + if (this.#file == null) { + this.#decrypt() } + return this.#file! + } + + get metadata() { + if (this.#metadata == null) { + this.#decrypt() + } + return this.#metadata! + } + + #decrypt() { + const cipher = this.#crypto.deriveConcreteKey({ k: this.#k }) + const { metadataSize, iconSize } = this + const buffer = this.#buffer + this.#icon = decryptOPData( + buffer.slice(16 + metadataSize, 16 + metadataSize + iconSize), + cipher + ) + this.#file = decryptOPData( + buffer.slice(16 + metadataSize + iconSize), + this.#crypto.ov + ) + + const metadata = JSON.parse(buffer.slice(16, 16 + metadataSize).toString("utf-8")) + metadata.overview = decryptOPData(Buffer.from(metadata.overview), cipher) + this.#metadata = metadata + } + + #lock() { + this.#metadata = undefined! + this.#icon = undefined! + this.#file = undefined! } dispose() { - this.buffer = null! + this.#buffer = null! + this.#lock() } } diff --git a/src/models/crypto.ts b/src/models/crypto.ts index cbda5bc..e699062 100644 --- a/src/models/crypto.ts +++ b/src/models/crypto.ts @@ -7,6 +7,10 @@ import { ItemDetails, Overview, Profile } from "../types" import { setIfAbsent } from "../util" import { EncryptedItem } from "./item" +declare module "crypto" { + export const webcrypto: Crypto +} + /** Encryption and MAC */ export interface Cipher { /** Encryption key */ @@ -110,6 +114,14 @@ export class Crypto extends EventEmitter<{ lock: void }> implements IDisposable return splitPlainText(derivedKey) } ) + + deriveGeneralKey = (input: Buffer) => { + crypto.createHash("sha512").update() + } + + get ov() { + return this.#overview + } } // async function pbkdf2(password: string, salt: string, iterations = 1000, length = 256) { diff --git a/src/models/item.ts b/src/models/item.ts index e731143..3ce132b 100644 --- a/src/models/item.ts +++ b/src/models/item.ts @@ -1,7 +1,5 @@ -import { assertHMac, decryptData, splitPlainText } from "../crypto" import type { ItemDetails, Overview } from "../types" import type { Crypto } from "./crypto" -import type { Cipher } from "../crypto" import { Attachment } from "./attachment" export interface EncryptedItem { @@ -39,10 +37,7 @@ export class Item { this.#data = data } - dispose() { - this.attachments.forEach(a => a.dispose()) - } - + /** @internal */ addAttachment(buffer: Buffer) { this.attachments.push(new Attachment(this.#crypto, this.#data.k, buffer)) } diff --git a/src/models/vault.ts b/src/models/vault.ts index 07524e1..49abcc8 100644 --- a/src/models/vault.ts +++ b/src/models/vault.ts @@ -8,6 +8,7 @@ import { Item } from "./item" import type { Profile, Overview } from "../types" import { WeakValueMap } from "../weakMap" import { EventEmitter } from "../ee" +import { asyncMap } from "../util" type Band = { [uuid: string]: Item } type FoldersMap = { [uuid: string]: Band } @@ -33,17 +34,15 @@ export class Vault extends EventEmitter { profile: Profile, folders: FoldersMap, items: Item[], - crypto: Crypto + crypto: Crypto, + itemsMap: WeakValueMap ) { super() this.#profile = profile this.#folders = folders this.#items = items this.#crypto = crypto - - items.forEach(item => { - this.#itemsMap.set(item.uuid, item) - }) + this.#itemsMap = itemsMap } /** @@ -57,19 +56,32 @@ export class Vault extends EventEmitter { stripText(await files.getProfile(), /^var profile\s*=/, ";") ) const folders = JSON.parse(stripText(await files.getFolders(), "loadFolders(", ");")) + + const itemsMap = new WeakValueMap() const bands: Item[] = [] for (let i = 0; i < 36; i++) { const letter = i.toString(36).toUpperCase() const source = await files.getBand(letter) if (!source) continue - const object = JSON.parse(stripText(source, "ld(", ");")) + const object: Record = JSON.parse( + stripText(source, "ld(", ");") + ) for (const value of Object.values(object)) { - bands.push(new Item(crypto, value as EncryptedItem)) + const item = new Item(crypto, value) + bands.push(item) + itemsMap.set(value.uuid, item) } } - return new Vault(profile, folders, bands, crypto) + const attachments = await files.getAttachments() + await asyncMap(attachments, async att => { + 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) } readonly overviews = Object.freeze({ @@ -151,7 +163,3 @@ function stripText(text: string, prefix: string | RegExp, suffix: string | RegEx } return text } - -function toBuffer(data: string) { - return Buffer.from(data, "base64") -} diff --git a/src/types.ts b/src/types.ts index 42f2183..632e4a3 100644 --- a/src/types.ts +++ b/src/types.ts @@ -7,12 +7,15 @@ export interface Profile { /** Unix seconds */ updatedAt: integer profileName: string - salt: string // base64 - masterKey: string // base64 + /** base64 */ + salt: string + /** base64 */ + masterKey: string iterations: integer // 50000 uuid: string // 32 chars overviewKey: string // "b3B...IMO52D" - createdAt: integer // Unix seconds + /** Unix seconds */ + createdAt: integer } export type TextField = { @@ -62,9 +65,11 @@ declare namespace ItemSection { type Date = { k: "date" v: number // 359100000 - n: string // "birthdate" + /** @example "birthdate" */ + n: string a: A - t: string // "birth date" | "date of birth" + /** @example "birth date" | "date of birth" */ + t: string } type Gender = { k: "gender" @@ -95,13 +100,16 @@ declare namespace ItemSection { // One of them is empty?, 0C4F27910A64488BB339AED63565D148 export interface ItemDetails { htmlForm?: { - htmlAction: string // "/login/" + /** @example "/login/" */ + htmlAction: string htmlMethod: "post" | "get" } notesPlain?: string sections: { - name: string // "name" | "title" | "internet" - title: string // "Identification" | "Address", "Internet Details" + /** @example "name" | "title" | "internet" */ + name: string + /** @example "Identification" | "Address" | "Internet Details" */ + title: string fields?: ItemSection.Any[] }[] /** Web form fields */ @@ -110,10 +118,11 @@ export interface ItemDetails { export interface Folder { created: number // 1373754128 - overview: string // "b3BkYXRhT/../KBM=" + /** base64 */ + overview: string smart?: true tx: number // 1373754523 - /** Updated time, UNIX seconds */ + /** Updated time in UNIX seconds */ updated: number uuid: string // "AC78552EB06A4F65BEBF58B4D9E32080" } @@ -122,7 +131,8 @@ export interface Overview { /** * Password strength. * - * `ps is the value for manually entered passwords and is used to display the relative strength value.` + * `ps` is the value for manually entered passwords and is used to display + * the relative strength value. */ ps: number @@ -143,8 +153,11 @@ export interface Overview { URLs?: { u: string }[] uuid?: string // Added manually appIds: { - name: string // 'Firefox' | 'Email' - type: string // 'accessibility://login' - id: string // 'android://...' + /** @example 'Firefox' | 'Email' */ + name: string + /** @example 'accessibility://login' */ + type: string + /** @example 'android://...' */ + id: string }[] } diff --git a/src/util.ts b/src/util.ts index 4809336..ba354c2 100644 --- a/src/util.ts +++ b/src/util.ts @@ -36,7 +36,7 @@ export function once any>(fn: T): T { return res as any } -export const cache = (): MethodDecorator => (_, key, descriptor: any) => { +const cache = (): MethodDecorator => (_, key, descriptor: any) => { if (process.env.NODE_ENV !== "production") { invariant(typeof key === "string") invariant(descriptor.get != null)