This commit is contained in:
aet 2021-07-20 01:40:03 -04:00
parent 0151dae1aa
commit 7c12f499f2
11 changed files with 142 additions and 156 deletions

21
repl.ts
View File

@ -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 fs from "fs"
import chalk from "chalk" import chalk from "chalk"
import prompts from "prompts" import prompts from "prompts"
import { OnePassword } from "./src/index" import { OnePassword } from "./src/index"
const path = "./freddy" const path = "./freddy-2013-12-04.opvault"
async function main(args: string[]) { async function main(args: string[]) {
const instance = new OnePassword({ path }) const instance = new OnePassword({ path })
@ -31,16 +31,17 @@ async function main(args: string[]) {
vault.unlock(password) vault.unlock(password)
const find = vault.overviews.get("A note with some attachments")! 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( // const d = vault.decryptAttachment(
item!.original, // item!.original,
fs.readFileSync( // fs.readFileSync(
"./freddy/default/F2DB5DA3FCA64372A751E0E85C67A538_AFBDA49A5F684179A78161E40CA2AAD3.attachment" // "./freddy/default/F2DB5DA3FCA64372A751E0E85C67A538_AFBDA49A5F684179A78161E40CA2AAD3.attachment"
) // )
) // )
fs.writeFileSync("./test2.png", d.file) // fs.writeFileSync("./test2.png", d.file)
// console.log(vault.overviews.values()) // console.log(vault.overviews.values())
} }

View File

@ -8,6 +8,12 @@ export interface IFileSystem {
*/ */
existsSync(path: string): boolean existsSync(path: string): boolean
/**
* Asynchronously reads the entire contents of a file.
* @param path A path to a file.
*/
readBuffer(path: string): Promise<Buffer>
/** /**
* Asynchronously reads the entire contents of a file. * Asynchronously reads the entire contents of a file.
* @param path A path to a file. * @param path A path to a file.

View File

@ -9,6 +9,7 @@ import type { IAdapter } from "./index"
const nodeAdapter: IAdapter = { const nodeAdapter: IAdapter = {
fs: { fs: {
readFile: path => fs.readFile(path, "utf-8"), readFile: path => fs.readFile(path, "utf-8"),
readBuffer: path => fs.readFile(path),
writeFile: fs.writeFile, writeFile: fs.writeFile,
readdir: fs.readdir, readdir: fs.readdir,
stat: fs.stat, stat: fs.stat,

View File

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

View File

@ -1,4 +1,4 @@
import { resolve, extname } from "path" import { resolve, extname, basename } from "path"
import invariant from "tiny-invariant" import invariant from "tiny-invariant"
import type { IFileSystem } from "./adapters" import type { IFileSystem } from "./adapters"
import { once } from "./util" import { once } from "./util"
@ -27,16 +27,19 @@ export function OnePasswordFileManager(
async getAttachments() { async getAttachments() {
const files = await fs.readdir(root) const files = await fs.readdir(root)
files return files
.filter(name => extname(name) === ".attachment") .filter(name => extname(name) === ".attachment")
.forEach(name => { .map(name => {
const sep = name.indexOf("_") const sep = name.indexOf("_")
const path = resolve(root, name) 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 { return {
itemUUID, itemUUID,
fileUUID, fileUUID,
getFile: once(() => fs.readFile(path)), getFile: once(() => fs.readBuffer(path)),
} }
}) })
}, },

View File

@ -1,9 +1,5 @@
import type { Cipher } from "../crypto" import { Crypto, decryptOPData } from "./crypto"
import { decryptOPData } from "../crypto"
import { invariant } from "../errors" import { invariant } from "../errors"
import { cache } from "../util"
import { Crypto } from "./crypto"
import type { Item } from "./item"
type integer = number type integer = number
@ -14,7 +10,7 @@ export interface AttachmentMetadata {
updatedAt: integer updatedAt: integer
txTimestamp: integer txTimestamp: integer
/** Base64 encoded OPData */ /** Base64 encoded OPData */
overview: string overview: Buffer
createdAt: integer createdAt: integer
uuid: string uuid: string
} }
@ -22,23 +18,33 @@ export interface AttachmentMetadata {
export class Attachment { export class Attachment {
#k: string #k: string
#crypto: Crypto #crypto: Crypto
#buffer: Buffer
#icon?: Buffer
#file?: Buffer
#metadata?: AttachmentMetadata
private metadataSize: number private metadataSize: number
private iconSize: 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.#validate()
this.#crypto = crypto this.#crypto = crypto
this.#k = k this.#k = k
this.metadataSize = buffer.readIntLE(8, 2) this.metadataSize = buffer.readIntLE(8, 2)
this.iconSize = buffer.readIntLE(12, 3) this.iconSize = buffer.readIntLE(12, 3)
crypto.on("lock", () => {
this.#lock()
})
} }
/** /**
* Validate attachment file. * Validate attachment file.
*/ */
#validate() { #validate() {
const file = this.buffer const file = this.#buffer
invariant( invariant(
file.slice(0, 6).toString("utf-8") === "OPCLDA", file.slice(0, 6).toString("utf-8") === "OPCLDA",
"Attachment must start with 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() { get icon() {
const { buffer, metadataSize, iconSize } = this if (this.#icon == null) {
const iconData = buffer.slice(16 + metadataSize, 16 + metadataSize + iconSize) this.#decrypt()
return decryptOPData(iconData, cipher) }
return this.#icon!
} }
/** get file() {
* Decrypt the content of this attachment. This function if (this.#file == null) {
* shall be called by an `Item` where `cipher` is `deriveConcreteKey(item, master)`. this.#decrypt()
* @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),
} }
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() { dispose() {
this.buffer = null! this.#buffer = null!
this.#lock()
} }
} }

View File

@ -7,6 +7,10 @@ import { ItemDetails, Overview, Profile } from "../types"
import { setIfAbsent } from "../util" import { setIfAbsent } from "../util"
import { EncryptedItem } from "./item" import { EncryptedItem } from "./item"
declare module "crypto" {
export const webcrypto: Crypto
}
/** Encryption and MAC */ /** Encryption and MAC */
export interface Cipher { export interface Cipher {
/** Encryption key */ /** Encryption key */
@ -110,6 +114,14 @@ export class Crypto extends EventEmitter<{ lock: void }> implements IDisposable
return splitPlainText(derivedKey) 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) { // async function pbkdf2(password: string, salt: string, iterations = 1000, length = 256) {

View File

@ -1,7 +1,5 @@
import { assertHMac, decryptData, splitPlainText } from "../crypto"
import type { ItemDetails, Overview } from "../types" import type { ItemDetails, Overview } from "../types"
import type { Crypto } from "./crypto" import type { Crypto } from "./crypto"
import type { Cipher } from "../crypto"
import { Attachment } from "./attachment" import { Attachment } from "./attachment"
export interface EncryptedItem { export interface EncryptedItem {
@ -39,10 +37,7 @@ export class Item {
this.#data = data this.#data = data
} }
dispose() { /** @internal */
this.attachments.forEach(a => a.dispose())
}
addAttachment(buffer: Buffer) { addAttachment(buffer: Buffer) {
this.attachments.push(new Attachment(this.#crypto, this.#data.k, buffer)) this.attachments.push(new Attachment(this.#crypto, this.#data.k, buffer))
} }

View File

@ -8,6 +8,7 @@ import { Item } from "./item"
import type { Profile, Overview } from "../types" import type { Profile, Overview } from "../types"
import { WeakValueMap } from "../weakMap" import { WeakValueMap } from "../weakMap"
import { EventEmitter } from "../ee" import { EventEmitter } from "../ee"
import { asyncMap } from "../util"
type Band = { [uuid: string]: Item } type Band = { [uuid: string]: Item }
type FoldersMap = { [uuid: string]: Band } type FoldersMap = { [uuid: string]: Band }
@ -33,17 +34,15 @@ export class Vault extends EventEmitter<VaultEvents> {
profile: Profile, profile: Profile,
folders: FoldersMap, folders: FoldersMap,
items: Item[], items: Item[],
crypto: Crypto crypto: Crypto,
itemsMap: WeakValueMap<string, Item>
) { ) {
super() super()
this.#profile = profile this.#profile = profile
this.#folders = folders this.#folders = folders
this.#items = items this.#items = items
this.#crypto = crypto this.#crypto = crypto
this.#itemsMap = itemsMap
items.forEach(item => {
this.#itemsMap.set(item.uuid, item)
})
} }
/** /**
@ -57,19 +56,32 @@ export class Vault extends EventEmitter<VaultEvents> {
stripText(await files.getProfile(), /^var profile\s*=/, ";") 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<string, Item>()
const bands: Item[] = [] const bands: Item[] = []
for (let i = 0; i < 36; i++) { for (let i = 0; i < 36; i++) {
const letter = i.toString(36).toUpperCase() const letter = i.toString(36).toUpperCase()
const source = await files.getBand(letter) const source = await files.getBand(letter)
if (!source) continue if (!source) continue
const object = JSON.parse(stripText(source, "ld(", ");")) const object: Record<string, EncryptedItem> = JSON.parse(
stripText(source, "ld(", ");")
)
for (const value of Object.values(object)) { 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({ readonly overviews = Object.freeze({
@ -151,7 +163,3 @@ function stripText(text: string, prefix: string | RegExp, suffix: string | RegEx
} }
return text return text
} }
function toBuffer(data: string) {
return Buffer.from(data, "base64")
}

View File

@ -7,12 +7,15 @@ export interface Profile {
/** Unix seconds */ /** Unix seconds */
updatedAt: integer updatedAt: integer
profileName: string profileName: string
salt: string // base64 /** base64 */
masterKey: string // base64 salt: string
/** base64 */
masterKey: string
iterations: integer // 50000 iterations: integer // 50000
uuid: string // 32 chars uuid: string // 32 chars
overviewKey: string // "b3B...IMO52D" overviewKey: string // "b3B...IMO52D"
createdAt: integer // Unix seconds /** Unix seconds */
createdAt: integer
} }
export type TextField = { export type TextField = {
@ -62,9 +65,11 @@ declare namespace ItemSection {
type Date = { type Date = {
k: "date" k: "date"
v: number // 359100000 v: number // 359100000
n: string // "birthdate" /** @example "birthdate" */
n: string
a: A a: A
t: string // "birth date" | "date of birth" /** @example "birth date" | "date of birth" */
t: string
} }
type Gender = { type Gender = {
k: "gender" k: "gender"
@ -95,13 +100,16 @@ declare namespace ItemSection {
// One of them is empty?, 0C4F27910A64488BB339AED63565D148 // One of them is empty?, 0C4F27910A64488BB339AED63565D148
export interface ItemDetails { export interface ItemDetails {
htmlForm?: { htmlForm?: {
htmlAction: string // "/login/" /** @example "/login/" */
htmlAction: string
htmlMethod: "post" | "get" htmlMethod: "post" | "get"
} }
notesPlain?: string notesPlain?: string
sections: { sections: {
name: string // "name" | "title" | "internet" /** @example "name" | "title" | "internet" */
title: string // "Identification" | "Address", "Internet Details" name: string
/** @example "Identification" | "Address" | "Internet Details" */
title: string
fields?: ItemSection.Any[] fields?: ItemSection.Any[]
}[] }[]
/** Web form fields */ /** Web form fields */
@ -110,10 +118,11 @@ export interface ItemDetails {
export interface Folder { export interface Folder {
created: number // 1373754128 created: number // 1373754128
overview: string // "b3BkYXRhT/../KBM=" /** base64 */
overview: string
smart?: true smart?: true
tx: number // 1373754523 tx: number // 1373754523
/** Updated time, UNIX seconds */ /** Updated time in UNIX seconds */
updated: number updated: number
uuid: string // "AC78552EB06A4F65BEBF58B4D9E32080" uuid: string // "AC78552EB06A4F65BEBF58B4D9E32080"
} }
@ -122,7 +131,8 @@ export interface Overview {
/** /**
* Password strength. * 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 ps: number
@ -143,8 +153,11 @@ export interface Overview {
URLs?: { u: string }[] URLs?: { u: string }[]
uuid?: string // Added manually uuid?: string // Added manually
appIds: { appIds: {
name: string // 'Firefox' | 'Email' /** @example 'Firefox' | 'Email' */
type: string // 'accessibility://login' name: string
id: string // 'android://...' /** @example 'accessibility://login' */
type: string
/** @example 'android://...' */
id: string
}[] }[]
} }

View File

@ -36,7 +36,7 @@ export function once<T extends (...args: any[]) => any>(fn: T): T {
return res as any return res as any
} }
export const cache = (): MethodDecorator => (_, key, descriptor: any) => { const cache = (): MethodDecorator => (_, key, descriptor: any) => {
if (process.env.NODE_ENV !== "production") { if (process.env.NODE_ENV !== "production") {
invariant(typeof key === "string") invariant(typeof key === "string")
invariant(descriptor.get != null) invariant(descriptor.get != null)