Refactor folder structure
This commit is contained in:
parent
98cc916432
commit
0151dae1aa
702
OPVault design _ 1Password (05_07_2021 14_21_06).html
Normal file
702
OPVault design _ 1Password (05_07_2021 14_21_06).html
Normal file
File diff suppressed because one or more lines are too long
2
src/global.d.ts
vendored
2
src/global.d.ts
vendored
@ -1,5 +1,5 @@
|
||||
type integer = number
|
||||
|
||||
interface Disposable {
|
||||
interface IDisposable {
|
||||
dispose(): void
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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")
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
14
src/util.ts
14
src/util.ts
@ -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
|
||||
|
Loading…
x
Reference in New Issue
Block a user