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

View File

@ -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<Buffer>
/**
* Asynchronously reads the entire contents of a file.
* @param path A path to a file.

View File

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

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

View File

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

View File

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

View File

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

View File

@ -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<VaultEvents> {
profile: Profile,
folders: FoldersMap,
items: Item[],
crypto: Crypto
crypto: Crypto,
itemsMap: WeakValueMap<string, Item>
) {
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<VaultEvents> {
stripText(await files.getProfile(), /^var profile\s*=/, ";")
)
const folders = JSON.parse(stripText(await files.getFolders(), "loadFolders(", ");"))
const itemsMap = new WeakValueMap<string, Item>()
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<string, EncryptedItem> = 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")
}

View File

@ -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
}[]
}

View File

@ -36,7 +36,7 @@ export function once<T extends (...args: any[]) => 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)