Update
This commit is contained in:
parent
0151dae1aa
commit
7c12f499f2
21
repl.ts
21
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())
|
||||
}
|
||||
|
6
src/adapters/index.d.ts
vendored
6
src/adapters/index.d.ts
vendored
@ -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.
|
||||
|
@ -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,
|
||||
|
@ -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)
|
||||
}
|
13
src/fs.ts
13
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)),
|
||||
}
|
||||
})
|
||||
},
|
||||
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
@ -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) {
|
||||
|
@ -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))
|
||||
}
|
||||
|
@ -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")
|
||||
}
|
||||
|
41
src/types.ts
41
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
|
||||
}[]
|
||||
}
|
||||
|
@ -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)
|
||||
|
Loading…
x
Reference in New Issue
Block a user