Refactor folder structure

This commit is contained in:
aet 2021-07-18 18:15:00 -04:00
parent 98cc916432
commit 0151dae1aa
9 changed files with 934 additions and 125 deletions

File diff suppressed because one or more lines are too long

BIN
icon.bin Normal file

Binary file not shown.

2
src/global.d.ts vendored
View File

@ -1,5 +1,5 @@
type integer = number
interface Disposable {
interface IDisposable {
dispose(): void
}

View File

@ -15,7 +15,7 @@ const mapValue = <T, R>(
}
type json = typeof json
type i18n = {
export type i18n = {
[K in keyof json]: {
[L in keyof json[K]]: string
}

View File

@ -2,6 +2,7 @@ import type { Cipher } from "../crypto"
import { decryptOPData } from "../crypto"
import { invariant } from "../errors"
import { cache } from "../util"
import { Crypto } from "./crypto"
import type { Item } from "./item"
type integer = number
@ -18,15 +19,17 @@ export interface AttachmentMetadata {
uuid: string
}
export class Attachment implements Disposable {
#item: WeakRef<Item>
export class Attachment {
#k: string
#crypto: Crypto
private metadataSize: number
private iconSize: number
constructor(item: Item, private buffer: Buffer) {
constructor(crypto: Crypto, k: string, private buffer: Buffer) {
this.#validate()
this.#item = new WeakRef(item)
this.#crypto = crypto
this.#k = k
this.metadataSize = buffer.readIntLE(8, 2)
this.iconSize = buffer.readIntLE(12, 3)
}
@ -63,7 +66,8 @@ export class Attachment implements Disposable {
* shall be called by an `Item` where `cipher` is `deriveConcreteKey(item, master)`.
* @internal
*/
decrypt(cipher: Cipher) {
decrypt() {
const cipher = this.#deriveConcreteKey(master)
const { buffer, metadataSize, iconSize } = this
const metadata: AttachmentMetadata = JSON.parse(
buffer.slice(16, 16 + metadataSize).toString("utf-8")

View File

@ -1 +1,172 @@
export class Crypto {}
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 { ItemDetails, Overview, Profile } from "../types"
import { setIfAbsent } from "../util"
import { 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(
/* password */ masterPassword,
/* salt */ Buffer.from(profile.salt, "base64"),
/* iterations */ profile.iterations,
/* keylen */ 64,
/* digest */ "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 = <K, V, K2 = K>(
deriveArg: (value: K) => K2,
implementation: (value: K2) => V
) => {
const map = new Map<K2, V>()
this.#disposables.push({
dispose: () => map.clear(),
})
return (data: K) => setIfAbsent(map, deriveArg(data), implementation)
}
#createWeakCache = <K extends object, V>(implementation: (value: K) => V) => {
let map = new WeakMap<K, V>()
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)
}
)
}
// 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,7 +1,8 @@
import { assertHMac, decryptData, decryptOPData, splitPlainText } from "../crypto"
import type { ItemDetails } from "../types"
import { assertHMac, decryptData, splitPlainText } from "../crypto"
import type { ItemDetails, Overview } from "../types"
import type { Crypto } from "./crypto"
import type { Cipher } from "../crypto"
import type { Attachment } from "./attachment"
import { Attachment } from "./attachment"
export interface EncryptedItem {
category: string // "001"
@ -17,51 +18,32 @@ export interface EncryptedItem {
uuid: string // 32 chars
}
export class ItemAttachmentBridge {
constructor(public cipher?: Cipher) {}
}
export class Item implements Disposable {
export class Item {
#crypto: Crypto
#data: EncryptedItem
itemDetails!: ItemDetails
attachments: Attachment[] = []
get uuid() {
return this.#data.uuid
}
get overview(): Overview {
return this.#crypto.decryptItemOverview(this.#data)
}
get itemDetails(): ItemDetails {
return this.#crypto.decryptItemDetails(this.#data)
}
constructor(data: EncryptedItem) {
constructor(crypto: Crypto, data: EncryptedItem) {
this.#crypto = crypto
this.#data = data
}
dispose() {
this.attachments.forEach(a => a.dispose())
this.itemDetails = null!
}
#deriveConcreteKey(master: Cipher) {
const k = Buffer.from(this.#data.k, "base64")
const data = k.slice(0, -32)
assertHMac(data, master.hmac, k.slice(-32))
const derivedKey = decryptData(master.key, data.slice(0, 16), data.slice(16))
return splitPlainText(derivedKey)
}
/** @internal */
decryptItemDetail(master: Cipher): ItemDetails {
const cipher = this.#deriveConcreteKey(master)
const detail = decryptOPData(
/* cipherText */ Buffer.from(this.#data.d, "base64"),
/* cipher */ cipher
)
this.itemDetails = JSON.parse(detail.toString("utf-8"))
return this.itemDetails
}
/** @internal */
decryptAttachments(master: Cipher) {
const cipher = this.#deriveConcreteKey(master)
this.attachments.forEach(a => a.decrypt(cipher))
addAttachment(buffer: Buffer) {
this.attachments.push(new Attachment(this.#crypto, this.#data.k, buffer))
}
}

View File

@ -1,11 +1,9 @@
import * as crypto from "crypto"
import type { IAdapter } from "../adapters"
import type { Cipher } from "../crypto"
import { decryptKeys, splitPlainText, decryptOPData } from "../crypto"
import { HMACAssertionError, invariant } from "../errors"
import { OnePasswordFileManager } from "../fs"
import { i18n } from "../i18n"
import type { EncryptedItem } from "./item"
import { Crypto } from "./crypto"
import { Item } from "./item"
import type { Profile, Overview } from "../types"
import { WeakValueMap } from "../weakMap"
@ -22,31 +20,26 @@ interface VaultEvents {
* Main OnePassword Vault class
*/
export class Vault extends EventEmitter<VaultEvents> {
// Ciphers
#master?: Cipher
#overview?: Cipher
// File system interface
#files: OnePasswordFileManager
#profile: Profile
#folders: FoldersMap
#overviews = new Map<string, Overview>()
#items: Item[] = []
#itemsMap = new WeakValueMap<string, Item>()
#crypto: Crypto
private constructor(
files: OnePasswordFileManager,
profile: Profile,
folders: FoldersMap,
items: Item[]
items: Item[],
crypto: Crypto
) {
super()
this.#files = files
this.#profile = profile
this.#folders = folders
this.#items = items
this.#crypto = crypto
items.forEach(item => {
this.#itemsMap.set(item.uuid, item)
@ -58,6 +51,7 @@ export class Vault extends EventEmitter<VaultEvents> {
* @internal
*/
static async of(path: string, profileName = "default", adapter: IAdapter) {
const crypto = new Crypto(i18n)
const files = OnePasswordFileManager(adapter.fs, path, profileName)
const profile = JSON.parse(
stripText(await files.getProfile(), /^var profile\s*=/, ";")
@ -71,31 +65,30 @@ export class Vault extends EventEmitter<VaultEvents> {
if (!source) continue
const object = JSON.parse(stripText(source, "ld(", ");"))
for (const value of Object.values(object)) {
bands.push(new Item(value as EncryptedItem))
bands.push(new Item(crypto, value as EncryptedItem))
}
}
return new Vault(files, profile, folders, bands)
return new Vault(profile, folders, bands, crypto)
}
readonly overviews = Object.freeze({
get: (condition: string | ((overview: Overview) => boolean)) => {
this.#assertUnlocked()
this.#crypto.assertUnlocked()
if (typeof condition === "string") {
const title = condition
condition = overview => overview.title === title
}
for (const value of this.#overviews.values()) {
if (condition(value)) {
for (const value of this.#items) {
if (condition(value.overview)) {
return value
}
}
},
values: () => {
this.#assertUnlocked()
return Array.from(this.#overviews.values())
this.#crypto.assertUnlocked()
return Array.from(this.#items.map(x => x.overview))
},
})
@ -105,29 +98,14 @@ export class Vault extends EventEmitter<VaultEvents> {
* master and overview key will be stored within the class.
*/
unlock(masterPassword: string) {
const profile = this.#profile
const derivedKey = crypto.pbkdf2Sync(
/* password */ masterPassword,
/* salt */ toBuffer(profile.salt),
/* iterations */ profile.iterations,
/* keylen */ 64,
/* digest */ "sha512"
)
const cipher = splitPlainText(derivedKey)
// Derive master key and overview keys
try {
this.#master = decryptKeys(profile.masterKey, cipher)
this.#overview = decryptKeys(profile.overviewKey, cipher)
this.#crypto.unlock(this.#profile, masterPassword)
} catch (e) {
if (e instanceof HMACAssertionError) {
throw new Error(i18n.error.invalidPassword)
}
throw e
}
this.#readOverviews()
return this
}
@ -135,60 +113,18 @@ export class Vault extends EventEmitter<VaultEvents> {
* Remove derived keys stored within the class instance.
*/
lock() {
this.#master = null!
this.#overview = null!
this.#overviews.clear()
this.#crypto.lock()
this.emit("lock")
return this
}
get isLocked() {
return this.#master?.key == null
}
#assertUnlocked() {
if (this.isLocked) {
throw new Error(i18n.error.vaultIsLocked)
}
return this.#crypto.locked
}
getItem(uuid: string) {
this.#assertUnlocked()
const item = this.#itemsMap.get(uuid)
if (!item) return
if (!item.itemDetails) {
item.decryptItemDetail(this.#master!)
}
const encrypted = uuid ? this.#encryptedItems.get(uuid[0])![uuid] : undefined
if (!encrypted) return
return {
original: encrypted,
details: decryptItemDetail(encrypted, this.#master!),
}
}
#readOverviews() {
this.#assertUnlocked()
this.#encryptedItems.forEach(value => {
for (const [uuid, item] of Object.entries(value)) {
const overview = decryptOverview(item, this.#overview!)
overview.uuid = uuid
this.#overviews.set(uuid, overview)
}
})
}
}
function decryptOverview(item: EncryptedItem, overviewCipher: Cipher): Overview {
try {
const overview = decryptOPData(toBuffer(item.o), overviewCipher)
return JSON.parse(overview.toString("utf8")) as Overview
} catch (e) {
console.error(i18n.error.cannotDecryptOverviewItem)
throw e
this.#crypto.assertUnlocked()
return this.#itemsMap.get(uuid)
}
}

View File

@ -7,6 +7,20 @@ export function asyncMap<T, R>(
return Promise.all(list.map(fn))
}
export function setIfAbsent<K, V>(
// @ts-expect-error
map: Map<K, V> | WeakMap<K, V>,
key: K,
getValue: (key: K) => V
) {
if (!map.has(key)) {
const value = getValue(key)
map.set(key, value)
return value
}
return map.get(key)!
}
export function once<T extends (...args: any[]) => any>(fn: T): T {
let result: ReturnType<T>
let executed = false