Migrate to monorepo
This commit is contained in:
124
packages/opvault.js/adapters/browser.ts
Normal file
124
packages/opvault.js/adapters/browser.ts
Normal file
@ -0,0 +1,124 @@
|
||||
import { Buffer } from "buffer"
|
||||
import createHmac from "create-hmac"
|
||||
import type { IAdapter, IFileSystem } from "./index"
|
||||
|
||||
function normalize(path: string) {
|
||||
return path.replace(/^\//, "")
|
||||
}
|
||||
|
||||
function splitPath(path: string) {
|
||||
const segments = normalize(path).split("/")
|
||||
const filename = segments.pop()!
|
||||
return [segments, filename] as const
|
||||
}
|
||||
|
||||
export class FileSystem implements IFileSystem {
|
||||
constructor(private handle: FileSystemDirectoryHandle) {}
|
||||
|
||||
static async create() {
|
||||
const handle = await showDirectoryPicker()
|
||||
return new FileSystem(handle)
|
||||
}
|
||||
|
||||
private async getDirectoryHandle(segments: string[]) {
|
||||
if (!segments.length || (segments.length === 1 && !segments[0])) {
|
||||
return this.handle
|
||||
}
|
||||
const [first, ...pathSegments] = segments
|
||||
const handle = await pathSegments.reduce(
|
||||
async (accum, next) => (await accum).getDirectoryHandle(next),
|
||||
this.handle.getDirectoryHandle(first)
|
||||
)
|
||||
return handle
|
||||
}
|
||||
|
||||
private async getFileHandle(path: string) {
|
||||
const [segments, filename] = splitPath(path)
|
||||
const dirHandle = await this.getDirectoryHandle(segments)
|
||||
const fileHandle = await dirHandle.getFileHandle(filename)
|
||||
return fileHandle
|
||||
}
|
||||
|
||||
async readFile(path: string) {
|
||||
const handle = await this.getFileHandle(path)
|
||||
const file = await handle.getFile()
|
||||
return file.text()
|
||||
}
|
||||
|
||||
async exists(path: string): Promise<boolean> {
|
||||
if (path === "/") {
|
||||
return true
|
||||
}
|
||||
|
||||
const [segments, filename] = splitPath(path)
|
||||
let { handle } = this
|
||||
for (const segment of segments) {
|
||||
try {
|
||||
handle = await handle.getDirectoryHandle(segment)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
(await success(() => handle.getFileHandle(filename))) ||
|
||||
(await success(() => handle.getDirectoryHandle(filename)))
|
||||
)
|
||||
}
|
||||
|
||||
async readBuffer(path: string): Promise<Buffer> {
|
||||
const handle = await this.getFileHandle(path)
|
||||
const file = await handle.getFile()
|
||||
return Buffer.from(await file.arrayBuffer())
|
||||
}
|
||||
|
||||
async writeFile(path: string, data: string): Promise<void> {
|
||||
const handle = await this.getFileHandle(path)
|
||||
const writable = await handle.createWritable()
|
||||
await writable.write(data)
|
||||
await writable.close()
|
||||
}
|
||||
|
||||
async readdir(path: string): Promise<string[]> {
|
||||
const segments = normalize(path).split("/")
|
||||
const dirHandle = await this.getDirectoryHandle(segments)
|
||||
const keys: string[] = []
|
||||
for await (const key of dirHandle.keys()) {
|
||||
keys.push(key)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
async isDirectory(path: string) {
|
||||
const [segments, filename] = splitPath(path)
|
||||
const dirHandle = await this.getDirectoryHandle(segments)
|
||||
for await (const [key, handle] of dirHandle.entries()) {
|
||||
if (key !== filename) continue
|
||||
|
||||
if (handle instanceof FileSystemDirectoryHandle) {
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function success(fn: () => Promise<any>) {
|
||||
try {
|
||||
await fn()
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Default Browser adapter.
|
||||
*/
|
||||
export const getBrowserAdapter = (handle: FileSystemDirectoryHandle): IAdapter => ({
|
||||
fs: new FileSystem(handle),
|
||||
subtle: crypto.subtle,
|
||||
hmacSHA256: (key, data) => createHmac("sha256", key).update(data).digest(),
|
||||
})
|
324
packages/opvault.js/adapters/decipher.ts
Normal file
324
packages/opvault.js/adapters/decipher.ts
Normal file
@ -0,0 +1,324 @@
|
||||
function bufferXor(a: Buffer, b: Buffer) {
|
||||
const length = Math.min(a.length, b.length)
|
||||
const buffer = Buffer.alloc(length)
|
||||
|
||||
for (let i = 0; i < length; ++i) {
|
||||
buffer[i] = a[i] ^ b[i]
|
||||
}
|
||||
|
||||
return buffer
|
||||
}
|
||||
|
||||
function asUInt32Array(buf: Buffer) {
|
||||
const len = (buf.length / 4) | 0
|
||||
const out: number[] = new Array(len)
|
||||
|
||||
for (let i = 0; i < len; i++) {
|
||||
out[i] = buf.readUInt32BE(i * 4)
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
function cryptBlock(
|
||||
M: number[],
|
||||
keySchedule: number[],
|
||||
SUB_MIX: number[][],
|
||||
SBOX: number[],
|
||||
nRounds: number
|
||||
) {
|
||||
const SUB_MIX0 = SUB_MIX[0]
|
||||
const SUB_MIX1 = SUB_MIX[1]
|
||||
const SUB_MIX2 = SUB_MIX[2]
|
||||
const SUB_MIX3 = SUB_MIX[3]
|
||||
|
||||
let s0 = M[0] ^ keySchedule[0]
|
||||
let s1 = M[1] ^ keySchedule[1]
|
||||
let s2 = M[2] ^ keySchedule[2]
|
||||
let s3 = M[3] ^ keySchedule[3]
|
||||
let t0: number
|
||||
let t1: number
|
||||
let t2: number
|
||||
let t3: number
|
||||
let ksRow = 4
|
||||
|
||||
for (let round = 1; round < nRounds; round++) {
|
||||
t0 =
|
||||
SUB_MIX0[s0 >>> 24] ^
|
||||
SUB_MIX1[(s1 >>> 16) & 0xff] ^
|
||||
SUB_MIX2[(s2 >>> 8) & 0xff] ^
|
||||
SUB_MIX3[s3 & 0xff] ^
|
||||
keySchedule[ksRow++]
|
||||
t1 =
|
||||
SUB_MIX0[s1 >>> 24] ^
|
||||
SUB_MIX1[(s2 >>> 16) & 0xff] ^
|
||||
SUB_MIX2[(s3 >>> 8) & 0xff] ^
|
||||
SUB_MIX3[s0 & 0xff] ^
|
||||
keySchedule[ksRow++]
|
||||
t2 =
|
||||
SUB_MIX0[s2 >>> 24] ^
|
||||
SUB_MIX1[(s3 >>> 16) & 0xff] ^
|
||||
SUB_MIX2[(s0 >>> 8) & 0xff] ^
|
||||
SUB_MIX3[s1 & 0xff] ^
|
||||
keySchedule[ksRow++]
|
||||
t3 =
|
||||
SUB_MIX0[s3 >>> 24] ^
|
||||
SUB_MIX1[(s0 >>> 16) & 0xff] ^
|
||||
SUB_MIX2[(s1 >>> 8) & 0xff] ^
|
||||
SUB_MIX3[s2 & 0xff] ^
|
||||
keySchedule[ksRow++]
|
||||
s0 = t0
|
||||
s1 = t1
|
||||
s2 = t2
|
||||
s3 = t3
|
||||
}
|
||||
|
||||
t0 =
|
||||
((SBOX[s0 >>> 24] << 24) |
|
||||
(SBOX[(s1 >>> 16) & 0xff] << 16) |
|
||||
(SBOX[(s2 >>> 8) & 0xff] << 8) |
|
||||
SBOX[s3 & 0xff]) ^
|
||||
keySchedule[ksRow++]
|
||||
t1 =
|
||||
((SBOX[s1 >>> 24] << 24) |
|
||||
(SBOX[(s2 >>> 16) & 0xff] << 16) |
|
||||
(SBOX[(s3 >>> 8) & 0xff] << 8) |
|
||||
SBOX[s0 & 0xff]) ^
|
||||
keySchedule[ksRow++]
|
||||
t2 =
|
||||
((SBOX[s2 >>> 24] << 24) |
|
||||
(SBOX[(s3 >>> 16) & 0xff] << 16) |
|
||||
(SBOX[(s0 >>> 8) & 0xff] << 8) |
|
||||
SBOX[s1 & 0xff]) ^
|
||||
keySchedule[ksRow++]
|
||||
t3 =
|
||||
((SBOX[s3 >>> 24] << 24) |
|
||||
(SBOX[(s0 >>> 16) & 0xff] << 16) |
|
||||
(SBOX[(s1 >>> 8) & 0xff] << 8) |
|
||||
SBOX[s2 & 0xff]) ^
|
||||
keySchedule[ksRow++]
|
||||
t0 = t0 >>> 0
|
||||
t1 = t1 >>> 0
|
||||
t2 = t2 >>> 0
|
||||
t3 = t3 >>> 0
|
||||
|
||||
return [t0, t1, t2, t3]
|
||||
}
|
||||
|
||||
// AES constants
|
||||
const RCON = [0x00, 0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80, 0x1b, 0x36]
|
||||
const G = (function () {
|
||||
// Compute double table
|
||||
const d = new Array(256)
|
||||
for (let j = 0; j < 256; j++) {
|
||||
if (j < 128) {
|
||||
d[j] = j << 1
|
||||
} else {
|
||||
d[j] = (j << 1) ^ 0x11b
|
||||
}
|
||||
}
|
||||
|
||||
const SBOX: number[] = []
|
||||
const INV_SBOX: number[] = []
|
||||
const SUB_MIX: number[][] = [[], [], [], []]
|
||||
const INV_SUB_MIX: number[][] = [[], [], [], []]
|
||||
|
||||
// Walk GF(2^8)
|
||||
let x = 0
|
||||
let xi = 0
|
||||
for (let i = 0; i < 256; ++i) {
|
||||
// Compute sbox
|
||||
let sx = xi ^ (xi << 1) ^ (xi << 2) ^ (xi << 3) ^ (xi << 4)
|
||||
sx = (sx >>> 8) ^ (sx & 0xff) ^ 0x63
|
||||
SBOX[x] = sx
|
||||
INV_SBOX[sx] = x
|
||||
|
||||
// Compute multiplication
|
||||
const x2 = d[x]
|
||||
const x4 = d[x2]
|
||||
const x8 = d[x4]
|
||||
|
||||
// Compute sub bytes, mix columns tables
|
||||
let t = (d[sx] * 0x101) ^ (sx * 0x1010100)
|
||||
SUB_MIX[0][x] = (t << 24) | (t >>> 8)
|
||||
SUB_MIX[1][x] = (t << 16) | (t >>> 16)
|
||||
SUB_MIX[2][x] = (t << 8) | (t >>> 24)
|
||||
SUB_MIX[3][x] = t
|
||||
|
||||
// Compute inv sub bytes, inv mix columns tables
|
||||
t = (x8 * 0x1010101) ^ (x4 * 0x10001) ^ (x2 * 0x101) ^ (x * 0x1010100)
|
||||
INV_SUB_MIX[0][sx] = (t << 24) | (t >>> 8)
|
||||
INV_SUB_MIX[1][sx] = (t << 16) | (t >>> 16)
|
||||
INV_SUB_MIX[2][sx] = (t << 8) | (t >>> 24)
|
||||
INV_SUB_MIX[3][sx] = t
|
||||
|
||||
if (x === 0) {
|
||||
x = xi = 1
|
||||
} else {
|
||||
x = x2 ^ d[d[d[x8 ^ x2]]]
|
||||
xi ^= d[d[xi]]
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
SBOX,
|
||||
INV_SBOX,
|
||||
SUB_MIX,
|
||||
INV_SUB_MIX,
|
||||
}
|
||||
})()
|
||||
|
||||
class AES {
|
||||
private _key: number[]
|
||||
private _nRounds!: number
|
||||
private _invKeySchedule!: number[]
|
||||
|
||||
constructor(key: Buffer) {
|
||||
this._key = asUInt32Array(key)
|
||||
this._reset()
|
||||
}
|
||||
|
||||
_reset() {
|
||||
const keyWords = this._key
|
||||
const keySize = keyWords.length
|
||||
const nRounds = keySize + 6
|
||||
const ksRows = (nRounds + 1) * 4
|
||||
|
||||
const keySchedule: number[] = []
|
||||
for (let k = 0; k < keySize; k++) {
|
||||
keySchedule[k] = keyWords[k]
|
||||
}
|
||||
|
||||
for (let k = keySize; k < ksRows; k++) {
|
||||
let t = keySchedule[k - 1]
|
||||
|
||||
if (k % keySize === 0) {
|
||||
t = (t << 8) | (t >>> 24)
|
||||
t =
|
||||
(G.SBOX[t >>> 24] << 24) |
|
||||
(G.SBOX[(t >>> 16) & 0xff] << 16) |
|
||||
(G.SBOX[(t >>> 8) & 0xff] << 8) |
|
||||
G.SBOX[t & 0xff]
|
||||
|
||||
t ^= RCON[(k / keySize) | 0] << 24
|
||||
} else if (keySize > 6 && k % keySize === 4) {
|
||||
t =
|
||||
(G.SBOX[t >>> 24] << 24) |
|
||||
(G.SBOX[(t >>> 16) & 0xff] << 16) |
|
||||
(G.SBOX[(t >>> 8) & 0xff] << 8) |
|
||||
G.SBOX[t & 0xff]
|
||||
}
|
||||
|
||||
keySchedule[k] = keySchedule[k - keySize] ^ t
|
||||
}
|
||||
|
||||
const invKeySchedule = []
|
||||
for (let ik = 0; ik < ksRows; ik++) {
|
||||
const ksR = ksRows - ik
|
||||
const tt = keySchedule[ksR - (ik % 4 ? 0 : 4)]
|
||||
|
||||
if (ik < 4 || ksR <= 4) {
|
||||
invKeySchedule[ik] = tt
|
||||
} else {
|
||||
invKeySchedule[ik] =
|
||||
G.INV_SUB_MIX[0][G.SBOX[tt >>> 24]] ^
|
||||
G.INV_SUB_MIX[1][G.SBOX[(tt >>> 16) & 0xff]] ^
|
||||
G.INV_SUB_MIX[2][G.SBOX[(tt >>> 8) & 0xff]] ^
|
||||
G.INV_SUB_MIX[3][G.SBOX[tt & 0xff]]
|
||||
}
|
||||
}
|
||||
|
||||
this._nRounds = nRounds
|
||||
this._invKeySchedule = invKeySchedule
|
||||
}
|
||||
|
||||
decryptBlock(buffer: Buffer) {
|
||||
const M = asUInt32Array(buffer)
|
||||
|
||||
// swap
|
||||
const m1 = M[1]
|
||||
M[1] = M[3]
|
||||
M[3] = m1
|
||||
|
||||
const out = cryptBlock(
|
||||
M,
|
||||
this._invKeySchedule,
|
||||
G.INV_SUB_MIX,
|
||||
G.INV_SBOX,
|
||||
this._nRounds
|
||||
)
|
||||
|
||||
const buf = Buffer.allocUnsafe(16)
|
||||
buf.writeUInt32BE(out[0], 0)
|
||||
buf.writeUInt32BE(out[3], 4)
|
||||
buf.writeUInt32BE(out[2], 8)
|
||||
buf.writeUInt32BE(out[1], 12)
|
||||
return buf
|
||||
}
|
||||
|
||||
static blockSize = 4 * 4
|
||||
static keySize = 256 / 8
|
||||
}
|
||||
|
||||
class Decipher {
|
||||
private _cipher: AES
|
||||
private _prev: Buffer
|
||||
private _cache = new Splitter()
|
||||
|
||||
constructor(key: Buffer, iv: Buffer) {
|
||||
this._cipher = new AES(key)
|
||||
this._prev = Buffer.from(iv)
|
||||
}
|
||||
|
||||
update(data: Buffer) {
|
||||
this._cache.add(data)
|
||||
let chunk: Buffer | null
|
||||
let thing: Buffer
|
||||
const out: Buffer[] = []
|
||||
while ((chunk = this._cache.get())) {
|
||||
thing = this.cbc_decrypt(chunk)
|
||||
out.push(thing)
|
||||
}
|
||||
return Buffer.concat(out)
|
||||
}
|
||||
|
||||
setAutoPadding(setTo: boolean) {
|
||||
return this
|
||||
}
|
||||
|
||||
cbc_decrypt(block: Buffer) {
|
||||
const pad = this._prev
|
||||
|
||||
this._prev = block
|
||||
const out = this._cipher.decryptBlock(block)
|
||||
|
||||
return bufferXor(out, pad)
|
||||
}
|
||||
}
|
||||
|
||||
class Splitter {
|
||||
cache = Buffer.allocUnsafe(0)
|
||||
|
||||
add(data: Buffer) {
|
||||
this.cache = Buffer.concat([this.cache, data])
|
||||
}
|
||||
|
||||
get() {
|
||||
if (this.cache.length >= 16) {
|
||||
const out = this.cache.slice(0, 16)
|
||||
this.cache = this.cache.slice(16)
|
||||
return out
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function createDecipheriv(_suite: any, password: Buffer, iv: Buffer) {
|
||||
if (iv.length !== 16) {
|
||||
throw new TypeError(`invalid iv length ${iv.length}`)
|
||||
}
|
||||
if (password.length !== 256 / 8) {
|
||||
throw new TypeError(`invalid key length ${password.length}`)
|
||||
}
|
||||
return new Decipher(password, iv)
|
||||
}
|
60
packages/opvault.js/adapters/index.d.ts
vendored
Normal file
60
packages/opvault.js/adapters/index.d.ts
vendored
Normal file
@ -0,0 +1,60 @@
|
||||
/**
|
||||
* An object that implements basic file system functionalities.
|
||||
*/
|
||||
export interface IFileSystem {
|
||||
/**
|
||||
* Asynchronously tests whether or not the given path exists by checking with the file system.
|
||||
* @param path A path to a file or directory.
|
||||
*/
|
||||
exists(path: string): Promise<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.
|
||||
*/
|
||||
readFile(path: string): Promise<string>
|
||||
|
||||
/**
|
||||
* Asynchronously writes data to a file, replacing the file if it already exists.
|
||||
* @param path A path to a file.
|
||||
* @param data The data to write.
|
||||
*/
|
||||
writeFile(path: string, data: string): Promise<void>
|
||||
|
||||
/**
|
||||
* Asynchronous readdir(3) - read a directory.
|
||||
* @param path A path to a directory.
|
||||
*/
|
||||
readdir(path: string): Promise<string[]>
|
||||
|
||||
/**
|
||||
* Returns true if the path points to a directory.
|
||||
*/
|
||||
isDirectory(path: string): Promise<boolean>
|
||||
}
|
||||
|
||||
export interface IAdapter {
|
||||
/**
|
||||
* Underlying `fs` module. You can replace it with a wrapper of
|
||||
* `memfs` or any object that implements `IFileSystem`.
|
||||
*/
|
||||
fs: IFileSystem
|
||||
|
||||
/**
|
||||
* `SubtleCrypto` implementation. On Node.js this is
|
||||
* `require("crypto").webcrypto.subtle` and in the browser this is
|
||||
* `window.crypto.subtle`.
|
||||
*/
|
||||
subtle: SubtleCrypto
|
||||
|
||||
/**
|
||||
* Equivalent to `createHmac("sha256", key).update(data).digest()`
|
||||
*/
|
||||
hmacSHA256(key: Buffer, data: Buffer): Buffer
|
||||
}
|
21
packages/opvault.js/adapters/node.ts
Normal file
21
packages/opvault.js/adapters/node.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import { promises as fs, existsSync } from "fs"
|
||||
import { webcrypto, createHmac } from "crypto"
|
||||
|
||||
import type { IAdapter } from "./index"
|
||||
|
||||
/**
|
||||
* Default Node.js adapter. This can be used while using `opvault.js`
|
||||
* in a Node.js environment.
|
||||
*/
|
||||
export const adapter: IAdapter = {
|
||||
fs: {
|
||||
readFile: path => fs.readFile(path, "utf-8"),
|
||||
readBuffer: path => fs.readFile(path),
|
||||
writeFile: fs.writeFile,
|
||||
readdir: fs.readdir,
|
||||
isDirectory: async path => fs.stat(path).then(x => x.isDirectory()),
|
||||
exists: async path => existsSync(path),
|
||||
},
|
||||
subtle: (webcrypto as any).subtle,
|
||||
hmacSHA256: (key, data) => createHmac("sha256", key).update(data).digest(),
|
||||
}
|
22
packages/opvault.js/ee.ts
Normal file
22
packages/opvault.js/ee.ts
Normal file
@ -0,0 +1,22 @@
|
||||
export function createEventEmitter<T = void>() {
|
||||
type EventListener = T extends void ? () => void : (value: T) => void
|
||||
type Emitter = T extends void
|
||||
? { (): void; (listener: EventListener): IDisposable }
|
||||
: { (value: T): void; (listener: EventListener): IDisposable }
|
||||
|
||||
const listeners = new Set<EventListener>()
|
||||
|
||||
function emitter(value: T | EventListener) {
|
||||
if (typeof value === "function") {
|
||||
listeners.add(value as EventListener)
|
||||
return {
|
||||
dispose() {
|
||||
listeners.delete(value as EventListener)
|
||||
},
|
||||
}
|
||||
} else {
|
||||
listeners.forEach(fn => fn(value))
|
||||
}
|
||||
}
|
||||
return emitter as Emitter
|
||||
}
|
11
packages/opvault.js/errors.ts
Normal file
11
packages/opvault.js/errors.ts
Normal file
@ -0,0 +1,11 @@
|
||||
export abstract class OPVaultError extends Error {}
|
||||
|
||||
export class AssertionError extends OPVaultError {}
|
||||
|
||||
export class HMACAssertionError extends AssertionError {}
|
||||
|
||||
export function invariant(condition: any, message?: string): asserts condition {
|
||||
if (!condition) {
|
||||
throw new AssertionError(message)
|
||||
}
|
||||
}
|
67
packages/opvault.js/fs.ts
Normal file
67
packages/opvault.js/fs.ts
Normal file
@ -0,0 +1,67 @@
|
||||
import { resolve, extname, basename } from "path"
|
||||
import invariant from "tiny-invariant"
|
||||
import type { IFileSystem } from "./adapters"
|
||||
import { once } from "./util"
|
||||
|
||||
export type OnePasswordFileManager = ReturnType<typeof OnePasswordFileManager>
|
||||
|
||||
export async function OnePasswordFileManager(
|
||||
fs: IFileSystem,
|
||||
path: string,
|
||||
profileName: string
|
||||
) {
|
||||
const root = resolve(path, profileName)
|
||||
invariant(await fs.exists(path), `Path ${path} does not exist.`)
|
||||
invariant(await fs.exists(root), `Profile ${profileName} does not exist.`)
|
||||
|
||||
const abs = (path: string) => resolve(root, path)
|
||||
|
||||
const result = {
|
||||
getProfile() {
|
||||
return fs.readFile(abs("profile.js"))
|
||||
},
|
||||
|
||||
getFolders() {
|
||||
return fs.readFile(abs("folders.js"))
|
||||
},
|
||||
|
||||
async getAttachments() {
|
||||
const files = await fs.readdir(root)
|
||||
return files
|
||||
.filter(name => extname(name) === ".attachment")
|
||||
.map(name => {
|
||||
const sep = name.indexOf("_")
|
||||
const path = resolve(root, name)
|
||||
const [itemUUID, fileUUID] = [
|
||||
name.slice(0, sep),
|
||||
basename(name.slice(sep + 1), extname(name)),
|
||||
]
|
||||
return {
|
||||
itemUUID,
|
||||
fileUUID,
|
||||
getFile: once(() => fs.readBuffer(path)),
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
async getBand(name: string) {
|
||||
const path = abs(`band_${name}.js`)
|
||||
if (await fs.exists(path)) {
|
||||
return await fs.readFile(path)
|
||||
}
|
||||
},
|
||||
|
||||
async setProfile(profile: string) {
|
||||
await fs.writeFile("profile.js", profile)
|
||||
},
|
||||
|
||||
async setFolders(folders: string) {
|
||||
await fs.writeFile("folders.js", folders)
|
||||
},
|
||||
|
||||
async setBand(name: string, band: string) {
|
||||
await fs.writeFile(`band_${name}.js`, band)
|
||||
},
|
||||
}
|
||||
return result
|
||||
}
|
5
packages/opvault.js/global.d.ts
vendored
Normal file
5
packages/opvault.js/global.d.ts
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
type integer = number
|
||||
|
||||
interface IDisposable {
|
||||
dispose(): void
|
||||
}
|
26
packages/opvault.js/i18n/index.ts
Normal file
26
packages/opvault.js/i18n/index.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import json from "./res.json"
|
||||
|
||||
const locale =
|
||||
process.env.LOCALE || Intl.DateTimeFormat().resolvedOptions().locale.split("-")[0]
|
||||
|
||||
const mapValue = <T, R>(
|
||||
object: Record<string, T>,
|
||||
fn: (value: T, key: string) => R
|
||||
): Record<string, R> => {
|
||||
const res = Object.create(null)
|
||||
Object.entries(object).forEach(([key, value]) => {
|
||||
res[key] = fn(value, key)
|
||||
})
|
||||
return res
|
||||
}
|
||||
|
||||
type json = typeof json
|
||||
export type i18n = {
|
||||
[K in keyof json]: {
|
||||
[L in keyof json[K]]: string
|
||||
}
|
||||
}
|
||||
|
||||
export const i18n: i18n = mapValue(json, dict =>
|
||||
mapValue(dict, (value: any) => value[locale] ?? value.en)
|
||||
) as any
|
16
packages/opvault.js/i18n/res.json
Normal file
16
packages/opvault.js/i18n/res.json
Normal file
@ -0,0 +1,16 @@
|
||||
{
|
||||
"error": {
|
||||
"invalidPassword": {
|
||||
"en": "Invalid password",
|
||||
"fr": "Mot de passe invalide"
|
||||
},
|
||||
"vaultIsLocked": {
|
||||
"en": "This vault is locked",
|
||||
"fr": "Ce coffre est verrouillé."
|
||||
},
|
||||
"cannotDecryptOverviewItem": {
|
||||
"en": "Failed to decrypt overview item",
|
||||
"fr": "Impossible de déchiffrer cet aperçu"
|
||||
}
|
||||
}
|
||||
}
|
58
packages/opvault.js/index.ts
Normal file
58
packages/opvault.js/index.ts
Normal file
@ -0,0 +1,58 @@
|
||||
import { resolve } from "path"
|
||||
import { Vault } from "./models/vault"
|
||||
import type { IAdapter } from "./adapters"
|
||||
import { asyncMap } from "./util"
|
||||
|
||||
export type { Vault } from "./models/vault"
|
||||
export { Category, FieldType } from "./models"
|
||||
|
||||
interface IOptions {
|
||||
/**
|
||||
* Path to `.opvault` directory
|
||||
*/
|
||||
path: string
|
||||
|
||||
/**
|
||||
* Adapter used to interact with the file system and cryptography modules
|
||||
*/
|
||||
adapter?: IAdapter
|
||||
}
|
||||
|
||||
/**
|
||||
* OnePassword instance
|
||||
*/
|
||||
export class OnePassword {
|
||||
readonly #path: string
|
||||
readonly #adapter: IAdapter
|
||||
|
||||
constructor({ path, adapter = require("./adapters/node").adapter }: IOptions) {
|
||||
this.#adapter = adapter
|
||||
this.#path = path
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns A list of names of profiles of the current vault.
|
||||
*/
|
||||
async getProfileNames() {
|
||||
const [fs, path] = [this.#adapter.fs, this.#path]
|
||||
const children = await fs.readdir(path)
|
||||
const profiles: string[] = []
|
||||
await asyncMap(children, async child => {
|
||||
const fullPath = resolve(path, child)
|
||||
if (
|
||||
(await fs.isDirectory(fullPath)) &&
|
||||
(await fs.exists(resolve(fullPath, "profile.js")))
|
||||
) {
|
||||
profiles.push(child)
|
||||
}
|
||||
})
|
||||
return profiles
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns A OnePassword Vault instance.
|
||||
*/
|
||||
async getProfile(profileName: string) {
|
||||
return await Vault.of(this.#path, profileName, this.#adapter)
|
||||
}
|
||||
}
|
39
packages/opvault.js/models.ts
Normal file
39
packages/opvault.js/models.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import { invariant } from "./errors"
|
||||
|
||||
export enum Category {
|
||||
Login = 1,
|
||||
CreditCard = 2,
|
||||
SecureNote = 3,
|
||||
Identity = 4,
|
||||
Password = 5,
|
||||
Tombstone = 99,
|
||||
SoftwareLicense = 100,
|
||||
BankAccount = 101,
|
||||
Database = 102,
|
||||
DriverLicense = 103,
|
||||
OutdoorLicense = 104,
|
||||
Membership = 105,
|
||||
Passport = 106,
|
||||
Rewards = 107,
|
||||
SSN = 108,
|
||||
Router = 109,
|
||||
Server = 110,
|
||||
Email = 111,
|
||||
}
|
||||
|
||||
export enum FieldType {
|
||||
Password = "P",
|
||||
Text = "T",
|
||||
Email = "E",
|
||||
Number = "N",
|
||||
Radio = "R",
|
||||
Telephone = "TEL",
|
||||
Checkbox = "C",
|
||||
URL = "U",
|
||||
}
|
||||
|
||||
export function getCategory(category: string) {
|
||||
const int = parseInt(category)
|
||||
invariant(int in Category, `Invalid category: ${category}`)
|
||||
return int as Category
|
||||
}
|
112
packages/opvault.js/models/attachment.ts
Normal file
112
packages/opvault.js/models/attachment.ts
Normal file
@ -0,0 +1,112 @@
|
||||
import { Buffer } from "buffer"
|
||||
import type { Crypto } from "./crypto"
|
||||
import { invariant } from "../errors"
|
||||
|
||||
type integer = number
|
||||
|
||||
export interface AttachmentMetadata {
|
||||
itemUUID: string
|
||||
contentSize: integer
|
||||
external: boolean
|
||||
updatedAt: integer
|
||||
txTimestamp: integer
|
||||
/** Encoded as base64 encoded OPData */
|
||||
overview: {
|
||||
filename: string
|
||||
}
|
||||
createdAt: integer
|
||||
uuid: string
|
||||
}
|
||||
|
||||
export class Attachment {
|
||||
#k: string
|
||||
#crypto: Crypto
|
||||
#buffer: Buffer
|
||||
|
||||
#icon?: Buffer // png buffer
|
||||
#file?: Buffer
|
||||
#metadata?: AttachmentMetadata
|
||||
|
||||
private metadataSize: number
|
||||
private iconSize: number
|
||||
|
||||
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.onLock(() => {
|
||||
this.#lock()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate attachment file.
|
||||
*/
|
||||
#validate() {
|
||||
const file = this.#buffer
|
||||
invariant(
|
||||
file.slice(0, 6).toString("utf-8") === "OPCLDA",
|
||||
"Attachment must start with OPCLDA"
|
||||
)
|
||||
invariant(
|
||||
file.readIntLE(7, 1) === 1,
|
||||
"The version for this attachment file format is not supported."
|
||||
)
|
||||
}
|
||||
|
||||
get icon() {
|
||||
if (this.#icon == null) {
|
||||
this.#decrypt()
|
||||
}
|
||||
return this.#icon!
|
||||
}
|
||||
|
||||
get file() {
|
||||
if (this.#file == null) {
|
||||
this.#decrypt()
|
||||
}
|
||||
return this.#file!
|
||||
}
|
||||
|
||||
get metadata() {
|
||||
if (this.#metadata == null) {
|
||||
this.#decrypt()
|
||||
}
|
||||
return this.#metadata!
|
||||
}
|
||||
|
||||
#decrypt() {
|
||||
const crypto = this.#crypto
|
||||
const cipher = crypto.deriveConcreteKey({ k: this.#k })
|
||||
const { metadataSize, iconSize } = this
|
||||
const buffer = this.#buffer
|
||||
this.#icon = crypto.decryptOPData(
|
||||
buffer.slice(16 + metadataSize, 16 + metadataSize + iconSize),
|
||||
cipher
|
||||
)
|
||||
this.#file = crypto.decryptOPData(buffer.slice(16 + metadataSize + iconSize), cipher)
|
||||
|
||||
const metadata = JSON.parse(buffer.slice(16, 16 + metadataSize).toString("utf-8"))
|
||||
metadata.overview = JSON.parse(
|
||||
crypto
|
||||
.decryptOPData(Buffer.from(metadata.overview, "base64"), crypto.overview)
|
||||
.toString()
|
||||
)
|
||||
this.#metadata = metadata
|
||||
}
|
||||
|
||||
#lock() {
|
||||
this.#metadata = undefined!
|
||||
this.#icon = undefined!
|
||||
this.#file = undefined!
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.#buffer = null!
|
||||
this.#lock()
|
||||
}
|
||||
}
|
173
packages/opvault.js/models/crypto.ts
Normal file
173
packages/opvault.js/models/crypto.ts
Normal file
@ -0,0 +1,173 @@
|
||||
import { Buffer } from "buffer"
|
||||
import { createDecipheriv } from "../adapters/decipher"
|
||||
import type { IAdapter } from "../adapters"
|
||||
import { createEventEmitter } from "../ee"
|
||||
import { HMACAssertionError } from "../errors"
|
||||
import type { i18n } from "../i18n"
|
||||
import type { ItemDetails, Overview, Profile } from "../types"
|
||||
import { setIfAbsent } from "../util"
|
||||
import type { EncryptedItem } from "./item"
|
||||
|
||||
/** Encryption and MAC */
|
||||
export interface Cipher {
|
||||
/** Encryption key */
|
||||
key: Buffer
|
||||
/** HMAC key */
|
||||
hmac: Buffer
|
||||
}
|
||||
|
||||
export class Crypto implements IDisposable {
|
||||
#disposables: IDisposable[] = []
|
||||
#locked = true
|
||||
|
||||
#master!: Cipher
|
||||
#overview!: Cipher
|
||||
|
||||
private subtle: SubtleCrypto
|
||||
private hmacSHA256: IAdapter["hmacSHA256"]
|
||||
|
||||
readonly onLock = createEventEmitter<void>()
|
||||
|
||||
constructor(private readonly i18n: i18n, adapter: IAdapter) {
|
||||
this.subtle = adapter.subtle
|
||||
this.hmacSHA256 = adapter.hmacSHA256
|
||||
}
|
||||
|
||||
async unlock(profile: Profile, masterPassword: string) {
|
||||
const encoder = new TextEncoder()
|
||||
const key = await this.subtle.importKey(
|
||||
"raw",
|
||||
encoder.encode(masterPassword),
|
||||
{ name: "PBKDF2" },
|
||||
false,
|
||||
["deriveBits"]
|
||||
)
|
||||
const derivedKey = await this.subtle.deriveBits(
|
||||
{
|
||||
name: "PBKDF2",
|
||||
salt: Buffer.from(profile.salt, "base64"),
|
||||
iterations: profile.iterations,
|
||||
hash: {
|
||||
name: "SHA-512",
|
||||
},
|
||||
},
|
||||
key,
|
||||
64 << 3
|
||||
)
|
||||
|
||||
const cipher = splitPlainText(Buffer.from(derivedKey))
|
||||
|
||||
// Derive master key and overview keys
|
||||
this.#master = await this.decryptKeys(profile.masterKey, cipher)
|
||||
this.#overview = await this.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.onLock()
|
||||
}
|
||||
|
||||
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 = this.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 = this.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)
|
||||
this.assertHMac(data, this.#master.hmac, k.slice(-32))
|
||||
const derivedKey = decryptData(this.#master.key, data.slice(0, 16), data.slice(16))
|
||||
return splitPlainText(derivedKey)
|
||||
}
|
||||
)
|
||||
|
||||
assertHMac(data: Buffer, key: Buffer, expected: Buffer) {
|
||||
const actual = this.hmacSHA256(key, data)
|
||||
if (!actual.equals(expected)) {
|
||||
throw new HMACAssertionError()
|
||||
}
|
||||
}
|
||||
|
||||
decryptOPData(cipherText: Buffer, cipher: Cipher) {
|
||||
const key = cipherText.slice(0, -32)
|
||||
this.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)
|
||||
}
|
||||
|
||||
async decryptKeys(encryptedKey: string, derived: Cipher) {
|
||||
const buffer = Buffer.from(encryptedKey, "base64")
|
||||
const base = this.decryptOPData(buffer, derived)
|
||||
const digest = await this.subtle.digest("SHA-512", base)
|
||||
return splitPlainText(Buffer.from(digest))
|
||||
}
|
||||
|
||||
get overview() {
|
||||
return this.#overview
|
||||
}
|
||||
}
|
||||
|
||||
export const splitPlainText = (derivedKey: Buffer): Cipher => ({
|
||||
key: derivedKey.slice(0, 32),
|
||||
hmac: derivedKey.slice(32, 64),
|
||||
})
|
||||
|
||||
function decryptData(key: Buffer, iv: Buffer, data: Buffer) {
|
||||
return createDecipheriv("aes-256-cbc", key, iv).setAutoPadding(false).update(data)
|
||||
}
|
||||
|
||||
function readUint16({ buffer, byteOffset, length }: Buffer) {
|
||||
return new DataView(buffer, byteOffset, length).getUint16(0, true)
|
||||
}
|
44
packages/opvault.js/models/item.ts
Normal file
44
packages/opvault.js/models/item.ts
Normal file
@ -0,0 +1,44 @@
|
||||
import type { ItemDetails, Overview } from "../types"
|
||||
import type { Crypto } from "./crypto"
|
||||
import { Attachment } from "./attachment"
|
||||
|
||||
export interface EncryptedItem {
|
||||
category: string // "001"
|
||||
/** Unix seconds */
|
||||
created: integer
|
||||
d: string // "b3BkYXRhMbt"
|
||||
folder: string // 32 chars
|
||||
hmac: string // base64
|
||||
k: string // base64
|
||||
o: string // base64
|
||||
tx: integer // Unix seconds
|
||||
updated: integer // Unix seconds
|
||||
uuid: string // 32 chars
|
||||
}
|
||||
|
||||
export class Item {
|
||||
#crypto: Crypto
|
||||
#data: EncryptedItem
|
||||
|
||||
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(crypto: Crypto, data: EncryptedItem) {
|
||||
this.#crypto = crypto
|
||||
this.#data = data
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
addAttachment(buffer: Buffer) {
|
||||
this.attachments.push(new Attachment(this.#crypto, this.#data.k, buffer))
|
||||
}
|
||||
}
|
157
packages/opvault.js/models/vault.ts
Normal file
157
packages/opvault.js/models/vault.ts
Normal file
@ -0,0 +1,157 @@
|
||||
import type { IAdapter } from "../adapters"
|
||||
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 } from "../types"
|
||||
import { WeakValueMap } from "../weakMap"
|
||||
import { createEventEmitter } from "../ee"
|
||||
|
||||
// type FoldersMap = { [uuid: string]: { [uuid: string]: Item } }
|
||||
|
||||
/**
|
||||
* Main OnePassword Vault class
|
||||
*/
|
||||
export class Vault {
|
||||
#profile: Profile
|
||||
// #folders: FoldersMap
|
||||
#items: Item[] = []
|
||||
#itemsMap = new WeakValueMap<string, Item>()
|
||||
#crypto: Crypto
|
||||
|
||||
readonly onLock = createEventEmitter<void>()
|
||||
|
||||
private constructor(
|
||||
profile: Profile,
|
||||
// folders: FoldersMap,
|
||||
items: Item[],
|
||||
crypto: Crypto,
|
||||
itemsMap: WeakValueMap<string, Item>
|
||||
) {
|
||||
this.#profile = profile
|
||||
// this.#folders = folders
|
||||
this.#items = items
|
||||
this.#crypto = crypto
|
||||
this.#itemsMap = itemsMap
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new OnePassword Vault instance and read all bands.
|
||||
* @internal
|
||||
*/
|
||||
static async of(path: string, profileName = "default", adapter: IAdapter) {
|
||||
const crypto = new Crypto(i18n, adapter)
|
||||
const files = await OnePasswordFileManager(adapter.fs, path, profileName)
|
||||
const profile = JSON.parse(
|
||||
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: Record<string, EncryptedItem> = JSON.parse(
|
||||
stripText(source, "ld(", ");")
|
||||
)
|
||||
for (const value of Object.values(object)) {
|
||||
const item = new Item(crypto, value)
|
||||
bands.push(item)
|
||||
itemsMap.set(value.uuid, item)
|
||||
}
|
||||
}
|
||||
|
||||
const attachments = await files.getAttachments()
|
||||
for (const att of attachments) {
|
||||
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, bands, crypto, itemsMap)
|
||||
}
|
||||
|
||||
getOverview(uuid: string) {
|
||||
this.#crypto.assertUnlocked()
|
||||
return this.#items.find(x => x.uuid === uuid)?.overview
|
||||
}
|
||||
|
||||
keys() {
|
||||
return this.#items.map(x => x.uuid)
|
||||
}
|
||||
|
||||
/**
|
||||
* Unlock this OnePassword vault.
|
||||
* @param masterPassword User provided master password. Only the derived
|
||||
* master and overview key will be stored within the class.
|
||||
*/
|
||||
async unlock(masterPassword: string) {
|
||||
try {
|
||||
await this.#crypto.unlock(this.#profile, masterPassword)
|
||||
} catch (e) {
|
||||
if (e instanceof HMACAssertionError) {
|
||||
throw new Error(i18n.error.invalidPassword)
|
||||
}
|
||||
throw e
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove derived keys stored within the class instance.
|
||||
*/
|
||||
lock() {
|
||||
this.#crypto.lock()
|
||||
this.onLock()
|
||||
return this
|
||||
}
|
||||
|
||||
get isLocked() {
|
||||
return this.#crypto.locked
|
||||
}
|
||||
|
||||
getItem(uuid: string): Item | undefined
|
||||
getItem(filter: { title: string }): Item | undefined
|
||||
|
||||
getItem(filter: any) {
|
||||
this.#crypto.assertUnlocked()
|
||||
if (typeof filter === "string") {
|
||||
return this.#itemsMap.get(filter)
|
||||
} else {
|
||||
for (const value of this.#items) {
|
||||
if (value.overview.title === filter.title) {
|
||||
return value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function stripText(text: string, prefix: string | RegExp, suffix: string | RegExp) {
|
||||
if (typeof prefix === "string") {
|
||||
if (text.startsWith(prefix)) {
|
||||
text = text.slice(prefix.length)
|
||||
}
|
||||
} else {
|
||||
const prefixMatch = text.match(prefix)
|
||||
if (prefixMatch) {
|
||||
text = text.slice(prefixMatch[0].length)
|
||||
}
|
||||
}
|
||||
if (typeof suffix === "string") {
|
||||
if (text.endsWith(suffix)) {
|
||||
text = text.slice(0, -suffix.length)
|
||||
}
|
||||
} else {
|
||||
const suffixMatch = text.match(suffix)
|
||||
if (suffixMatch) {
|
||||
text = text.slice(0, -suffixMatch[0].length)
|
||||
}
|
||||
}
|
||||
return text
|
||||
}
|
162
packages/opvault.js/types.ts
Normal file
162
packages/opvault.js/types.ts
Normal file
@ -0,0 +1,162 @@
|
||||
import type { FieldType } from "./models"
|
||||
|
||||
type integer = number
|
||||
|
||||
export interface Profile {
|
||||
lastUpdatedBy: "Dropbox"
|
||||
/** Unix seconds */
|
||||
updatedAt: integer
|
||||
profileName: string
|
||||
/** base64 */
|
||||
salt: string
|
||||
/** base64 */
|
||||
masterKey: string
|
||||
iterations: integer // 50000
|
||||
uuid: string // 32 chars
|
||||
overviewKey: string // "b3B...IMO52D"
|
||||
/** Unix seconds */
|
||||
createdAt: integer
|
||||
}
|
||||
|
||||
export type TextField = {
|
||||
type: FieldType.Text
|
||||
value: string
|
||||
designation: string
|
||||
name: string
|
||||
}
|
||||
export type BooleanField = {
|
||||
type: FieldType.Checkbox
|
||||
name: string
|
||||
value?: "✓" | string
|
||||
}
|
||||
|
||||
export type ItemField =
|
||||
| TextField
|
||||
| BooleanField
|
||||
| {
|
||||
// @TODO: This currently catches all item fields.
|
||||
type: FieldType
|
||||
value: string
|
||||
designation?: string
|
||||
name: string
|
||||
}
|
||||
|
||||
declare namespace ItemSection {
|
||||
type A = {
|
||||
guarded: "yes"
|
||||
clipboardFilter?: string
|
||||
}
|
||||
|
||||
type String = {
|
||||
k: "string"
|
||||
v: string
|
||||
/** Unique name */
|
||||
n: string // "firstname" | "initial" | "address" | "class" | "conditions" | "expiry_date"
|
||||
a?: A
|
||||
/** User-readable title */
|
||||
t: string // "first name" | "initial" | "address" | "license class" | "conditions / restrictions" | "expiry date"
|
||||
}
|
||||
type Menu = {
|
||||
k: "menu"
|
||||
v: string // "female"
|
||||
a: A
|
||||
t: string // "sex"
|
||||
}
|
||||
type Date = {
|
||||
k: "date"
|
||||
v: number // 359100000
|
||||
/** @example "birthdate" */
|
||||
n: string
|
||||
a: A
|
||||
/** @example "birth date" | "date of birth" */
|
||||
t: string
|
||||
}
|
||||
type Gender = {
|
||||
k: "gender"
|
||||
n: "sex"
|
||||
v: string // "female"
|
||||
t: "sex"
|
||||
}
|
||||
type MonthYear = {
|
||||
k: "monthYear"
|
||||
n: string // "expiry_date"
|
||||
v: number // 2515
|
||||
t: string // "expiry date"
|
||||
}
|
||||
type Concealed = {
|
||||
k: "concealed"
|
||||
n: "password"
|
||||
v: string
|
||||
a?: {
|
||||
generate: "off"
|
||||
}
|
||||
t: "password"
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
type Any = String | Menu | Date | Gender | MonthYear | Concealed
|
||||
}
|
||||
|
||||
// One of them is empty?, 0C4F27910A64488BB339AED63565D148
|
||||
export interface ItemDetails {
|
||||
htmlForm?: {
|
||||
/** @example "/login/" */
|
||||
htmlAction: string
|
||||
htmlMethod: "post" | "get"
|
||||
}
|
||||
notesPlain?: string
|
||||
sections: {
|
||||
/** @example "name" | "title" | "internet" */
|
||||
name: string
|
||||
/** @example "Identification" | "Address" | "Internet Details" */
|
||||
title: string
|
||||
fields?: ItemSection.Any[]
|
||||
}[]
|
||||
/** Web form fields */
|
||||
fields?: ItemField[]
|
||||
}
|
||||
|
||||
export interface Folder {
|
||||
created: number // 1373754128
|
||||
/** base64 */
|
||||
overview: string
|
||||
smart?: true
|
||||
tx: number // 1373754523
|
||||
/** Updated time in UNIX seconds */
|
||||
updated: number
|
||||
uuid: string // "AC78552EB06A4F65BEBF58B4D9E32080"
|
||||
}
|
||||
|
||||
export interface Overview {
|
||||
/**
|
||||
* Password strength.
|
||||
*
|
||||
* `ps` is the value for manually entered passwords and is used to display
|
||||
* the relative strength value.
|
||||
*/
|
||||
ps: number
|
||||
|
||||
/**
|
||||
* Password Bits of Entropy used for generating the password.
|
||||
*/
|
||||
pbe?: number
|
||||
|
||||
/**
|
||||
* https://1password.community/discussion/comment/372147/#Comment_372147
|
||||
* pbe should be used for password strength display
|
||||
*/
|
||||
pgrng?: boolean
|
||||
title?: string
|
||||
ainfo?: string
|
||||
url?: string
|
||||
tags?: string[]
|
||||
URLs?: { u: string }[]
|
||||
appIds: {
|
||||
/** @example 'Firefox' | 'Email' */
|
||||
name: string
|
||||
/** @example 'accessibility://login' */
|
||||
type: string
|
||||
/** @example 'android://...' */
|
||||
id: string
|
||||
}[]
|
||||
}
|
53
packages/opvault.js/util.ts
Normal file
53
packages/opvault.js/util.ts
Normal file
@ -0,0 +1,53 @@
|
||||
import { invariant } from "./errors"
|
||||
|
||||
export function asyncMap<T, R>(
|
||||
list: T[],
|
||||
fn: (value: T, index: number, list: T[]) => Promise<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
|
||||
const res = function (this: ThisParameterType<T>) {
|
||||
if (executed) {
|
||||
return result
|
||||
} else {
|
||||
result = fn.apply(this, arguments as any as Parameters<T>)
|
||||
executed = true
|
||||
return result
|
||||
}
|
||||
}
|
||||
return res as any
|
||||
}
|
||||
|
||||
const cache = (): MethodDecorator => (_, key, descriptor: any) => {
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
invariant(typeof key === "string")
|
||||
invariant(descriptor.get != null)
|
||||
}
|
||||
|
||||
const cacheMap = new WeakMap()
|
||||
const fn = descriptor.get
|
||||
descriptor.get = function () {
|
||||
if (!cacheMap.has(this)) {
|
||||
cacheMap.set(this, fn.call(this))
|
||||
}
|
||||
return cacheMap.get(this)!
|
||||
}
|
||||
}
|
40
packages/opvault.js/weakMap.ts
Normal file
40
packages/opvault.js/weakMap.ts
Normal file
@ -0,0 +1,40 @@
|
||||
export class WeakValueMap<K, V extends object> {
|
||||
#map = new Map<K, WeakRef<V>>()
|
||||
|
||||
#delete = (key: K) => {
|
||||
const value = this.#map.get(key)!
|
||||
this.#finalizers.unregister(value)
|
||||
this.#map.delete(key)
|
||||
return false
|
||||
}
|
||||
|
||||
#finalizers = new FinalizationRegistry((key: K) => {
|
||||
this.#map.delete(key)
|
||||
})
|
||||
|
||||
delete(key: K) {
|
||||
return this.#map.has(key) && !this.#delete(key)
|
||||
}
|
||||
|
||||
has(key: K) {
|
||||
const has = this.#map.has(key)
|
||||
const value = has && !this.#map.get(key)!.deref()
|
||||
return value ? this.#delete(key) : has
|
||||
}
|
||||
|
||||
get(key: K) {
|
||||
return this.#map.get(key)?.deref()
|
||||
}
|
||||
|
||||
set(key: K, value: V) {
|
||||
this.delete(key)
|
||||
const ref = new WeakRef(value)
|
||||
this.#finalizers.register(value, key, ref)
|
||||
this.#map.set(key, ref)
|
||||
return this
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.#map.clear()
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user